From 8a046776ad87bc9ec80d5fc2d60c4570251e2149 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Sat, 20 Jun 2026 00:13:55 +0900 Subject: [PATCH] tsvm new api: con.setFullscreen --- CLAUDE.md | 89 +++++++++++++++++------ assets/disk0/tvdos/TVDOS.SYS | 19 ++++- assets/disk0/tvdos/VTMGR.SYS | 57 +++++++++++---- assets/disk0/tvdos/bin/playmov.js | 14 +++- assets/disk0/tvdos/bin/playtaud.js | 12 ++- assets/disk0/tvdos/bin/taut.js | 7 ++ assets/disk0/tvdos/bin/zfm.js | 7 ++ assets/disk0/tvdos/hopper/tvdos.hop.per | 2 +- tsvm_core/src/net/torvald/tsvm/JS_INIT.js | 34 +++++++++ 9 files changed, 195 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2d4e47b..667c677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -556,27 +556,61 @@ arithmetic (no regression outside vtmgr). Applied so far in `assets/disk0/tvdos/bin/taut.js` and `assets/disk0/hopper/include/aa.mjs` (used by `bb.js`). Any future direct-VRAM app needs the same one-line `vaddr`. -### Raw-keyboard apps must grab (the `con.grabRawKeyboard` pattern) +### Fullscreen apps declare themselves (the `con.setFullscreen` pattern) -Fullscreen apps that poll the **raw key snapshot** (`sys.poke(-40,1)` then -`sys.peek(-41..-48)`) directly — e.g. the DOOM port's `i_input.mjs` — bypass the -pane input ring entirely. But the dispatcher keeps the cooked collector (`-39`) -on and drains typed chars into the *active* pane's ring every frame. While such -an app is the active pane, every keystroke piles into a ring it never reads, and -floods its parent shell the instant the app exits (no bug outside vtmgr, where -`-39` is off while a raw app runs). Fix: the pane bootstrap exposes -`con.grabRawKeyboard()` / `con.releaseRawKeyboard()` (write the active VT number -into `CTRL+CTRL_RAW_GRAB_VT`); while the active pane holds the grab the -dispatcher discards cooked chars and keeps that pane's ring flushed. `con.getch` -self-heals a grab leaked by a crashed app (a cooked reader isn't a grabber). -A raw-input app feature-detects (`typeof con.grabRawKeyboard === "function"`) and -grabs/releases around its fullscreen session — DOOM does it in -`i_video.mjs` `I_InitGraphics`/`I_ShutdownGraphics` (covers every fullscreen -mode; shutdown runs in `wadplayer.js`'s `finally`). Complementary: such an app's -poll should also no-op when it's *not* the active VT (compare `VT_CTRL_ADDR` -byte 0 to `VT_NUM`) so a backgrounded app doesn't eat the foreground console's -input — DOOM's `I_PollKeys` does this. Any future raw-key app under vtmgr needs -both. +A **fullscreen app** paints the whole screen and polls the **raw key snapshot** +(`sys.poke(-40,1)` then `sys.peek(-41..-48)`) directly — e.g. the DOOM port's +`i_input.mjs`, or `playmov` — bypassing the pane input ring. Two problems arise +only under vtmgr: (1) the dispatcher keeps the cooked collector (`-39`) on and +drains typed chars into the *active* pane's ring every frame, so while a raw app +is the active pane every keystroke piles into a ring it never reads and floods +its parent shell the instant it exits (no bug outside vtmgr, where `-39` is off +while a raw app runs — `readKey` clears it); (2) a *backgrounded* raw app would +still read the physical snapshot, eating the foreground console's input. + +This is now **first-class**: an app declares itself fullscreen in **one line** and +the right thing happens whether or not vtmgr is present. The API lives on the base +`con` (JS_INIT.js) so it is always defined — **no feature detection**: + +- `con.setFullscreen(true)` on entry / `con.setFullscreen(false)` on exit. + Bare metal: state-only no-op. Under vtmgr (pane override): grabs/releases the + dispatcher's cooked-input feed via `CTRL+CTRL_RAW_GRAB_VT` (flush type-ahead on + grab; the dispatcher keeps the ring empty while held). `con.getch` self-heals a + grab leaked by a crashed app (a cooked reader isn't a grabber). +- `con.isActiveConsole()` — true on bare metal; under vtmgr, true only while this + pane is the foreground VT. Raw apps that read MMIO directly (keys AND mouse) + gate their reads on this so a backgrounded app reads nothing. +- `con.poll_keys()` is **auto-guarded**: it returns all-zeros unless + `con.isActiveConsole()`, so an app that reads keys through `con.poll_keys()` + (e.g. `playmov`, `playtaud`) needs no explicit active check — just the + `setFullscreen` declaration. +- `input.withEvent()` (TVDOS.SYS, the shared key/mouse event API that reads the + raw snapshot for `taut`/`zfm`/`edit`/…) is **also auto-guarded**: when + `!con.isActiveConsole()` it zeros the key snapshot and pins the mouse, so a + backgrounded `withEvent` app emits no events. Such apps therefore only need the + `setFullscreen` declaration too. + +The pane's `con.setFullscreen(true)` claims the grab **only while it is the +foreground VT**, so an app may re-assert it every frame (the simplest way to +re-establish the grab after launching a sub-program — `taut`/`zfm` do this at the +top of their event loop) without a backgrounded app clobbering the active +grabber's claim on the single `CTRL_RAW_GRAB_VT` byte. A single up-front claim +also survives backgrounding (nobody else clears it; the app re-claims on return). + +`con.grabRawKeyboard()`/`con.releaseRawKeyboard()` remain as deprecated thin +aliases for `con.setFullscreen(true/false)`. Consumers: +- **DOOM** declares fullscreen in `i_video.mjs` `I_InitGraphics`/`I_ShutdownGraphics` + (shutdown runs in `wadplayer.js`'s `finally`) and gates `i_input.mjs` `I_PollKeys` + (keys + mouse, read via raw MMIO) on `con.isActiveConsole()`. +- **`playmov`**, **`playtaud`** declare fullscreen around their session and read + keys through the auto-guarded `con.poll_keys()` (no explicit active check). +- **`taut`**, **`zfm`** re-assert `con.setFullscreen(true)` at the top of their + `input.withEvent` loop (and release on teardown); the active check is automatic + via `input.withEvent`. + +Any future fullscreen app just calls `con.setFullscreen(true/false)`; if it reads +keys via `con.poll_keys()` or `input.withEvent()` the active guard is automatic, +and only an app that reads MMIO with bespoke code needs `con.isActiveConsole()`. ### Files @@ -590,10 +624,17 @@ both. - `assets/disk0/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`) - `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`: `vaddr` VT-aware direct-VRAM addressing -- `assets/disk0/tvdos/VTMGR.SYS`: `CTRL_RAW_GRAB_VT` flag + - `con.grabRawKeyboard`/`releaseRawKeyboard` (raw-keyboard apps); dispatcher - drain honours it. DOOM consumer: `assets/disk0/home/doom/i_video.mjs` - (grab/release) + `i_input.mjs` (active-VT poll guard) +- `tsvm_core/src/net/torvald/tsvm/JS_INIT.js`: base (bare-metal) `con.setFullscreen`/ + `isFullscreen`/`isActiveConsole` + auto-guarded `con.poll_keys`; grab/release aliases +- `assets/disk0/tvdos/VTMGR.SYS`: `CTRL_RAW_GRAB_VT` flag + VT-aware overrides of + `con.setFullscreen` (claim gated on being the foreground VT) / `isActiveConsole` +- `assets/disk0/tvdos/TVDOS.SYS`: `input.withEvent` auto-guard (zeros keys / pins + mouse when `!con.isActiveConsole()`) — covers every `withEvent` app +- Consumers: `assets/disk0/home/doom/i_video.mjs` (`setFullscreen` in/out) + + `i_input.mjs` (`isActiveConsole` guard for keys+mouse); + `assets/disk0/tvdos/bin/playmov.js` + `bin/playtaud.js` (`setFullscreen` + + auto-guarded `con.poll_keys`); `bin/taut.js` + `bin/zfm.js` (`setFullscreen` + re-asserted at the top of their `input.withEvent` loop) ### Gotcha: injectIntChk vs. embedded source diff --git a/assets/disk0/tvdos/TVDOS.SYS b/assets/disk0/tvdos/TVDOS.SYS index 106a4cd..c87a370 100644 --- a/assets/disk0/tvdos/TVDOS.SYS +++ b/assets/disk0/tvdos/TVDOS.SYS @@ -98,7 +98,7 @@ function generateRandomHashStr(len) { // define TVDOS const _TVDOS = {}; -_TVDOS.VERSION = "1.0"; +_TVDOS.VERSION = "1.4"; _TVDOS.DRIVES = {}; // Object where key-value pair is : [serial-port, drive-number] _TVDOS.DRIVEFS = {}; // filesystem driver for the drive letter _TVDOS.DRIVEINFO = {}; @@ -1151,6 +1151,23 @@ input.withEvent = function(callback) { let mb = sys.peek(-37) & 0xFF; // bits 0..2 = L/R/M held, bit 6 = wheel up, bit 7 = wheel down let mouse = [mx, my, mb]; + // Under vtmgr, a backgrounded console must not see the physical keyboard or + // mouse (another VT is in the foreground). con.isActiveConsole() is true on + // bare metal and on the foreground pane, false on a background pane. When + // inactive, zero the key snapshot and pin the mouse to its previous state so + // the dispatch below emits no key/mouse events (still pacing via sleep). This + // makes every input.withEvent app auto-ignore background input — no per-app + // active check needed; a fullscreen app only has to call con.setFullscreen(). + if (con.isActiveConsole && !con.isActiveConsole()) { + keys = [0,0,0,0,0,0,0,0]; + if (inputwork.oldMouse && inputwork.oldMouse.length === 3) { + mx = inputwork.oldMouse[0]; my = inputwork.oldMouse[1]; mb = inputwork.oldMouse[2] & 0x07; + } else { + mb = 0; + } + mouse = [mx, my, mb]; + } + // --- mouse dispatch --- let oldMouse = inputwork.oldMouse; let hasOld = oldMouse && oldMouse.length === 3; diff --git a/assets/disk0/tvdos/VTMGR.SYS b/assets/disk0/tvdos/VTMGR.SYS index fcf3538..3af832a 100644 --- a/assets/disk0/tvdos/VTMGR.SYS +++ b/assets/disk0/tvdos/VTMGR.SYS @@ -396,10 +396,11 @@ function queuePop() { return b } con.getch = function() { - // Reading cooked input means we are NOT a raw-keyboard grabber; drop any - // stale grab this VT left set (e.g. a fullscreen app that crashed without - // releasing) so the dispatcher resumes feeding our ring. - if (sys.peek(RAW_GRAB_ADDR) === VT_NUM) sys.poke(RAW_GRAB_ADDR, 0) + // Reading cooked input means we are NOT a fullscreen raw-keyboard owner; + // drop any stale grab this VT left set (e.g. a fullscreen app that crashed + // without calling setFullscreen(false)) so the dispatcher resumes feeding + // our ring. + if (sys.peek(RAW_GRAB_ADDR) === VT_NUM) { sys.poke(RAW_GRAB_ADDR, 0); con._fullscreen = false } while (true) { if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) { let k = queuePop() @@ -408,22 +409,46 @@ con.getch = function() { sys.sleep(20) } } -// A fullscreen app that reads the raw keyboard snapshot (-41..-48) directly, -// bypassing this ring, must grab so the dispatcher stops piling cooked chars -// into a ring it never drains. Flush any prior type-ahead on grab; the -// dispatcher keeps the ring empty while held. Release on exit (or the next -// con.getch self-heals a grab leaked by a crashed app). -con.grabRawKeyboard = function() { - sys.poke(RAW_GRAB_ADDR, VT_NUM) - sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) -} -con.releaseRawKeyboard = function() { - if (sys.peek(RAW_GRAB_ADDR) === VT_NUM) sys.poke(RAW_GRAB_ADDR, 0) +// ── Fullscreen / raw-keyboard session (VT-aware overrides) ───────────────── +// con.setFullscreen(true) declares this pane a fullscreen raw-keyboard owner: +// a fullscreen app reads the raw key snapshot (-41..-48) directly, bypassing +// this ring, so it grabs to stop the dispatcher piling cooked chars into a ring +// it never drains (they'd flood the shell the instant the app exits). Flush any +// prior type-ahead on grab; the dispatcher keeps the ring empty while held. +// setFullscreen(false) releases (a leaked grab also self-heals on the next +// con.getch). These override JS_INIT's bare-metal no-ops so an app's single +// con.setFullscreen(true) call does the right thing under vtmgr too. +con.setFullscreen = function(on) { + con._fullscreen = !!on + if (on) { + // Claim the grab only while we are the foreground VT. Apps may re-assert + // this every frame (e.g. taut/zfm, to re-establish it after launching a + // sub-program); a BACKGROUNDED fullscreen app must not clobber the active + // grabber's claim on the shared CTRL_RAW_GRAB_VT byte. The claim persists + // across a switch-away (nobody else clears it) and the app re-claims on + // return, so a single up-front claim also survives backgrounding. + if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) { + sys.poke(RAW_GRAB_ADDR, VT_NUM) + sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) + } + } else if (sys.peek(RAW_GRAB_ADDR) === VT_NUM) { + sys.poke(RAW_GRAB_ADDR, 0) + } } +con.isFullscreen = function() { return con._fullscreen } +// This pane owns the physical keyboard only while it is the foreground VT. +// con.poll_keys() (inherited from JS_INIT) and direct-MMIO raw-input apps gate +// their key/mouse reads on this, so a backgrounded app reads nothing. +con.isActiveConsole = function() { return sys.peek(ACTIVE_VT_ADDR) === VT_NUM } +// Deprecated aliases kept for older raw-keyboard apps; prefer setFullscreen(). +con.grabRawKeyboard = function() { con.setFullscreen(true) } +con.releaseRawKeyboard = function() { con.setFullscreen(false) } con.hitterminate = function() { return false } con.hiteof = function() { return false } con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) } -con.poll_keys = function() { return [0,0,0,0,0,0,0,0] } +// poll_keys is inherited from JS_INIT: it returns zeros unless isActiveConsole() +// (overridden above), so a backgrounded pane reads no keys while a foreground +// pane reads the physical snapshot — no pane-specific override needed. // ── TVDOS.SYS init flags + BIOS stub ─────────────────────────────────────── globalThis._TVDOS_IS_VT_PANE = true diff --git a/assets/disk0/tvdos/bin/playmov.js b/assets/disk0/tvdos/bin/playmov.js index a1dda0f..878d61a 100644 --- a/assets/disk0/tvdos/bin/playmov.js +++ b/assets/disk0/tvdos/bin/playmov.js @@ -195,6 +195,13 @@ let dec = null let stage = "open" // breadcrumb for the error log try { + // Fullscreen raw-keyboard app: one declaration. Under vtmgr it grabs the + // cooked-input feed (so keystrokes typed at this player don't flood the + // shell on exit), and con.poll_keys() in readInput() auto-ignores the + // keyboard while this console is backgrounded; on bare metal it is a no-op. + // Released in the finally below. + con.setFullscreen(true) + dec = mediadec.open(fullPath, decOpts) const info = dec.info @@ -265,8 +272,10 @@ try { // triggered so a held key fires once. Quit + ASCII/colour toggles work even // without -i; the rest of the transport is interactive-only. function readInput() { - sys.poke(-40, 1) - const key = sys.peek(-41) + // con.poll_keys() returns the raw key snapshot, but yields all-zeros + // while this console is backgrounded under vtmgr, so a backgrounded + // player never reacts to the foreground console's typing. + const key = con.poll_keys()[0] if (key == K.BACKSPACE) { quit = true; return } if (key && key !== lastKey) { if (key == K.A) { if (aa) toggleAscii() } // inert when aa.mjs is absent @@ -350,6 +359,7 @@ catch (e) { errorlevel = 1 } finally { + con.setFullscreen(false) if (dec) dec.close() if (aa && aaCtx) aa.close(aaCtx) if (errorlevel === 0) con.clear() diff --git a/assets/disk0/tvdos/bin/playtaud.js b/assets/disk0/tvdos/bin/playtaud.js index 14de09d..2922c27 100644 --- a/assets/disk0/tvdos/bin/playtaud.js +++ b/assets/disk0/tvdos/bin/playtaud.js @@ -486,6 +486,11 @@ audio.setSampleBank(0) // restore the bank window to bank 0 after probing // ── Console setup ─────────────────────────────────────────────────────────── con.curs_set(0) con.clear() +// Fullscreen raw-keyboard app: one declaration. Under vtmgr it grabs the +// dispatcher's cooked-input feed (so the visualiser's keystrokes don't flood the +// shell on exit), and con.poll_keys() below auto-ignores input while this console +// is backgrounded; a no-op on bare metal. Released in the finally. +con.setFullscreen(true) function mvprn(row, col, ch) { con.mvaddch(row, col, ch) } function mvtext(row, col, s) { con.move(row, col); print(s) } @@ -1255,8 +1260,10 @@ try { // Keyboard polling (mirrors playtad). Backspace exits; Up/Down switch // to the previous/next song (wrapping) when the file holds more than // one song. lastNavKey debounces so each press switches exactly once. - sys.poke(-40, 1) - const rawKey = sys.peek(-41) + // con.poll_keys() returns the raw key snapshot, but all-zeros while this + // console is backgrounded under vtmgr, so we never act on another + // console's keys. + const rawKey = con.poll_keys()[0] if (rawKey === 67) stopReq = true else if (rawKey !== lastNavKey && song.numSongs > 1) { if (rawKey === 19) // up = previous song @@ -1307,6 +1314,7 @@ catch (e) { errorlevel = 1 } finally { + con.setFullscreen(false) audio.stop(PLAYHEAD) con.move(ROW_BOT_BORDER + 1, 1) con.curs_set(1) diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 15d61ba..74648b7 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -6837,6 +6837,12 @@ let exitFlag = false let pendingExternalDraw = false while (!exitFlag) { + // Fullscreen app: (re)assert the raw-keyboard grab each frame so cooked chars + // never pile into this pane's ring (they'd flood the shell on exit), and so + // it is re-established after a sub-editor returns. input.withEvent below is + // auto-guarded by con.isActiveConsole(), so a backgrounded editor sees no + // input. Both are no-ops on bare metal. Released in the teardown. + con.setFullscreen(true) input.withEvent(event => { if (dispatchMouseEvent(event)) return if (event[0] !== "key_down") return @@ -6955,6 +6961,7 @@ while (!exitFlag) { } audio.stop(PLAYHEAD) +con.setFullscreen(false) resetAudioDevice() sys.free(SCRATCH_PTR) font.resetLowRom() diff --git a/assets/disk0/tvdos/bin/zfm.js b/assets/disk0/tvdos/bin/zfm.js index 7883fb9..c8d5279 100644 --- a/assets/disk0/tvdos/bin/zfm.js +++ b/assets/disk0/tvdos/bin/zfm.js @@ -1115,6 +1115,12 @@ let firstRunLatch = true let pendingPostExecDrain = false while (!exit) { + // Fullscreen app: (re)assert the raw-keyboard grab each frame so cooked chars + // never pile into this pane's ring (they'd flood the shell on exit), and so + // it is re-established after a launched program returns. input.withEvent + // below is auto-guarded by con.isActiveConsole(); both are no-ops on bare + // metal. Released after the loop. + con.setFullscreen(true) input.withEvent(event => { if (dispatchMouseEvent(event)) { @@ -1160,6 +1166,7 @@ while (!exit) { } } +con.setFullscreen(false) con.curs_set(1) con.clear() return 0 \ No newline at end of file diff --git a/assets/disk0/tvdos/hopper/tvdos.hop.per b/assets/disk0/tvdos/hopper/tvdos.hop.per index 90a9f5a..4227933 100644 --- a/assets/disk0/tvdos/hopper/tvdos.hop.per +++ b/assets/disk0/tvdos/hopper/tvdos.hop.per @@ -1,6 +1,6 @@ HopperManifestVersion:1 HopperPackageName:tvdos -HopperPackageVersion:1.0.0 +HopperPackageVersion:1.4.0 HopperPackageMaintainer:CuriousTorvald HopperProvides:tvdos; HopperRequires: diff --git a/tsvm_core/src/net/torvald/tsvm/JS_INIT.js b/tsvm_core/src/net/torvald/tsvm/JS_INIT.js index 76fb60c..ac0463f 100644 --- a/tsvm_core/src/net/torvald/tsvm/JS_INIT.js +++ b/tsvm_core/src/net/torvald/tsvm/JS_INIT.js @@ -544,9 +544,43 @@ con.reset_graphics = function() { }; // returns current key-down status con.poll_keys = function() { + if (!con.isActiveConsole()) return [0,0,0,0,0,0,0,0]; sys.poke(-40, 1); return [-41,-42,-43,-44,-45,-46,-47,-48].map(it => sys.peek(it)); }; + +// ── Fullscreen / raw-keyboard session ──────────────────────────────────────── +// A "fullscreen app" paints the whole screen and reads the raw keyboard snapshot +// (-41..-48) directly, bypassing cooked line input (con.getch). Under the virtual +// console manager (vtmgr) such an app must (a) tell the dispatcher to stop feeding +// cooked characters into its input ring, and (b) ignore the keyboard while its +// console is backgrounded. Both used to require the app to feature-detect and +// poke vtmgr's shared memory by hand; they are now first-class con methods so an +// app declares itself fullscreen in ONE line and the right thing happens whether +// or not vtmgr is present. +// +// On bare metal (no vtmgr) nothing competes for the keyboard and the active +// console is always "this" one, so setFullscreen() is state-only and +// isActiveConsole() is always true. The vtmgr pane bootstrap overrides +// setFullscreen()/isActiveConsole() (and the guard above in poll_keys()) with +// the VT-aware implementations. +con._fullscreen = false; +// Declare (true) or revoke (false) this app's ownership of the raw keyboard and +// full screen. Call con.setFullscreen(true) once when entering a fullscreen mode +// and con.setFullscreen(false) on exit. Always safe to call. +con.setFullscreen = function(on) { + con._fullscreen = !!on; +}; +con.isFullscreen = function() { return con._fullscreen; }; +// True while this console currently owns the physical keyboard. Always true on +// bare metal; under vtmgr it is true only while this pane is the foreground VT. +// Raw-input apps gate their direct MMIO key/mouse reads on this so a backgrounded +// app does not eat the foreground console's input. (con.poll_keys() already does.) +con.isActiveConsole = function() { return true; }; +// Deprecated aliases kept for older raw-keyboard apps; prefer setFullscreen(). +con.grabRawKeyboard = function() { con.setFullscreen(true); }; +con.releaseRawKeyboard = function() { con.setFullscreen(false); }; + // some utilities functions // TypedArray re-implementation