Compare commits

...

4 Commits

Author SHA1 Message Date
minjaesong
e27a01dca6 command.js: autocomplete by candidate window 2026-06-04 01:16:56 +09:00
minjaesong
35263eeaa4 command.js: commandrc and AUTOEXEC.BAT split 2026-06-03 23:15:34 +09:00
minjaesong
d223adda25 command.js: left/right cursoring 2026-06-03 22:50:54 +09:00
minjaesong
a9d095e3cb tvdos: concurrency and VT 2026-06-03 20:49:59 +09:00
10 changed files with 1108 additions and 64 deletions

109
CLAUDE.md
View File

@@ -436,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
- Incorrect stereo imaging and extreme side channel distortion
- Severe frequency response errors that manifest as "clipping-like" distortion
## Virtual Consoles (vtmgr)
Linux-style virtual consoles for TVDOS: up to 6 independent shell sessions,
switched with **Alt-1..Alt-6** or the **`chvt N`** builtin, **Alt-0** to exit.
Implemented entirely in JS — **no tsvm_core changes**.
### Architecture
- **Dispatcher**: `assets/disk0/tvdos/sbin/vtmgr.js`. Launched directly by the
`TVDOS.SYS` boot block (only when `!_TVDOS_IS_VT_PANE`); when it exits (Alt-0)
the boot block runs `AUTOEXEC.BAT` as the bare fallback shell. Owns the
physical keyboard and screen. Each VT runs in its own GraalVM context/thread
via the existing `parallel.spawnNewContext` / `attachProgram` / `launch` API
(see `VMJSR223Delegate.kt` `class Parallel`). VT 1 spawns at boot; VT 2-6 are
lazy-spawned on first switch and re-spawned if their shell exits.
- **Concurrency model**: truly concurrent — switching works mid-command, not
just at the prompt. Background panes keep running (no `Thread.suspend`; it is
unusable on JDK 21). A cooperative gate inside the shimmed `con.getch` parks
panes blocked on input; CPU-bound background panes are allowed to run.
- **Shared memory**: one `sys.malloc` region holds a control block (active VT,
switch request, debounce, spawned-bits) plus, per VT, an input ring buffer and
a 7682-byte text-plane buffer mirroring the GPU text-area layout
(cursor 2 + fore 2560 + back 2560 + char 2560).
- **Compositor** (30 Hz): blits the active VT's text plane to the physical GPU
text area via `sys.memcpy`, and pushes that VT's cursor-visibility into the GPU
blink bit (MMIO attribute byte 6, addressed at `-1 - (131072*gpuSlot + 6)`).
- **Boot config split (`commandrc` + `AUTOEXEC.BAT`)**: environment setup and
app-launch are split into two files so panes can replay one without the other.
`\commandrc` holds the `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD) and is
run by the `TVDOS.SYS` boot block in **every** context (boot and pane) — it has
no `.BAT` extension, so the boot block runs it line-by-line (`set` mutates the
shared `_TVDOS.variables`, so the effect persists). `\AUTOEXEC.BAT` is the
**per-console launch** script (Korean IME `tvdos/i18n/korean`, then
`command -fancy`); it is run once per console — by each pane's bootstrap, and
by the boot block as the post-vtmgr fallback. No env snapshot/replay anymore;
each pane gets PATH/KEYBOARD/etc. natively from `commandrc`, and Korean IME
(a per-context `unicode.uniprint` handler) now registers in every pane.
- **Per-pane bootstrap**: each pane re-evals `TVDOS.SYS` (with `_TVDOS_IS_VT_PANE`
set — which makes the boot block run `commandrc` but skip the vtmgr/AUTOEXEC
launch — and a `_BIOS` stub captured live from the main context) then runs
`command -c \AUTOEXEC.BAT`, all in ONE direct `eval` so the launcher shares
scope with `_TVDOS`/`files`/`execApp`.
### Output/input shimming (in the pane bootstrap)
`con` and the global `print`/`println` family are plain JS, so the bootstrap
overrides them to read/write the per-VT shared-memory buffers instead of the
physical GPU. **`sys` and `graphics` are host objects and CANNOT be overridden
from JS** — this is the key constraint that shapes everything below.
- The shimmed `print` is a faithful JS port of the GPU's TTY interpreter
(`GlassTty.acceptChar` + `GraphicsAdapter` handlers): control bytes, the
`\x84<decimal>u` "emit char by code" escape (used by `con.prnch`), CSI cursor
moves / erase / SGR colours, and the `?25` cursor-visibility private sequence.
A swallow-only parser is NOT enough — TVDOS apps drive the screen through
these `print` escapes.
- `con.move`/`con.getyx` are **1-based** (mirroring `graphics.setCursorYX`'s
`cx-1` and `getCursorYX`'s `cx+1`); `con.addch` does NOT advance the cursor
(matches `graphics.putSymbol`), while `con.prnch` DOES.
- `command.js`'s `shell.execute` reassigns the global print family to
`shell.stdio.out.*`, which call `sys.print` (→ physical GPU). `shell.stdio.out`
was made to delegate to a `globalThis.__VT_OUT` hook when present (set by the
bootstrap); outside a VT the hook is absent and the path is byte-identical.
### Direct-VRAM apps need a VT-aware base (the `vaddr` pattern)
Apps that write the text area directly via `graphics.getGpuMemBase()` (rather
than `con.*`/`print`) bypass the shims and paint the physical screen, invading
whatever VT is visible. They must resolve text-area byte `m` through a
VT-aware base:
```js
// physical: backward (byte m at gpuBase - m) — getDev inverts to forward-native
// VT pane: forward (byte m at VT_TEXT_PLANE + m, the pane buffer the compositor blits)
const VT = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
const VRAM_BASE = VT ? globalThis.VT_TEXT_PLANE : (graphics.getGpuMemBase() - 253950)
const VRAM_SGN = VT ? 1 : -1
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
```
`sys.memcpy`/`sys.pokeBytes` copy forward in the resolved native memory, so this
works for both directions. The physical branch is identical to the original
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`.
### Files
- New: `assets/disk0/tvdos/sbin/vtmgr.js` (dispatcher + per-pane bootstrap)
- `assets/disk0/tvdos/bin/command.js`: `chvt` builtin, `[N]` prompt prefix for
VT 2-6, `shell.stdio.out` → `__VT_OUT` delegation
- `assets/disk0/tvdos/TVDOS.SYS`: boot block runs `\commandrc` (env) in every
context, then — only when `!_TVDOS_IS_VT_PANE` — launches `tvdos/sbin/vtmgr`
and, on its exit, `\AUTOEXEC.BAT` as the fallback shell
- `assets/disk0/commandrc`: env-only `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD)
- `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
### Gotcha: injectIntChk vs. embedded source
`execApp`/`require` run a program's source through `injectIntChk` (TVDOS.SYS),
which sed-rewrites the **first** `while`/`for`/`do` of each kind to call a
per-exec `tvdosSIGTERM_<hash>()` SIGTERM check. When vtmgr embeds the pane
bootstrap as a string literal, one of those rewrites can land inside the literal
— and the pane context has no such symbol. vtmgr strips them from the bootstrap
string with `raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')`. Any future
code that builds executable source as a string literal must do the same.

View File

@@ -1,12 +1,11 @@
echo "Starting TVDOS..."
rem put set-xxx commands here:
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
set INCLPATH=\hopper\include;$INCLPATH
set HELPPATH=\hopper\help;$HELPPATH
set KEYBOARD=us_colemak
rem this line specifies which shell to be presented after the boot precess:
rem AUTOEXEC.BAT -- per-console launch script. Run once for every console:
rem each virtual-console pane runs it (via vtmgr's bootstrap), and the boot
rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
rem
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
rem run per-console here rather than once at boot.
tvdos/i18n/korean
zfm
rem The interactive shell for this console.
command -fancy

9
assets/disk0/commandrc Normal file
View File

@@ -0,0 +1,9 @@
rem commandrc -- environment setup, run by TVDOS.SYS in EVERY context
rem (the boot shell AND every virtual-console pane). Put `set` commands and
rem other env-only configuration here. Do NOT launch apps from this file:
rem app launches belong in AUTOEXEC.BAT (run per-console by vtmgr).
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
set INCLPATH=\hopper\include;$INCLPATH
set HELPPATH=\hopper\help;$HELPPATH
set KEYBOARD=us_colemak

View File

@@ -1471,9 +1471,40 @@ try {
serial.println("Warning: Could not load HSDPA driver: " + e.message)
}
// Boot script
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
// Boot script. The work is split across two files:
// \commandrc -- environment (`set` commands); run in EVERY context.
// \AUTOEXEC.BAT -- per-console launch (IME + interactive shell).
// vtmgr re-evaluates TVDOS.SYS inside each per-VT pane; a pane sets
// _TVDOS_IS_VT_PANE so it only replays the environment here and leaves the
// AUTOEXEC launch to vtmgr's pane bootstrap (which avoids recursively
// spawning vtmgr inside a pane).
{
let cmdsrc = files.open("A:/tvdos/bin/command.js").sread()
let runBatch = (path) => eval(`var _BAT=function(exec_args){${cmdsrc}\n};_BAT`)(["", "-c", path])
let cmdfile = files.open("A:/tvdos/bin/command.js")
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
// Environment first, boot and pane alike. Gives every pane the same
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
// \commandrc has no .BAT extension (so command.js's batch-file path,
// which keys off the extension, won't pick it up); run it line-by-line.
// `set` mutates the shared _TVDOS.variables, so the effect persists across
// the per-line shell invocations. Skip blanks and `rem` comments.
let rcFile = files.open("A:/commandrc")
if (rcFile.exists) {
rcFile.sread().split('\n').forEach((line) => {
let t = line.trim()
if (t.length > 0 && !/^rem(\s|$)/i.test(t)) runBatch(line)
})
}
if (typeof _TVDOS_IS_VT_PANE === "undefined" || !_TVDOS_IS_VT_PANE) {
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
// Boot console: hand the screen to the virtual-console multiplexer.
// When it exits (Alt-0), fall through to AUTOEXEC so the console is
// never left bare.
runBatch("tvdos/sbin/vtmgr")
runBatch("\\AUTOEXEC.BAT")
}
else {
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
}
}

View File

@@ -30,7 +30,18 @@ function makeHash() {
const shellID = makeHash()
function print_prompt_text() {
// VT pane indicator: shown for VT 2..6, not VT 1 (the default) so the
// unmodified prompt is what users see when they never touch virtual
// consoles. VT_NUM is set by vtmgr's pane bootstrap.
let vtPrefix = ""
if (typeof VT_NUM !== "undefined" && VT_NUM > 1) vtPrefix = "[" + VT_NUM + "] "
if (goFancy) {
if (vtPrefix) {
con.color_pair(161,253)
print(`\u00DD${VT_NUM}`)
con.color_pair(253,161)
con.addch(16);con.curs_right()
}
con.color_pair(239,161)
print(" "+CURRENT_DRIVE+":")
con.color_pair(161,253)
@@ -49,9 +60,9 @@ function print_prompt_text() {
else {
// con.color_pair(253,255)
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
else
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
}
}
@@ -620,6 +631,21 @@ shell.coreutils = {
},
panic: function(args) {
throw Error("Panicking command.js")
},
chvt: function(args) {
// Request a switch to another virtual console. Only meaningful when
// running inside a pane spawned by vtmgr (VT_CTRL_ADDR is set by the
// pane bootstrap). Outside that environment this is a no-op error.
if (args[1] === undefined) { printerrln("Usage: chvt N (1..6)"); return 1 }
let n = parseInt(args[1])
if (isNaN(n) || n < 1 || n > 6) { printerrln("chvt: N must be in 1..6"); return 1 }
if (typeof VT_CTRL_ADDR === "undefined") {
printerrln("chvt: not running under vtmgr (no VT context)"); return 1
}
// CTRL_SWITCH_REQUEST is byte +1 of the shared CTRL area. Dispatcher
// picks this up on its next 30 Hz tick and performs the switch.
sys.poke(VT_CTRL_ADDR + 1, n)
return 0
}
}
// define command aliases here
@@ -636,10 +662,14 @@ shell.coreutils.where = shell.coreutils.which
Object.freeze(shell.coreutils)
shell.stdio = {
out: {
print: function(s) { sys.print(s) },
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
// When running inside a vtmgr virtual console, __VT_OUT routes output
// to the pane's text-plane buffer instead of the physical GPU (which
// the compositor would otherwise overwrite). Outside a VT the hook is
// absent and these fall through to sys.print exactly as before.
print: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.print(s); else sys.print(s) },
println: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.println(s); else { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") } },
printerr: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerr(s); else sys.print("\x1B[31m"+s+"\x1B[m") },
printerrln: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerrln(s); else { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") } },
},
pipe: {
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
@@ -955,6 +985,180 @@ shell.removePipe = function() {
Object.freeze(shell)
_G.shell = shell
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// TAB AUTOCOMPLETION
//
// Invoked by TAB at the interactive prompt. Only active when BOTH:
// 1. wintex.mjs is available (provides the selection popup), AND
// 2. goFancy == true.
// One candidate -> expand immediately (no popup).
// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to
// discard. The popup over-draws the screen without saving
// what was beneath it, so we snapshot the text plane before
// and copy it back after (the shell can't just redraw like a
// full-screen TUI — there's scrollback above the prompt).
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable.
let _acWin = undefined
function getAutocompleteWin() {
if (_acWin !== undefined) return _acWin
_acWin = null
try {
let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs)
if (w && typeof w.showDialog === "function") _acWin = w
} catch (e) {
debugprintln("command.js > autocomplete: wintex unavailable: " + e)
}
return _acWin
}
// List a directory's entries, swallowing any IO error.
function _acListDir(fullPath) {
try {
let f = files.open(fullPath)
if (!f.exists || !f.isDirectory) return []
return f.list() || []
} catch (e) { return [] }
}
// Strip a trailing PATHEXT extension so command names show without ".js" etc.
function _acStripExt(name) {
let lower = name.toLowerCase()
let exts = (_TVDOS.variables.PATHEXT || "").split(';').filter(function(e){ return e.length > 0 })
for (let i = 0; i < exts.length; i++) {
let e = exts[i].toLowerCase()
if (lower.endsWith(e)) return name.substring(0, name.length - e.length)
}
return name
}
// Candidates for the command position (first word, no path separators):
// shell built-ins + runnable files found along the current dir, drive root and PATH.
function _acCommandCandidates(prefix) {
let lower = prefix.toLowerCase()
let seen = {}
let out = []
function add(name) {
let k = name.toLowerCase()
if (seen[k]) return
seen[k] = true
out.push({ label: name, value: name + ' ', isDir: false })
}
// shell built-ins (and their aliases)
Object.keys(shell.coreutils).forEach(function(k) {
if (k.toLowerCase().startsWith(lower)) add(k)
})
// runnable files: search the same places shell.execute does, in the same order
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
.filter(function(e){ return e.length > 0 }).map(function(e){ return e.toLowerCase() })
let dirFulls = [shell.resolvePathInput('.').full] // current directory first
_TVDOS.getPath().forEach(function(d) {
dirFulls.push((d === '' || d === undefined) ? `${CURRENT_DRIVE}:\\` : shell.resolvePathInput(d).full)
})
dirFulls.forEach(function(full) {
_acListDir(full).forEach(function(it) {
if (it.isDirectory) return
let nameLower = (it.name || '').toLowerCase()
if (!exts.some(function(e){ return nameLower.endsWith(e) })) return // only runnables
let stripped = _acStripExt(it.name)
if (stripped.toLowerCase().startsWith(lower)) add(stripped)
})
})
return out
}
// Candidates for a path argument. The word may carry a directory prefix
// (kept verbatim) and a partial basename that we match against the directory.
function _acPathCandidates(word) {
let sepIdx = Math.max(word.lastIndexOf('\\'), word.lastIndexOf('/'))
let dirPart, basePart, listArg
if (sepIdx >= 0) {
dirPart = word.substring(0, sepIdx + 1) // includes the trailing separator
basePart = word.substring(sepIdx + 1)
listArg = dirPart
} else {
dirPart = ''
basePart = word
listArg = '.'
}
let resolved = shell.resolvePathInput(listArg)
if (resolved === undefined) return []
let sep = (dirPart.length > 0 && dirPart.charAt(dirPart.length - 1) === '/') ? '/' : '\\'
let lower = basePart.toLowerCase()
let out = []
_acListDir(resolved.full).forEach(function(it) {
let name = it.name || ''
if (!name.toLowerCase().startsWith(lower)) return
out.push({
// directories get a trailing separator so completion can continue into them;
// files get a trailing space so the next argument can be typed straight away.
label: name + (it.isDirectory ? '\\' : ''),
value: dirPart + name + (it.isDirectory ? sep : ' '),
isDir: it.isDirectory
})
})
return out
}
// Work out what is being completed at `caret` within `line`.
// Returns { wordStart, word, candidates } (candidates sorted by label).
function computeCompletion(line, caret) {
let wordStart = caret
while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1
let word = line.substring(wordStart, caret)
let isFirstWord = (line.substring(0, wordStart).trim().length === 0)
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
let candidates = (isFirstWord && !hasPathSep) ? _acCommandCandidates(word) : _acPathCandidates(word)
candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 })
return { wordStart: wordStart, word: word, candidates: candidates }
}
// --- text-plane snapshot/restore (so the popup leaves no artefacts) ---------
// In a vtmgr pane the shimmed con/print draw into the pane buffer
// (globalThis.VT_TEXT_PLANE, forward layout); on the physical console they
// draw into the GPU text area (mapped at getGpuMemBase()-253950). vaddr(0) is
// that base in either case; sys.memcpy reads/writes it forward-native.
// NOTE: 7681, not the full 7682-byte text area: relPtrInDev() bounds-checks
// `from+len` inclusively, so the final byte (bottom-right char cell, never
// touched by a centred popup) is unreachable by a single memcpy.
const _AC_TEXTAREA_BYTES = 7681
let _acTextBase = null
let _acScratchPtr = 0
function _acTextAreaBase() {
if (_acTextBase === null) {
_acTextBase = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
? globalThis.VT_TEXT_PLANE
: (graphics.getGpuMemBase() - 253950)
}
return _acTextBase
}
function _acSnapshotScreen() {
if (_acScratchPtr === 0) _acScratchPtr = sys.malloc(_AC_TEXTAREA_BYTES)
sys.memcpy(_acTextAreaBase(), _acScratchPtr, _AC_TEXTAREA_BYTES)
}
function _acRestoreScreen() {
if (_acScratchPtr === 0) return
sys.memcpy(_acScratchPtr, _acTextAreaBase(), _AC_TEXTAREA_BYTES)
}
// Modal popup of candidates. Returns the chosen item, or null if discarded.
function _acShowPopup(win, candidates) {
let res = win.showDialog({
title: `Complete (${candidates.length})`,
list: {
items: candidates,
height: Math.min(12, candidates.length),
onActivate: function(item, idx, key) { return 'select' }
},
buttons: [{ label: 'Cancel', action: 'cancel' }]
})
if (res && res.action === 'select' && res.listItem) return res.listItem
return null
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// ensure USERCONFIGPATH directory exists
@@ -1012,23 +1216,133 @@ if (goInteractive) {
print_prompt_text()
var cmdbuf = ""
var caret = 0 // insertion point within cmdbuf, 0..cmdbuf.length
// Self-contained line editor with a movable caret (so command.js does
// NOT depend on wintex being installed). The prompt has just been
// printed, so the current cursor marks where the editable text begins.
// We track that anchor and rebuild the on-screen line from it, decoding
// line-wrap ourselves so the maths holds in both the physical console
// and a vtmgr pane (whose con.move CLAMPS x instead of wrapping it).
let [baseY, baseX] = con.getyx() // 1-based
let termCols = con.getmaxyx()[1]
// absolute (y,x) on screen for caret index `idx`
function caretPos(idx) {
let abs = (baseX - 1) + idx
return [baseY + ((abs / termCols) | 0), (abs % termCols) + 1]
}
function gotoCaret() {
let [cy, cx] = caretPos(caret)
con.move(cy, cx)
}
// reprint cmdbuf from index `from` to the end, optionally padding with
// `clearTrail` blanks to wipe characters left over by a now-shorter
// line, then park the hardware cursor back on the caret.
function refresh(from, clearTrail) {
let [py, px] = caretPos(from)
con.move(py, px)
print(cmdbuf.substring(from))
for (let i = 0; i < clearTrail; i++) print(" ")
gotoCaret()
}
// replace the whole buffer (used by history recall)
function setBuf(next) {
let oldLen = cmdbuf.length
cmdbuf = next
caret = cmdbuf.length
refresh(0, Math.max(0, oldLen - cmdbuf.length))
}
// Replace the word [wordStart, caret) with `value`, keeping any text to
// the right of the caret, then reprint the line from `wordStart`.
function applyCompletion(wordStart, value) {
let oldLen = cmdbuf.length
cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret)
caret = wordStart + value.length
con.color_pair(shell.usrcfg.textCol, 255)
refresh(wordStart, Math.max(0, oldLen - cmdbuf.length))
}
// TAB handler. No-op unless fancy mode is on and wintex is installed.
function tryAutocomplete() {
if (!goFancy) return
let win = getAutocompleteWin()
if (!win) return
let comp = computeCompletion(cmdbuf, caret)
let cands = comp.candidates
if (cands.length === 0) return
if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return }
_acSnapshotScreen()
let chosen = _acShowPopup(win, cands)
_acRestoreScreen()
// The popup drives input through input.withEvent (physical held-key
// state), which bypasses the buffer con.getch reads. Inside a vtmgr
// pane the dispatcher keeps draining physical keystrokes into this
// pane's input ring the whole time the popup is open, so the navigation
// keys (and the closing Enter) would otherwise surface as phantom input
// afterwards. Flush them. (On the physical console readKey self-clears,
// so this is harmless there.)
con.resetkeybuf()
// The popup hid the caret and clobbered colours; restore the prompt
// editing state. The screen content is already back from the snapshot.
con.curs_set(1)
con.color_pair(shell.usrcfg.textCol, 255)
gotoCaret()
if (chosen) applyCompletion(comp.wordStart, chosen.value)
}
while (true) {
let key = con.getch()
// printable chars
if (key >= 32 && key <= 126) {
var s = String.fromCharCode(key)
cmdbuf += s
print(s)
let s = String.fromCharCode(key)
let atEnd = (caret === cmdbuf.length)
cmdbuf = cmdbuf.substring(0, caret) + s + cmdbuf.substring(caret)
caret += 1
if (atEnd) print(s) // fast path: simple append
else refresh(caret - 1, 0)
}
// backspace
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
print(String.fromCharCode(key))
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
else if (key === con.KEY_TAB) {
tryAutocomplete()
}
// backspace: delete the char to the left of the caret
else if (key === con.KEY_BACKSPACE && caret > 0) {
cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret)
caret -= 1
refresh(caret, 1)
}
// forward delete: delete the char under the caret
else if (key === con.KEY_DELETE && caret < cmdbuf.length) {
cmdbuf = cmdbuf.substring(0, caret) + cmdbuf.substring(caret + 1)
refresh(caret, 1)
}
// caret left
else if (key === con.KEY_LEFT) {
if (caret > 0) { caret -= 1; gotoCaret() }
}
// caret right
else if (key === con.KEY_RIGHT) {
if (caret < cmdbuf.length) { caret += 1; gotoCaret() }
}
// jump to start of line
else if (key === con.KEY_HOME) {
caret = 0; gotoCaret()
}
// jump to end of line
else if (key === con.KEY_END) {
caret = cmdbuf.length; gotoCaret()
}
// enter
else if (key === 10 || key === con.KEY_RETURN) {
caret = cmdbuf.length; gotoCaret()
println()
errorlevel = shell.execute(cmdbuf)
@@ -1044,32 +1358,17 @@ if (goInteractive) {
// up arrow
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
cmdHistoryScroll += 1
// back the cursor in order to type new cmd
var x = 0
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
// re-type the new command
print(cmdbuf)
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
}
// down arrow
else if (key === con.KEY_DOWN) {
if (cmdHistoryScroll > 0) {
// back the cursor in order to type new cmd
var x = 0
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
// re-type the new command
print(cmdbuf)
if (cmdHistoryScroll > 1) {
cmdHistoryScroll -= 1
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
}
else {
// back the cursor in order to type new cmd
var x = 0
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
cmdbuf = ""
else if (cmdHistoryScroll === 1) {
cmdHistoryScroll = 0
setBuf("")
}
}
}

View File

@@ -1042,6 +1042,7 @@ const colVoiceHdr = 230
const colVoiceHdrMuted = 249
const colVoiceHdrMutedCursorUp = 180
const colSep = 252
const colScrollBar = 249
const colPushBtnBack = 143
const colTabBarBack = 187
const colTabBarBack2 = 136
@@ -1231,7 +1232,7 @@ function drawSeparators(style) {
for (let x = PTNVIEW_OFFSET_X; x < SCRW - 3; x += COLSIZE_TIMELINE_FULL) {
for (let y = 0; y < PTNVIEW_HEIGHT+1; y++) {
let memOffset = (y+PTNVIEW_OFFSET_Y-2) * SCRW + (x-1)
let bgColOffset = GPU_MEM - TEXT_BACK_OFF - memOffset
let bgColOffset = vaddr(TEXT_BACK_OFF + memOffset)
let oldBgCol = sys.peek(bgColOffset)
if (oldBgCol == 255) {
sys.poke(bgColOffset, colColumnSep)
@@ -1806,6 +1807,17 @@ const TEXT_BACK_OFF = 2 + 2560
const TEXT_CHAR_OFF = 2 + 2560 + 2560
const TEXT_PLANES = [TEXT_CHAR_OFF, TEXT_BACK_OFF, TEXT_FORE_OFF]
// Direct text-VRAM addressing. On real hardware the GPU text area is addressed
// backward (byte m at GPU_MEM - m). Under vtmgr's virtual consoles the physical
// GPU is owned by the compositor, so direct writes must instead target this
// pane's forward text-plane buffer (VT_TEXT_PLANE + m), which the compositor
// blits to the screen. vaddr(m) returns the address of text-area byte m for the
// current environment; the physical branch is identical to the old arithmetic.
const _VT_VRAM = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
const VRAM_BASE = _VT_VRAM ? globalThis.VT_TEXT_PLANE : GPU_MEM
const VRAM_SGN = _VT_VRAM ? 1 : -1
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
// One scratch strip, reused across shifts
const SCRATCH_PTR = sys.malloc(SCRW * PTNVIEW_HEIGHT)
@@ -1828,8 +1840,8 @@ function shiftPatternArea(dy) {
for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p]
const srcAddr = GPU_MEM - chanOff - (srcTopY - 1) * SCRW
const dstAddr = GPU_MEM - chanOff - (dstTopY - 1) * SCRW
const srcAddr = vaddr(chanOff + (srcTopY - 1) * SCRW)
const dstAddr = vaddr(chanOff + (dstTopY - 1) * SCRW)
sys.memcpy(srcAddr, SCRATCH_PTR, stripBytes)
sys.memcpy(SCRATCH_PTR, dstAddr, stripBytes)
}
@@ -1850,9 +1862,9 @@ function shiftPatternAreaHorizontal(dVoice) {
for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p]
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, SALVAGE_HORIZ_LEN)
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, SALVAGE_HORIZ_LEN)
const idxBase = chanOff + (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(vaddr(idxBase + srcOff), SCRATCH_PTR, SALVAGE_HORIZ_LEN)
sys.memcpy(SCRATCH_PTR, vaddr(idxBase + dstOff), SALVAGE_HORIZ_LEN)
}
}
}
@@ -2165,9 +2177,9 @@ function shiftOrdersAreaHorizontal(dVoice) {
for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p]
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, stripWidth)
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, stripWidth)
const idxBase = chanOff + (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(vaddr(idxBase + srcOff), SCRATCH_PTR, stripWidth)
sys.memcpy(SCRATCH_PTR, vaddr(idxBase + dstOff), stripWidth)
}
}
}
@@ -3271,7 +3283,7 @@ function drawSamplesListColumn() {
const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0)
for (let r = 0; r < SMP_LIST_H; r++) {
con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X)
con.color_pair(colStatus, colSmpListBg)
con.color_pair(colScrollBar, colSmpListBg)
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
if (r == indPos) scrollChar += 3;
@@ -3888,7 +3900,7 @@ function drawInstrumentsListColumn() {
const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0)
for (let r = 0; r < INST_LIST_H; r++) {
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
con.color_pair(colStatus, colInstListBg)
con.color_pair(colScrollBar, colInstListBg)
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
if (r == indPos) scrollChar += 3;
@@ -5133,6 +5145,7 @@ function openHelpPopup() {
bg: colPopupBack,
height: HELP_CONTENT_H,
width: HELP_CONTENT_W+4,
scrollbarChars: popupScrollbarChars,
selectable: () => false,
renderItem: (ctx) => {
con.color_pair(colText, ctx.listBg)
@@ -5177,6 +5190,12 @@ const popupDrawFrame = (wo) => {
}
}
// Taut's charset carries dedicated scrollbar glyphs at 0xBA..0xBF (empty
// top/mid/bottom caps 0xBA..0xBC, filled top/mid/bottom thumb 0xBD..0xBF).
// wintex defaults to the CP437-safe 0xBA/0xDB pair, so pass these to every
// list popup to render the scrollbar in taut's style.
const popupScrollbarChars = [0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF]
// Standard colour palette shared by every taut popup so wintex's defaults blend
// with taut's popup chrome.
const popupColours = {
@@ -5291,6 +5310,7 @@ function openRetunePopup() {
height: listH,
width: 36,
cursor: selIdx,
scrollbarChars: popupScrollbarChars,
renderItem: (ctx) => {
const e = ctx.item.preset
const isCur = (e.index === PITCH_PRESET_IDX)
@@ -5362,6 +5382,7 @@ function openFlagsPopup() {
width: 22,
drawWell: false,
showScrollbar: false,
scrollbarChars: popupScrollbarChars,
selectable: (it) => it.kind === 'tone' || it.kind === 'intp',
renderItem: (ctx) => {
const it = ctx.item

Binary file not shown.

View File

@@ -217,6 +217,13 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
// action string to close the dialog,
// or null to stay open.
// showScrollbar: bool?, -- default: auto (true when overflowing).
// scrollbarChars: number[6]?, -- glyph codes for the scrollbar:
// [troughTopEmpty, troughMidEmpty,
// troughBotEmpty, troughTopFilled,
// troughMidFilled, troughBotFilled].
// Default [0xBA,0xBA,0xBA,0xDB,0xDB,0xDB]
// (CP437-safe). Callers with a custom
// charset (e.g. taut) pass their own.
// drawWell: bool?, -- draw the list background
// bg: number?, -- list background colour (default 242).
// },
@@ -307,6 +314,12 @@ function showDialog(opts) {
const hasList = !!list
const listOnActivate = list ? list.onActivate : null
const listBgColour = (list && list.bg != null) ? list.bg : listBg
// Scrollbar glyphs: [trough top/mid/bottom empty, then top/mid/bottom filled].
// Default is CP437-safe (0xBA track, 0xDB thumb); callers with their own
// charset (e.g. taut's 0xBA..0xBF) pass a 6-item override.
const listScrollbarChars = (list && Array.isArray(list.scrollbarChars) && list.scrollbarChars.length >= 6)
? list.scrollbarChars
: [0xBA, 0xBA, 0xBA, 0xDB, 0xDB, 0xDB]
function firstSelectable(from, dir) {
if (!hasList || listItems.length === 0) return -1
let i = from
@@ -531,8 +544,10 @@ function showDialog(opts) {
con.move(lbRow + 1 + r, lbCol + lw - 2)
const maxScroll = Math.max(1, listItems.length - listHeight)
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB
con.addch(r === indPos ? (trough + 3) : trough)
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
// filled (thumb) variant over the empty (trough) one.
const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1
con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg])
}
}

View File

@@ -0,0 +1,561 @@
// vtmgr — virtual console manager for TVDOS
//
// Spawns up to 6 independent shell sessions (virtual consoles), each in its
// own parallel GraalVM context with its own thread. Each pane runs a real
// `command -fancy` shell. The dispatcher (this file) owns the physical
// keyboard, polls Alt-N hotkeys at 30 Hz, blits the active pane's text
// plane to the GPU's text area, and routes typed characters into the
// active pane's input ring buffer.
//
// Hotkeys: Alt-1..Alt-6 switch to that VT (lazy-spawn on first use).
// Alt-0 cleanly tears down vtmgr.
// Builtins: `chvt N` from inside a pane writes to the switch register.
// ─── shared memory layout ───────────────────────────────────────────────────
// CTRL_AREA (64 bytes from base)
// +0 active_vt u8 (1..6)
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
// +2 debounce_held u8
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
// +4..63 reserved
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
// +0..7 reserved (cursor & color state lives inside text plane itself)
// +8 queue_head u8 (next-read index)
// +9 queue_tail u8 (next-write index)
// +10..11 reserved
// +12..267 queue_data (256-byte ring buffer; one slot lost to full/empty disambiguation)
// +268..271 reserved (alignment)
// +272..7953 text_plane (7682 bytes; mirrors GPU textArea layout exactly)
const MAX_VT = 6
const CTRL_AREA_SIZE = 64
const VT_BLOCK_SIZE = 8000
const TEXT_PLANE_OFFSET = 272
const TEXT_PLANE_SIZE = 7682
const QUEUE_DATA_OFFSET = 12
const CTRL_ACTIVE_VT = 0
const CTRL_SWITCH_REQUEST = 1
const CTRL_DEBOUNCE_HELD = 2
const CTRL_SPAWNED_BITS = 3
const GPU_TEXTAREA_OFFSET = 253950
const TEXT_COLS = 80
const TEXT_ROWS = 32
const TP_FORE_BASE = 2
const TP_BACK_BASE = 2 + 2560
const TP_TEXT_BASE = 2 + 2560 + 2560
const TOTAL_ALLOC_SIZE = CTRL_AREA_SIZE + MAX_VT * VT_BLOCK_SIZE
const BASE = sys.malloc(TOTAL_ALLOC_SIZE)
if (!BASE || BASE === 0) { printerrln("vtmgr: sys.malloc failed"); return 1 }
for (let i = 0; i < TOTAL_ALLOC_SIZE; i++) sys.poke(BASE + i, 0)
const CTRL = BASE
function vtBlockAddr(n) { return BASE + CTRL_AREA_SIZE + (n - 1) * VT_BLOCK_SIZE }
function vtTextPlaneAddr(n) { return vtBlockAddr(n) + TEXT_PLANE_OFFSET }
// ─── pane bootstrap ─────────────────────────────────────────────────────────
// Read TVDOS.SYS once at startup. Each pane's bootstrap embeds the source
// (via JSON.stringify-escaped string literal) and evaluates it together with
// the shell-start code as ONE direct-eval call. This matters because strict-
// mode direct eval is scope-isolated; if TVDOS.SYS and the shell launcher
// were two separate evals, the shell launcher wouldn't see `_TVDOS`,
// `files`, `execApp`, etc. defined by the first eval.
const TVDOS_SYS_SRC = files.open("A:/tvdos/TVDOS.SYS").sread()
// _BIOS is set by the real BIOS before TVDOS.SYS runs; TVDOS.SYS reads
// _BIOS.FIRST_BOOTABLE_PORT during init. Each pane is a fresh context with no
// BIOS, so capture the live value here (vtmgr runs in the main context where
// _BIOS is visible) and re-declare it in every pane bootstrap.
const BIOS_FIRST_BOOTABLE_PORT = JSON.stringify(_BIOS.FIRST_BOOTABLE_PORT)
// Environment no longer needs snapshotting/replaying: each pane re-evaluates
// TVDOS.SYS, whose boot block runs \commandrc in every context, so the pane
// gets the same PATH / KEYBOARD / etc. natively. The pane then runs
// \AUTOEXEC.BAT (the per-console launch script: IME + interactive shell).
function makePaneBootstrap(vtNum) {
const TP_BASE = vtTextPlaneAddr(vtNum)
const VT_BLK = vtBlockAddr(vtNum)
// Launcher code runs after TVDOS.SYS in the SAME eval scope, so `files`,
// `eval`, `_TVDOS` etc. resolve via lexical closure. TVDOS.SYS's boot
// block already ran \commandrc (env) and skipped its own AUTOEXEC because
// the pane sets _TVDOS_IS_VT_PANE; here we run \AUTOEXEC.BAT to launch the
// per-console shell.
const SHELL_START = ";\n"
+ "var _cmdfileSrc = files.open('A:/tvdos/bin/command.js').sread();\n"
+ "eval('var _VTSHELL=function(exec_args){' + _cmdfileSrc + '\\n};_VTSHELL')(['', '-c', '\\\\AUTOEXEC.BAT']);\n"
const combined = TVDOS_SYS_SRC + SHELL_START
const raw = `
globalThis.VT_NUM = ${vtNum}
globalThis.VT_TEXT_PLANE = ${TP_BASE}
globalThis.VT_BLOCK_ADDR = ${VT_BLK}
globalThis.VT_CTRL_ADDR = ${CTRL}
const TP = ${TP_BASE}
const VT_BLK = ${VT_BLK}
const CTRL = ${CTRL}
const QUEUE_DATA = VT_BLK + ${QUEUE_DATA_OFFSET}
const QUEUE_HEAD_ADDR = VT_BLK + 8
const QUEUE_TAIL_ADDR = VT_BLK + 9
const ACTIVE_VT_ADDR = CTRL + ${CTRL_ACTIVE_VT}
const COLS = ${TEXT_COLS}, ROWS = ${TEXT_ROWS}
const FORE_BASE = ${TP_FORE_BASE}, BACK_BASE = ${TP_BACK_BASE}, TEXT_BASE = ${TP_TEXT_BASE}
// ── output shims (write into the per-VT text-plane buffer in shared mem) ──
// This is a faithful JS port of the GPU's TTY interpreter (GlassTty.acceptChar
// + GraphicsAdapter handlers). TVDOS apps drive the screen by printing control
// bytes and escape sequences through print(), so the shim must interpret them
// exactly as the hardware would: the \\x84<decimal>u "emit char by code" escape
// (used by con.prnch), CSI cursor moves / erase / SGR colours, and the ?25
// cursor-visibility private sequence.
let curX = 0, curY = 0
let foreCol = 254
let backCol = 255
// Per-pane cursor visibility lives at VT_BLK+2 (1 = blink on, 0 = hidden).
// The compositor pushes the active pane's value into the GPU's blink bit.
const CURSOR_VIS_ADDR = VT_BLK + 2
sys.poke(CURSOR_VIS_ADDR, 1)
// SGR 30-37 / 40-47 → default 8-colour palette (matches GraphicsAdapter).
const SGR_PAL = [240, 211, 61, 230, 49, 219, 114, 254]
function writeCursor() {
let pos = curY * COLS + curX
sys.poke(TP + 0, pos & 0xFF)
sys.poke(TP + 1, (pos >> 8) & 0xFF)
}
function scrollBufUp(n) {
if (n < 1) n = 1
if (n > ROWS) n = ROWS
for (let p of [FORE_BASE, BACK_BASE, TEXT_BASE]) {
for (let y = 0; y < ROWS - n; y++) {
for (let x = 0; x < COLS; x++) {
sys.poke(TP + p + y * COLS + x, sys.peek(TP + p + (y + n) * COLS + x))
}
}
let clearVal = (p === TEXT_BASE) ? 0 : (p === FORE_BASE ? foreCol : backCol)
for (let y = ROWS - n; y < ROWS; y++)
for (let x = 0; x < COLS; x++) sys.poke(TP + p + y * COLS + x, clearVal)
}
}
function putCharRaw(x, y, c) {
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return
let off = y * COLS + x
sys.poke(TP + TEXT_BASE + off, c & 0xFF)
sys.poke(TP + FORE_BASE + off, foreCol)
sys.poke(TP + BACK_BASE + off, backCol)
}
// Mirror of GraphicsAdapter.setCursorPos: wrap on overflow x, scroll on
// overflow y, clamp y above the screen.
function setCursorPos(x, y) {
let nx = x, ny = y
if (nx >= COLS) { nx = 0; ny += 1 }
else if (nx < 0) nx = 0
if (ny < 0) ny = 0
else if (ny >= ROWS) { scrollBufUp(ny - ROWS + 1); ny = ROWS - 1 }
curX = nx; curY = ny
writeCursor()
}
// ── TTY actions (mirror the GraphicsAdapter overrides) ────────────────────
function ttyPrintable(c) { putCharRaw(curX, curY, c); setCursorPos(curX + 1, curY) }
function ttyCrlf() {
let ny = curY + 1
setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny)
if (ny >= ROWS) scrollBufUp(1)
}
function ttyBackspace() { let x = curX, y = curY; setCursorPos(x - 1, y); putCharRaw(curX, curY, 0x20) }
function ttyTab() { setCursorPos(((curX / 8 | 0) + 1) * 8, curY) }
function ttyResetStatus() { foreCol = 253; backCol = 255 }
function ttyEmitChar(code) { putCharRaw(curX, curY, code); setCursorPos(curX + 1, curY) }
function ttyCursorUp(n) { setCursorPos(curX, curY - n) }
function ttyCursorDown(n) { let ny = curY + n; setCursorPos(curX, (ny >= ROWS) ? ROWS - 1 : ny) }
function ttyCursorFwd(n) { setCursorPos(curX + n, curY) }
function ttyCursorBack(n) { setCursorPos(curX - n, curY) }
function ttyCursorNextLine(n) { let ny = curY + n; setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny); if (ny >= ROWS) scrollBufUp(ny - ROWS + 1) }
function ttyCursorPrevLine(n) { setCursorPos(0, curY - n) }
function ttyCursorX(n) { setCursorPos(n, curY) }
function ttyCursorXY(row, col) { setCursorPos(col - 1, row - 1) }
function ttyEraseInDisp(arg) {
if (arg === 2) {
for (let i = 0; i < COLS * ROWS; i++) {
sys.poke(TP + TEXT_BASE + i, 0)
sys.poke(TP + FORE_BASE + i, foreCol)
sys.poke(TP + BACK_BASE + i, backCol)
}
curX = 0; curY = 0; writeCursor()
}
// other args: GraphicsAdapter TODOs (throws); we no-op for safety
}
function ttySgr1(arg) {
if (arg >= 30 && arg <= 37) foreCol = SGR_PAL[arg - 30]
else if (arg >= 40 && arg <= 47) backCol = SGR_PAL[arg - 40]
else if (arg === 7) { let t = foreCol; foreCol = backCol; backCol = t }
else if (arg === 0) { foreCol = 253; backCol = 255; sys.poke(CURSOR_VIS_ADDR, 1) }
}
function ttySgr3(a1, a2, a3) {
if (a1 === 38 && a2 === 5) foreCol = a3
else if (a1 === 48 && a2 === 5) backCol = a3
}
function ttyPrivH(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 1) }
function ttyPrivL(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 0) }
// ── escape-sequence state machine (mirror of GlassTty.acceptChar) ─────────
// States: 0 INITIAL, 1 ESC, 2 CSI, 3 NUM1, 4 SEP1, 5 NUM2, 6 SEP2, 7 NUM3,
// 8 PRIVATESEQ, 9 PRIVATENUM, 10 XCSI, 11 XNUM1
let escState = 0
let escArgs = []
function isDig(c) { return c >= 0x30 && c <= 0x39 }
function escReset() { escState = 0; escArgs.length = 0 }
// reject() in hardware returns the char as printable; replicate by printing it
function escRejectPrint(c) { escReset(); ttyPrintable(c) }
function processByte(c) {
switch (escState) {
case 0: // INITIAL
if (c === 0x1B) escState = 1
else if (c === 0x84) escState = 10
else if (c === 0x0A) ttyCrlf()
else if (c === 0x08) ttyBackspace()
else if (c === 0x09) ttyTab()
else if (c === 0x07) { /* bell */ }
else if (c >= 0x00 && c <= 0x1F) { /* other control: ignored */ }
else ttyPrintable(c)
break
case 1: // ESC
if (c === 0x63) { ttyResetStatus(); escReset() } // 'c'
else if (c === 0x5B) escState = 2 // '['
else escRejectPrint(c)
break
case 2: // CSI
if (c === 0x41) { ttyCursorUp(1); escReset() }
else if (c === 0x42) { ttyCursorDown(1); escReset() }
else if (c === 0x43) { ttyCursorFwd(1); escReset() }
else if (c === 0x44) { ttyCursorBack(1); escReset() }
else if (c === 0x45) { ttyCursorNextLine(1); escReset() }
else if (c === 0x46) { ttyCursorPrevLine(1); escReset() }
else if (c === 0x47) { ttyCursorX(1); escReset() }
else if (c === 0x4A) { ttyEraseInDisp(0); escReset() }
else if (c === 0x4B) { escReset() } // eraseInLine: no-op
else if (c === 0x53) { scrollBufUp(1); escReset() } // S
else if (c === 0x54) { escReset() } // T scrollDown: no-op
else if (c === 0x6D) { ttySgr1(0); escReset() } // m
else if (c === 0x3F) escState = 8 // '?'
else if (c === 0x3B) { escArgs.push(0); escState = 4 } // ';'
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 3 }
else escRejectPrint(c)
break
case 3: // NUM1
if (c === 0x41) { ttyCursorUp(escArgs.pop()); escReset() }
else if (c === 0x42) { ttyCursorDown(escArgs.pop()); escReset() }
else if (c === 0x43) { ttyCursorFwd(escArgs.pop()); escReset() }
else if (c === 0x44) { ttyCursorBack(escArgs.pop()); escReset() }
else if (c === 0x45) { ttyCursorNextLine(escArgs.pop()); escReset() }
else if (c === 0x46) { ttyCursorPrevLine(escArgs.pop()); escReset() }
else if (c === 0x47) { ttyCursorX(escArgs.pop()); escReset() }
else if (c === 0x4A) { ttyEraseInDisp(escArgs.pop()); escReset() }
else if (c === 0x4B) { escArgs.pop(); escReset() }
else if (c === 0x53) { scrollBufUp(escArgs.pop()); escReset() }
else if (c === 0x54) { escArgs.pop(); escReset() }
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() }
else if (c === 0x3B) escState = 4
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
else escRejectPrint(c)
break
case 4: // SEP1 (seen "n;")
if (isDig(c)) { escArgs.push(c - 0x30); escState = 5 }
else if (c === 0x48) { let a1 = escArgs.pop(); ttyCursorXY(a1, 0); escReset() } // H
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() } // m (2-arg unimpl in HW)
else if (c === 0x3B) { escArgs.push(0); escState = 6 }
else escRejectPrint(c)
break
case 5: // NUM2 (seen "n;n")
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
else if (c === 0x48) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttyCursorXY(a1, a2); escReset() }
else if (c === 0x6D) { escArgs.pop(); escArgs.pop(); escReset() } // 2-arg SGR unimpl in HW
else if (c === 0x3B) escState = 6
else escRejectPrint(c)
break
case 6: // SEP2 (seen "n;n;")
if (c === 0x6D) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, 0); escReset() }
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 7 }
else escRejectPrint(c)
break
case 7: // NUM3 (seen "n;n;n")
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
else if (c === 0x6D) { let a3 = escArgs.pop(), a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, a3); escReset() }
else escRejectPrint(c)
break
case 8: // PRIVATESEQ (seen "?")
if (isDig(c)) { escArgs.push(c - 0x30); escState = 9 }
else escRejectPrint(c)
break
case 9: // PRIVATENUM (seen "?n")
if (c === 0x68) { ttyPrivH(escArgs.pop()); escReset() } // h
else if (c === 0x6C) { ttyPrivL(escArgs.pop()); escReset() } // l
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
else escRejectPrint(c)
break
case 10: // XCSI (seen \\x84)
if (c === 0x75) { ttyEmitChar(0); escReset() } // 'u'
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 11 }
else escRejectPrint(c)
break
case 11: // XNUM1 (seen \\x84<digits>)
if (c === 0x75) { ttyEmitChar(escArgs.pop()); escReset() } // 'u'
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
else escRejectPrint(c)
break
}
}
print = function(s) {
if (s === undefined || s === null) return
let str = '' + s
for (let i = 0; i < str.length; i++) processByte(str.charCodeAt(i))
}
println = function(s) {
if (s === undefined) print("\\n")
else print(s + "\\n")
}
printerr = function(s) { print(s) }
printerrln = function(s) { println(s) }
// command.js's shell.execute reassigns the global print/println/printerr/
// printerrln to shell.stdio.out.* (which call sys.print → physical GPU,
// bypassing these shims). Expose the buffer writers through a global hook so
// shell.stdio.out can delegate to them when running inside a VT pane. The
// non-VT path in command.js stays unchanged (hook is undefined there).
globalThis.__VT_OUT = { print: print, println: println, printerr: printerr, printerrln: printerrln }
// con.move / con.getyx are 1-based in TVDOS (graphics.setCursorYX does cx-1,
// getCursorYX returns cx+1). Internal curX/curY are 0-based, so convert.
con.move = function(y, x) {
curY = Math.max(0, Math.min(ROWS - 1, (y | 0) - 1))
curX = Math.max(0, Math.min(COLS - 1, (x | 0) - 1))
writeCursor()
}
con.getyx = function() { return [curY + 1, curX + 1] }
con.getmaxyx = function() { return [ROWS, COLS] }
con.color_pair = function(f, b) { foreCol = f & 0xFF; backCol = b & 0xFF }
con.color_fore = function(n) { foreCol = n & 0xFF }
con.color_back = function(n) { backCol = n & 0xFF }
con.get_color_fore = function() { return foreCol }
con.get_color_back = function() { return backCol }
// addch writes a glyph at the cursor WITHOUT advancing — matching
// graphics.putSymbol(). TVDOS code pairs addch with explicit curs_right();
// advancing here would double-step and leave gaps (e.g. the fancy prompt).
con.addch = function(c) { putCharRaw(curX, curY, c) }
con.mvaddch = function(y, x, c) { con.move(y, x); con.addch(c) }
con.curs_up = function(n) { n = n || 1; curY = Math.max(0, curY - n); writeCursor() }
con.curs_down = function(n) { n = n || 1; curY = Math.min(ROWS - 1, curY + n); writeCursor() }
con.curs_left = function(n) { n = n || 1; curX = Math.max(0, curX - n); writeCursor() }
con.curs_right = function(n) { n = n || 1; curX = Math.min(COLS - 1, curX + n); writeCursor() }
con.curs_set = function(arg) { sys.poke(CURSOR_VIS_ADDR, ((arg | 0) === 0) ? 0 : 1) }
con.video_reverse = function() { /* unsupported; ANSI swallowed */ }
con.reset_graphics = function() { foreCol = 254; backCol = 255 }
con.clear = function() {
for (let i = 0; i < COLS * ROWS; i++) {
sys.poke(TP + TEXT_BASE + i, 0)
sys.poke(TP + FORE_BASE + i, foreCol)
sys.poke(TP + BACK_BASE + i, backCol)
}
curX = 0; curY = 0; writeCursor()
}
// prnch prints a glyph and DOES advance (unlike addch) — the real impl emits
// it through print() as \\x84<code>u, so route it through the interpreter.
con.prnch = function(c) {
if (Array.isArray(c)) c.forEach(x => ttyEmitChar(x))
else ttyEmitChar(c)
}
// ── input shims ──────────────────────────────────────────────────────────
// Pane reads from its own ring buffer in shared mem. NEVER touches physical
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
// gate on active_vt keeps background panes parked when they call getch.
function queuePop() {
let head = sys.peek(QUEUE_HEAD_ADDR)
let tail = sys.peek(QUEUE_TAIL_ADDR)
if (head === tail) return -1
let b = sys.peek(QUEUE_DATA + head)
sys.poke(QUEUE_HEAD_ADDR, (head + 1) & 0xFF)
return b
}
con.getch = function() {
while (true) {
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
let k = queuePop()
if (k >= 0) return k
}
sys.sleep(20)
}
}
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] }
// ── TVDOS.SYS init flags + BIOS stub ───────────────────────────────────────
globalThis._TVDOS_IS_VT_PANE = true
globalThis._BIOS = { FIRST_BOOTABLE_PORT: ${BIOS_FIRST_BOOTABLE_PORT} }
// ── load TVDOS.SYS and run AUTOEXEC.BAT (the per-console shell) in one direct-eval ─────
// Strict-mode direct eval is scope-isolated, so TVDOS.SYS's \`const _TVDOS\`
// only survives within the eval scope. The shell launcher must run inside
// the same eval to access it (via lexical closure into nested evals).
eval(${JSON.stringify(combined)})
`
// The outer execApp's injectIntChk rewrote the first while/for/do (each
// kind) in our literal source to call a per-exec SIGTERM check function.
// Some of those rewrites landed inside this template literal — the pane
// has no such symbol in scope. Strip them; panes don't need SIGTERM
// checks (parallel.kill handles teardown).
return raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')
}
// ─── pane lifecycle ─────────────────────────────────────────────────────────
// Lazy spawn: VT 1 at boot; VT 2-6 the first time the user requests them.
// Re-spawn if the previous pane's thread has died (e.g. user typed `exit`).
const panes = {} // n -> { runner, thread }
function isPaneAlive(n) {
return panes[n] && parallel.isRunning(panes[n].thread)
}
function spawnPane(n) {
serial.println("[vtmgr] spawning VT " + n)
let runner = parallel.spawnNewContext()
let thread = parallel.attachProgram("vt" + n, runner, makePaneBootstrap(n))
parallel.launch(thread)
panes[n] = { runner: runner, thread: thread }
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) | (1 << (n - 1)))
}
function ensurePane(n) {
if (!isPaneAlive(n)) {
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) & ~(1 << (n - 1)))
spawnPane(n)
}
}
ensurePane(1)
sys.poke(CTRL + CTRL_ACTIVE_VT, 1)
// VT 1's TVDOS.SYS eval is slow; give it room before we start compositing.
sys.sleep(800)
// ─── compositor / dispatcher loop ───────────────────────────────────────────
// 30 Hz: blit active pane → GPU text area; honour switch_request; detect
// Alt-N with debounce; drain typed chars into active pane's queue.
const gpuBase = graphics.getGpuMemBase()
const TEXTAREA_BASE_ABS = gpuBase - GPU_TEXTAREA_OFFSET
function blitVt(srcAddr) {
sys.memcpy(srcAddr, TEXTAREA_BASE_ABS, TEXT_PLANE_SIZE - 2)
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 2), sys.peek(srcAddr + TEXT_PLANE_SIZE - 2))
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 1), sys.peek(srcAddr + TEXT_PLANE_SIZE - 1))
}
// GPU textmode-attribute MMIO byte (offset 6): bit 0 = blinkCursor, bit 1 =
// rawMode, bits 4-7 = chrrom. We flip only bit 0 to match the active pane's
// cursor visibility. getGpuMemBase() = -1 - 1MB*slot; the peripheral's MMIO
// window sits at IOSpace offset 128KB*slot, so MMIO byte k = -1 - (128KB*slot + k).
const gpuSlot = (((-gpuBase) - 1) / 1048576) | 0
const GPU_MMIO_ATTR = -1 - (131072 * gpuSlot + 6)
let lastCursorVis = -1
function applyCursorVis(active) {
let vis = sys.peek(vtBlockAddr(active) + 2)
if (vis === lastCursorVis) return
let attr = sys.peek(GPU_MMIO_ATTR)
sys.poke(GPU_MMIO_ATTR, vis ? (attr | 1) : (attr & 0xFE))
lastCursorVis = vis
}
function queuePush(vtN, byte) {
let qBase = vtBlockAddr(vtN)
let head = sys.peek(qBase + 8)
let tail = sys.peek(qBase + 9)
let next = (tail + 1) & 0xFF
if (next === head) return false
sys.poke(qBase + QUEUE_DATA_OFFSET + tail, byte)
sys.poke(qBase + 9, next)
return true
}
function switchTo(n) {
if (n < 1 || n > MAX_VT) return
ensurePane(n)
sys.poke(CTRL + CTRL_ACTIVE_VT, n)
}
sys.poke(-39, 1) // enable physical keyboard input collection
let running = true
while (running) {
let active = sys.peek(CTRL + CTRL_ACTIVE_VT)
if (active < 1 || active > MAX_VT) active = 1
blitVt(vtTextPlaneAddr(active))
applyCursorVis(active)
// honour chvt's switch request
let req = sys.peek(CTRL + CTRL_SWITCH_REQUEST)
if (req >= 1 && req <= MAX_VT) {
if (req !== active) {
serial.println("[vtmgr] chvt switch -> VT " + req)
switchTo(req)
}
sys.poke(CTRL + CTRL_SWITCH_REQUEST, 0)
}
// Alt-N (and Alt-0 = exit) detection
sys.poke(-40, 1)
let keys = [sys.peek(-41), sys.peek(-42), sys.peek(-43), sys.peek(-44),
sys.peek(-45), sys.peek(-46), sys.peek(-47), sys.peek(-48)]
let altHeld = keys.indexOf(57) >= 0 || keys.indexOf(58) >= 0
let digit = -1
for (let n = 0; n <= MAX_VT; n++) {
if (keys.indexOf(7 + n) >= 0) { digit = n; break }
}
let debounce = sys.peek(CTRL + CTRL_DEBOUNCE_HELD) !== 0
if (debounce) {
if (!altHeld && digit < 0) sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 0)
}
else if (altHeld && digit === 0) {
serial.println("[vtmgr] Alt-0 -> exit")
running = false
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
sys.poke(-39, 1)
}
else if (altHeld && digit >= 1) {
serial.println("[vtmgr] Alt-" + digit + " -> switching to VT " + digit)
switchTo(digit)
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
sys.poke(-39, 1) // swallow the digit char so it doesn't leak into the queue
}
if (!running) break
// drain typed chars into the active pane's queue
while (sys.peek(-50) !== 0) {
let k = sys.peek(-38)
if (k < 0) k += 256
queuePush(active, k)
}
sys.sleep(33)
}
for (let n = 1; n <= MAX_VT; n++) if (panes[n]) parallel.kill(panes[n].thread)
con.color_pair(254, 255)
con.clear()
println("vtmgr exited.")
return 0