mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-15 00:44:05 +09:00
Compare commits
41 Commits
dad345c027
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04cac5c60b | ||
|
|
1fa4073a11 | ||
|
|
fb0765a041 | ||
|
|
9eb8b9b3f8 | ||
|
|
d5f2e5ed02 | ||
|
|
af8dd6aea8 | ||
|
|
62fe227b6b | ||
|
|
94b65a2801 | ||
|
|
ed43e4becc | ||
|
|
5bfc1cca3a | ||
|
|
240ce01209 | ||
|
|
aa9ea2eeca | ||
|
|
2e9b380843 | ||
|
|
aa2d3263c8 | ||
|
|
db44b6b523 | ||
|
|
db63c3cfdd | ||
|
|
09248b2760 | ||
|
|
6ab0acb379 | ||
|
|
b69c947232 | ||
|
|
6068080bcb | ||
|
|
ffc1d420cd | ||
|
|
e32f7565ba | ||
|
|
c17f4828b0 | ||
|
|
95ac8c53dd | ||
|
|
c8fc363445 | ||
|
|
ce45929c4e | ||
|
|
0f5ede5276 | ||
|
|
aa45c2194f | ||
|
|
3444bdf63b | ||
|
|
df16b99ba5 | ||
|
|
6a0241a249 | ||
|
|
5c7ff9e906 | ||
|
|
c6e087e74c | ||
|
|
7dea413454 | ||
|
|
ee202efe09 | ||
|
|
6be98b5207 | ||
|
|
729e5246c9 | ||
|
|
e27a01dca6 | ||
|
|
35263eeaa4 | ||
|
|
d223adda25 | ||
|
|
a9d095e3cb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -74,3 +74,6 @@ assets/disk0/movtestimg/*.jpg
|
||||
assets/disk0/*.mov
|
||||
assets/diskMediabin/*
|
||||
assets/disk0/hopper/*
|
||||
|
||||
# TVDOS runtime caches (regenerated on the VM; never commit)
|
||||
assets/disk0/tvdos/cache/
|
||||
|
||||
141
CLAUDE.md
141
CLAUDE.md
@@ -38,6 +38,27 @@ Current topics:
|
||||
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
||||
- `reference_materials/schismtracker` — Open-source re-implementation of ImpulseTracker
|
||||
- `reference_materials/pt2-clone` — Open-source re-implementation of ProTracker 2
|
||||
- `reference_materials/doom/` — id Software's GPL source release of DOOM
|
||||
(linuxdoom-1.10). Reference for the TSVM DOOM port in
|
||||
`assets/disk0/home/doom/`; demo-sync-critical tables, fixed-point maths and
|
||||
playsim call order must be translated from this source, never from memory.
|
||||
- `reference_materials/soundfont/` — SoundFont 2.04 spec (PDF + `pdftotext`
|
||||
rendering for citations) for `midi2taud.py`. The `README.md` digests SF2
|
||||
*layering* semantics (all matching preset+instrument zones sound at once —
|
||||
no "first wins"), a generator/modulator census of the three production banks
|
||||
(SGM, Timbres of Heaven, Evanescence2), the spec-vs-files layering table, and
|
||||
what implementing layering in Taud needs (no new per-layer params — Ixmp
|
||||
already carries them; only multi-fire engine semantics + a layer cap of 4–5).
|
||||
Probes: `devtests/sf2_layer_probe.py`, `devtests/sf2_gen_census.py`.
|
||||
- `reference_materials/fluidsynth/` — verbatim FluidSynth source, the reference
|
||||
SoundFont 2 synthesiser. The audible ground truth for Taud's **SF2 filter
|
||||
mode**: the SF2 voice low-pass is an **RBJ biquad** (cutoff in absolute cents
|
||||
via `fluid_ct2hz`, Q from cB with FluidSynth's −3.01 dB Butterworth offset,
|
||||
`1/√Q` passband gain-norm), NOT the IT all-pole filter. The `README.md`
|
||||
digests the cutoff/Q/coefficient maths with file:line citations; ported into
|
||||
`AudioAdapter.kt` `refreshVoiceFilter`/`applyVoiceFilter` (`filterSfMode`
|
||||
branch) to fix the muffling vs. the old overdamped all-pole port. Upstream's
|
||||
own README is preserved as `README.upstream.md`.
|
||||
|
||||
When fetching new references, copy the relevant upstream files verbatim into
|
||||
a topic folder, write a `README.md` summarising the relevant maths /
|
||||
@@ -171,7 +192,16 @@ The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/Aud
|
||||
|
||||
### Critical Implementation Notes
|
||||
|
||||
**Re-bind the local `inst` after any mid-tick `triggerNote`.** `applyTrackerTick` binds `var inst = instruments[voice.instrumentId]` once at the top of the per-voice loop. When the note-delay (`S$Dx`) deferred trigger fires mid-tick, `triggerNote` swaps the voice's `instrumentId` — but the rest of that tick (playback-rate recompute at the `computePlaybackRate(inst, finalPitch)` line, `advanceEnvelope`, `advancePfEnvelope`, `advanceAutoVibrato`, and the fadeout / filter-env reads of `inst.*`) keeps using the captured binding. The damage on a **never-triggered voice** (`instrumentId == 0` → stale `inst = instruments[0]`, whose `samplingRate == 0`) is that `playbackRate` is overwritten with `0.0`, freezing the sample at its start for the trigger tick — perceived as "the first delayed note on a fresh channel doesn't fire" (canonical: WHEN.taud cue 0 voice 13 pattern 0x0A row 16, inst `0x11` SD2 on a fresh play). On a warm voice the stale `inst` is a real instrument with non-zero rate, so the note sounds (at the wrong rate for one tick — a sub-perceptual glitch). Re-bind `inst = instruments[voice.instrumentId]` immediately after the note-delay fire block. Any future in-tick trigger paths (currently only S$Dx) must do the same.
|
||||
**Re-bind the local `inst` after any mid-tick `triggerNote`.** `applyTrackerTick` binds `var inst = instruments[voice.instrumentId]` once at the top of the per-voice loop. When the note-delay (`S$Dx`) deferred trigger fires mid-tick, `triggerNote` swaps the voice's `instrumentId` — but the rest of that tick (playback-rate recompute at the `computePlaybackRate(inst, finalPitch)` line, `advanceEnvelope`, `advancePitchEnvelope`/`advanceFilterEnvelope`, `advanceAutoVibrato`, and the fadeout / filter-env reads of `inst.*`) keeps using the captured binding. The damage on a **never-triggered voice** (`instrumentId == 0` → stale `inst = instruments[0]`, whose `samplingRate == 0`) is that `playbackRate` is overwritten with `0.0`, freezing the sample at its start for the trigger tick — perceived as "the first delayed note on a fresh channel doesn't fire" (canonical: WHEN.taud cue 0 voice 13 pattern 0x0A row 16, inst `0x11` SD2 on a fresh play). On a warm voice the stale `inst` is a real instrument with non-zero rate, so the note sounds (at the wrong rate for one tick — a sub-perceptual glitch). Re-bind `inst = instruments[voice.instrumentId]` immediately after the note-delay fire block. Any future in-tick trigger paths (currently only S$Dx) must do the same.
|
||||
|
||||
**Per-patch envelopes go through the Voice's ACTIVE-envelope view, never `inst.*` directly.** Since 2026-06-13 an Ixmp patch can carry its own volume / pan / filter / pitch envelopes (+ fadeout / cutoff / resonance) — see terranmon.txt §Ixmp, variable-length patches. `applyActiveSample` → `resolveActiveEnvelopes(voice, inst, patch)` snapshots the effective envelope source onto `voice.active{Vol,Pan,Pitch,Filter}Env{,Loop,Sustain}`, `voice.has{Pitch,Filter}Env`, and `voice.active{FadeoutStep,DefaultCutoff,DefaultResonance}`. The base instrument exposes **two** pf-envelope slots — bytes 19.. (`pfEnv*`) and bytes 197..250 (`pf2Env*`, the mandatory complement) — routed into the pitch/filter roles by each slot's m-bit (LOOP-word bit 7). `advanceEnvelope` (vol+pan), `advancePitchEnvelope`, `advanceFilterEnvelope`, `applyKeyLift`, the per-tick pitch/filter/fadeout application (foreground AND background), and `triggerNote`'s envelope seeds must ALL read the `voice.active*` view, not `inst.*`. `copyVoice` (NNA ghost) must copy the whole active view so ghosts keep their patch's envelopes. There is no single `envPf*`/`envPfIsFilter` field any more — it was split into explicit `envPitch*`/`envFilter*` pairs. Headless coverage: `devtests/ixmp/PatchEnvTest` (per-patch env applied) + `IxmpFileTest /tmp/m_e1m1.taud`.
|
||||
|
||||
**The shared pitch/filter envelope walker (`advancePfRole`) must SKIP zero-duration nodes, not freeze on them.** A node whose `offset` rounds to 0 — sub-4 ms, since `ThreeFiveMinifloat`'s smallest non-zero step is ≈3.9 ms — represents an instant transition; the walk must advance to the next node. The old code `return`ed on `offset == 0.0` without advancing the index, stranding fast-attack envelopes at their first node. The audible damage: SF2 filter mod-envelopes (`midi2taud.py` `_filter_env_block_sf`) routinely have a ~1 ms attack that stores offset 0, so the filter never opened from its base cutoff to its sustain cutoff — Strings/Flute/Guitar (SGM base ~600 Hz, sustain ~6 kHz) and low-base sweep drums played permanently muffled at their floor. The skip loop stops at a sustain/loop boundary (`susEnd`, handled by the dispatch above) or `maxIdx`. This also affects pitch mod-envs and any IT/XM envelope with a zero-tick (vertical-jump) node, all now correct. There is still a one-tick (≈seed) delay before the env opens — inaudible on sustained notes; the seed value is the base node.
|
||||
|
||||
**SoundFont filter mode uses an RBJ biquad, NOT the IT all-pole filter.** `refreshVoiceFilter` has two topologies. The IT/tracker path (`else` branch) is the all-pole 2-pole resonant LPF from `reference_materials/tracker_filter/` (no feedforward zeros) — must stay byte-faithful for tracker playback, do not touch it. The **`filterSfMode` branch ports FluidSynth's voice filter** (`reference_materials/fluidsynth/`, see its `README.md`): cutoff = absolute cents → Hz via `8.176·2^(cents/1200)` clamped to `[5 Hz, 0.45·fs]`; Q from centibels with FluidSynth's **−3.01 dB offset** (so Q=0 cB ⇒ q_lin = 1/√2 Butterworth, no resonance hump); RBJ cookbook low-pass coefficients with the SF2 `1/√Q` passband gain-norm. `applyVoiceFilter` runs the biquad (Direct Form I: `y = b02·(x+x₂) + b1·x₁ − a1·y₁ − a2·y₂`) when `voice.filterIsBiquad`. The old code reused the all-pole filter for SF mode too; it is overdamped and rolled the passband off ~3 dB @ 8 kHz / ~5 dB @ 12 kHz vs FluidSynth → audible muffling on every filtered GM instrument. Per-voice biquad state (`filterBqB02/B1/A1/A2`, input history `filterX1/X2`) must be reset on trigger/retrigger and copied in `copyVoice` (NNA ghost) alongside the output history. The background-voice filter-env path must branch on `filterSfMode` too, else an SF-mode ghost's cents-domain cutoff gets clamped into the IT 0..254 byte range (≈9 Hz → silence).
|
||||
|
||||
### System Soundfont Location
|
||||
Look for `/media/torvald/Warehouse/*.sf2` and `/media/torvald/Warehouse/*.SF2`
|
||||
|
||||
## TVDOS
|
||||
|
||||
@@ -436,3 +466,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/VTMGR.SYS`. 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/VTMGR.SYS` (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.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
con.reset_graphics();con.curs_set(0);con.clear();
|
||||
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 15);graphics.setBackground(0,0,0);
|
||||
|
||||
let logo = gzip.decomp(base64.atob("H4sICJoBTGECA3Rzdm1sb2dvLnJhdwDtneu2nCoQhPf7v6xLEMUL5lxyVk6yhxm7mmZGpfqnK7uC+gkN1TA/fhTFF+Ni8eOjwedPXsgLeSEvDPLCIC8M8sIgL+SFvJAX8kJeGOSFQV4Y5IVBXsgLeSEv5IW8MMgLow1e1i4XfH/kJR8deSEvcl48eSEvAC+RvJAXgJedvJAXOS9DR17Ii5yXSF7IC8DLTl7Ii5yX0JEX8iLnZSUv5EXOy7Nsl7yQF6h7IS/kBcheyAt5eYx+Jy/kRc7L0pEX8iLmZezIC3kR8zJ05IW8iHnxO3khL2JeDnAhL+Tlj8HoABfyQl6kqS55IS9/rrssHXkhL1Jewt6RF/Ii5GVYO4vYctouxGVLe2cXXvHg3TeN3eeu6rR9lRafl5ewGr3I6RHEOXXmMSse/PeSwTV7Vac9V2nxSXkZotmnv/ffvulYAZZ//h8HP/f+e0tC9qpK2+01WnxSXtZq372bu1oxwc/9u+mesld12lOVFp+Ul65SXtHHrl5s8HNfs+9vNdHeqrT4/rz8/kxC6mrGUJiR/hwfvIn2UKXFDfAyIhlgWSyFGenyopWo9lKlxffn5f9s122VcUHzx4casCF7VaXt9hotboCX+OsJpq56ROipj9mRczTRjlVa3AAvTmhym0QqykjHl3kqpp2qtPj+vKxY/1waoSAj/TlyDibaoUqLG+AlvG8w+h1PTUY6H+SpiPZapcX35yX18sWIN5tIDz2eP+oH5dq+Sosb4GV6z0RaY8lM2Q99MtGeq7S4AV4cOJqbm1XyjDQc5qli7X6v0uL787J8PfHv6sVobh3h2mOVFjfAi4fWIt5qIq3ZhZDVRHur0uL787J95auPTmAiPSwHOckikUx7qNLiBngZ35zsApZMzP5VNNFeqrT4/rz8zOTe3L3ILBnIOgK14aVJ3ES6Jy/z+7OX3+bwmHXUy/JUifZUpcUN8OIhJ+WtJhJmHWHaqUqL78/Lqkr+3mIi+ezI6U20Q5UWN8BL+ES2K7Nk5uzIOZtor1VafH9e/rOO0vt56RyakXp5nnqoXaXFDfAyfWLx5fe1N3lGugF5agQn6jYtboCXt1tHj664NCMdgZ7wQFvpfaS+dV6Wr8/MpgWWzJB9WYOJ9lilxQ3wMujWOt9hIi3ZwWAx0d6qtPj+vGyFz89k6UeY7TpsVdYbFUrJVS+wfxrBp2DxalIUf0gwXMytI5n2Ujp+t87LbrsQLk0TXlkye3adSG76vNAuqGqHTKT78vL6L3stL4cvZpIXSvXoPG4ytI503w55QeNoLTaJh7IJzrOSoXWkM5E4HqFxmFgO5tbRsXaZVzaQl2r57rFNswo7pkXhcq2G1pHKRLovL2Xz6T1tSwxOZQM7WaGUhwv6n2qXeh+OvNis16V5wBfeo6xQSrUqGw2tI42JdF9erPyAFB2onLdkZIVSq0b7kOBN1eK2eDH0G2eH9f5BkJHm99jvXqN9eKuDRrUxXkzrGWKPDHWr2jqKKu2jTmlRqTbGi229VArI7NVrC6W8Rlsww1eoNseLcT3mDKA4H2ZT69OruLZkBRFXbY4X63rvzYlX3x93ssv22AeNdi9xKPAWN8eLeQFvcmoTSWYd/XsV1j5EwZXZXs3wYl5ht3vpELAdZKTTi6uo9iYaalDVBnmxr/j+Zf2DJpLPLqjmr6LawlRWbXu1w0uFHUi/hiSsbEpWKLWotBdhx1FS6NUILxW2lGzS6mr3KiMdnl9FtQ/vcdSotslLjT0CMzApwayjDZrwwFO13iTjvTcvNc4jC7iJJLOORo1BBZifOturKV5qbFr777ECRo/QOurlC7ZBfoNeo9osLzU23Ue0bEp2PPOsKslCire0hV4t8VJjG5LDvmyxdfSF9xpQnwH0Re3yUuE8+BkzkWTHM6/Q0vSsKj43MJFuz0uN35tw0MxEbh3Bsx5wzmNgIt2flwq/ZxNlII7ZbDe/x/7b5ESoDW6eE6o2zov9kJSQlVXZ8cwRrD7eVGu20rXgtnmx/z2+QebcDLn1V/f19CriCg3SfwSrkpdatVOSzxuzjuTzukXVXRSbSI3wYvx7wklmyfydPz6svw7ZVdnhcPtJThtPRwSq5OXnVMLUS3LS6cmYJW18Oe2VaiumO8UmUjO8/J0zGA5KQbj80cv22E+KITT1muWUY1Xy8j8x0WpUisLl1Sk7wfWvp71C7cMO02tUA3n5Y4YwmyCzCC2ZlP3kZ9G66pH20dCymp4W0Cgv//QyIS5bKlvE25T+t3++897cWw86VUde8OgnoS+TFJhNwlWysp4wKVUjedHEa2B2XQXfUaGUZXVgVKq+znjJy7MeRvY/O/wHWQfpmkeRU/r0FMMyE+navPQf5wU6ZubZHvtnUXKEzaJWXa/MS61T6KzGI2jXrc9aR77Kjt5Br+ovzEu1U+iM8l2kgO/5Hnv74sCtQHW+MC8fOtUdeB3yk29D1joK6k5O2/OWlE2dnZflnLwsgCXzZ58UhNNeTBvyDUtMpLPzEs/JS1TUSrzaY29dhzEXqW7X5SWck5eAWDKwdQRrQylr0d77s/PizsmLw3Os/PHMS5X8bStUXS7Ly0d+tRNca5edoft6j/2z0P1q2lio+rzXOz0v8xl5mfGs9GCPvWnGe1gld6gaL8vLcEZeBjwpx6yjsoQ/Fqumy/JyxgEp4UkWaB2VJXCuXDVclpcTzqgjWoQk2WP/LPCfHlkNVNfL8nLCGZLDZ/2odVSyohAMVHd/VV7Ol/E+9gqHpdcpuxAvOoUdPvNIdO5Pr9x7fwFe3Om7F6ElA1lHehNpMlF9klpdgJezZTBRw/SIWkf678XZqI6X5aU/1RQp391LtqauAvDKPdfFSHW7LC/nMpGC1pIBrSOtieStVIfL8nKmlHdWWzJR2RFgJtJmprpcl5fzlE1takvGJ8n3W2wijWaq2f7vIry4k6QwyaktmUXdESAm0t7bqU7X5aXGKXQaI8/ZjZnyjgDRng1V04V5qXAKnQIXb1fatCOV6nJtb6kaLszLCYak5AyNHqQjkGuvpqrrlXmxP4UOTXWd5azfQ/cu1Q6mqpnh90K8fHhafdghQMuKG3bnQu3U26rGa/NifAodNBYJvlzE6Angncu0J2PVxyTrWrwYn0IHeEaSDxcwenZ0X6ZM21mrjhfnxfYUOvFQJHwPcqMnwvct0V7MVbfL82J5Cp1sJIrir1Zca7w7+K4l2oO9qr8+L19mp9AJYJmhdyCdwa2Kez7W3iqozrfg5cvmFLpXPUDalhjQbkBq9ATFDR9rjxVUv/eEl+WF8ZEgLwzywiAvDPLC509eyAt5IS8M8sIgLwzywiAv5IW8kBfyQl4Y5IVBXhjkhUFeyAt5IS/khbwwyAuDvDDIC+OWvPwFgd7gz8BmAQA="));
|
||||
graphics.resetPalette();graphics.setBackground(0,0,0);
|
||||
|
||||
let logo = gzip.decomp(base64.atob("KLUv/aTAZgEABUAAZjZzEeDpUsq9pdxbyp1kAwAAQIEBbABsAG4AM2iX1JTWdkQh0DgC2AAAYCcpIWMQM9tMW2aimiH1Z1+Gs/X33dfS13naMQYOYyi7vqBstcwUJO0jYKEmjCffvSl9rXfaK8QbcmjFEiYGDL4+8GqOs6dJec2D7FALXA4eSzbiIrY91x6wSZkSBYCpzrgjdC+wdrQkQvrTu3MIV6jD9xL9diN1ncSElF0ug1EVqTjCFiS8J3/3tmHEjjFySAAb+AfOmcwxclRwoAq+IVUKpHd5/u1bCUEkaLYBYHapqgJhCxI+/H79Me4Gll3rLfuZl75gh2ClQ3DuhC2NQSuEmUgnJgkFVTViyRg3hsJ3vyfSu9tToYJuIMmiDgP3FYeCDB/uo1lVGhpVm5F136/KzzjVz5c03IIR9v0o6m3uHEJwnHEAGanNBbNS4k3w6/kcd1cccPt7FAnWd1K66ggTT5cSRAzfEDATFVR96zTH3BE/E35auqOhFWaqNkc6iTjzNQPP/BAyeNWPAUC7C0Yx4H4E7bjqvyGUgswZ6TycAmaTY8wRUqXwh7uZ+ZFUSRHBmtlPCJlBJHNn/0d1dG6qjjYsIX5DqAqjOiNzZoycv4BZBoMbqALqfbUcgkKNgBCQEGkIaxsSQCAQBBAIBEEQEIQgBAlGoBAgBAjCIAyCwAzEsSTmjfKsYQypHIaqhY782TFH0zZk9v65rXj1wihIZhwaM9EJCM6oGrKY+HR9fateD4VUZCQ6YM7lMzz6/BCOyT0+DyY6xduMZwQ+IvB0W/J8nr/LrEB02XOAZ2GXwdk7vrVEXeHSoGu6a2GzcnxtqibNPJsDaw9b9ZbsCUobzYVqZo7PAtcoijH1PsdJMg8eoI1UiYn8GK6Ef/tYKXRIO7jy1b/N2HHZp4qkM/V5+GwwvuGslANy8mHLtBWe3WYoWKY5nrzlh3LL5OcCr8P2FWUG/ETfR7mkZeomXhtLqXzfiVabPVkhsdgoTEYATB4fhUqGpL/QUZuxmNSuhPQjkY9aG8vkbhib7siueLJ5dvadM30INl7WNtrc4egmSg9CPkobFRrsW/niGcNHsMn6B4xuTXx8hNhmKO1ML6mv7gMBIxG2GYI1U0/KV7zsCSBCYhPMdiGp3Vv6HtFt7Nkko/IRxsvERNY2IyNlWMMx0cRmgwcjTGpuMzhQpoVfH6X4q2wDP7G4zQjEDIwZH3VYKKGHmxhvBiGzOwlF+Sg1ElRXggZuxiFkfKdcNdR09uuxTEE4L/3jWoGD+ywc6ZjhDTJ9ut1PGtubE8TZipUigaXXMfDkVcNmS8DsR5YyTHKPI6OPmasYaDGW0kRk82/5/cqMVmaJzThEa/rWDjVZxGYKdoV6dsbdMobx9ZAepdj3N3LgoaQzDk0KZlguE0pCudLRd8dH8n6WOdqeTlwjMGM7WVltyPZHhWU/QIgVZ3+ucE2AGdvKytTxJ6wDz/5CCQT+3ETT/4wts2eQ5y3LOTsuYccfCqEoG4YSEcww+sxowh+9+aEyfHTghdBlypFghsuZ7YX7wK7CR0UJTfVTyiQwQ8vMZEh8sakD4+Sp+l9DvBICMybKaqr+k3OcolmAdRXeDKKAGZPJSlTxWbr7nP31GfHeeP8zhMrMUPT10A0VO1mOj/6fYUhmWhgVwUtb0YwqSErvnP1nhJ6BYmDmQDQfHVPdj07oi8mmFdBXA07oDpROeTqe/wwhMrth/l1aMx+1s0MSvL7+GUNkt6tDqjz7qMgj6dmL/WfclPEO1/YjeuqkNJj8/M/8M3KVkaA9n9r4DmvMD8E/45yMf4iPgxP2pFO+vGL/DI1kzAn5tiNYEEmjvtB/RtAMzNoPKUUz4q5hNpBxlv8MF5mUwBrwdbTXi0JSFw/in5EjwzbiQ/2z78Bmr73nDdk/4ySDbuIDFYpmdBMy5N76nxFoGV+7PrdGqmDbMqf/GSPM8o34XBYBvz1Vmav6ZwQ6g2/Eh7bPvhM/QPry9c/waCaLjHxcOkupiCLUIRwAanUsvp7Ax+a7SQTzcWY7lKYhfBS34FcKfjSqBcxYnkOPkE+xOPubV00IeJmXP2W09UvGfZQnQsuaFIqMxxYlIlYGAmclmlGk6eUZspS50IqMBwEER6zPgifis2GZtwyp50ApFStb1EcH3125BLCohNFHj5LnsG9sAMimTCL5dGBNBGULG04Z+64Pk+WWFudEIbUPUUyKullbxuYxtw8vY0+VStQSBIb+0O867s577g8PK1+DvBTDdf540PO3fpLNYjQ1Zb9eYNlc3dnIPB14Z0MIpUYls2Szge1ZVVjbtaWc5w9YllOo4yUWZd/kKp7e7hVsosD5hb0klIS4IbDUf0ZWT1P3UWnn36CDETiC2icObjVnOk9gEUs+CwnBrXRZ8lmCW+FCJl6IDK/UskGGhoK1PPmai6sXWRNFkCouVK1WJjKT50dEgiFjNI+hF85yoFOGIjIG3QcbvlLQ5hs2IoSGEfmGBTtDKIWQ7J7PN3dIyHPfiUj2AJQTS0aeh9/4L+aStPh15LwkEAiJZ5/FSxCsfjUzn8TDxBn46ovRPoSfIL8mP1X03Pabiy68ka+pJqRslV8laE6k+Q9HcHLpI+AQVtppJr8BoJzx6B750IQ8uuCrnhC5jQqKwkBTECiKgQ4HUqd4N/7BkwqOVTyp+LzCk4rHig8rPFdwXvFc8cnCkxXPFZ8sPFhwrHi68CH9xGzMoB3jyrOhVB3SqMvm"));
|
||||
// display logo in kickin' ass-style of panasonic
|
||||
// hide entire framebuffer with black text to hide the slow image drawing
|
||||
/*
|
||||
@@ -77,7 +76,7 @@ tmr = sys.nanoTime();
|
||||
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
||||
// clear screen
|
||||
graphics.clearPixels(255);con.color_pair(239,255);
|
||||
con.clear();con.move(1,1);graphics.resetPalette();
|
||||
con.clear();con.move(1,1);
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
BIN
assets/bios/tsvmlogo.bin.zst
Normal file
BIN
assets/bios/tsvmlogo.bin.zst
Normal file
Binary file not shown.
@@ -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
9
assets/disk0/commandrc
Normal 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
|
||||
@@ -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/VTMGR.SYS")
|
||||
runBatch("\\AUTOEXEC.BAT")
|
||||
}
|
||||
else {
|
||||
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
|
||||
}
|
||||
}
|
||||
|
||||
572
assets/disk0/tvdos/VTMGR.SYS
Normal file
572
assets/disk0/tvdos/VTMGR.SYS
Normal file
@@ -0,0 +1,572 @@
|
||||
// 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
|
||||
|
||||
// Re-assert ownership of the cooked keyboard. keyboardInputRequested (-39)
|
||||
// is a single global flag gating whether typed chars reach keyboardBuffer;
|
||||
// the dispatcher relies on it staying 1. An active-pane app that used cooked
|
||||
// host input (sys.read / sys.readKey leave it at 0) or crashed mid-read
|
||||
// leaves it off, and the shimmed con.getch — unlike the base getch, which
|
||||
// calls sys.readKey (→ -39=1) every time — never re-asserts it. That is why
|
||||
// the current VT's keyboard locks up (and why it never happens without
|
||||
// vtmgr). Re-enable ONLY when it is actually off: poke(-39,1) clears
|
||||
// keyboardBuffer, so doing it every frame would drop chars typed last frame.
|
||||
if (sys.peek(-39) === 0) sys.poke(-39, 1)
|
||||
|
||||
// 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
|
||||
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "color",
|
||||
"summary": "Set the screen background and foreground colours",
|
||||
"symbols": {
|
||||
"code": {
|
||||
"kind": "positional",
|
||||
"type": "string",
|
||||
"name": "BF",
|
||||
"summary": "Two hex digits: background then foreground",
|
||||
"validation": { "pattern": "^[0-9A-Fa-f]{2}$" },
|
||||
"completion": { "method": "none" }
|
||||
}
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "code" }
|
||||
}
|
||||
@@ -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,234 @@ 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
|
||||
}
|
||||
|
||||
// Lazily-resolved synopsis module (TSF loader/completion resolver). Held for
|
||||
// the whole session so its in-memory cache survives across keystrokes.
|
||||
// undefined = not probed yet, null = unavailable.
|
||||
let _acSyn = undefined
|
||||
function getSynopsisMod() {
|
||||
if (_acSyn !== undefined) return _acSyn
|
||||
_acSyn = null
|
||||
try {
|
||||
let m = require("synopsis") // resolved through INCLPATH (\tvdos\include\synopsis.mjs)
|
||||
if (m && typeof m.getCompletion === "function") _acSyn = m
|
||||
} catch (e) {
|
||||
debugprintln("command.js > autocomplete: synopsis unavailable: " + e)
|
||||
}
|
||||
return _acSyn
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Candidates for an argument (not the command word). Consults the command's
|
||||
// TSF synopsis (via synopsis.mjs) for option flags, enum/list values and
|
||||
// subcommand names, and merges in filesystem entries when the synopsis says the
|
||||
// slot expects a path/file/directory. Falls back to plain path completion when
|
||||
// no synopsis exists, so behaviour is unchanged for commands without one.
|
||||
function _acArgCandidates(prefix, word) {
|
||||
let syn = getSynopsisMod()
|
||||
if (syn) {
|
||||
try {
|
||||
let toks = prefix.trim().split(/\s+/)
|
||||
let cmd = toks[0]
|
||||
let argToks = toks.slice(1)
|
||||
let r = syn.getCompletion(cmd, argToks, word)
|
||||
if (r && r.ok) {
|
||||
let out = (r.candidates || []).slice()
|
||||
if (r.filesystem) {
|
||||
_acPathCandidates(word).forEach(function(c) {
|
||||
if (r.filesystem === 'directory' && !c.isDir) return // dirs only
|
||||
out.push(c)
|
||||
})
|
||||
}
|
||||
// de-dupe by the text that would be inserted
|
||||
let seen = {}, dedup = []
|
||||
out.forEach(function(c) { if (seen[c.value]) return; seen[c.value] = true; dedup.push(c) })
|
||||
return dedup
|
||||
}
|
||||
} catch (e) {
|
||||
debugprintln("command.js > _acArgCandidates: " + e)
|
||||
}
|
||||
}
|
||||
return _acPathCandidates(word)
|
||||
}
|
||||
|
||||
// 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 prefix = line.substring(0, wordStart)
|
||||
let isFirstWord = (prefix.trim().length === 0)
|
||||
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||
let candidates
|
||||
if (isFirstWord)
|
||||
candidates = hasPathSep ? _acPathCandidates(word) : _acCommandCandidates(word)
|
||||
else
|
||||
candidates = _acArgCandidates(prefix, 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 +1270,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 +1412,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("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "drives",
|
||||
"summary": "List connected and mounted disk drives",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "edit",
|
||||
"summary": "Full-screen text editor",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to edit; a new buffer when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "geturl",
|
||||
"summary": "Fetch a URL and print the response",
|
||||
"symbols": {
|
||||
"url": { "kind": "positional", "type": "url", "name": "URL", "summary": "Address to fetch" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "url" }
|
||||
}
|
||||
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "gzip",
|
||||
"summary": "Compress or decompress a file (Zstd-backed)",
|
||||
"symbols": {
|
||||
"decompress": { "kind": "option", "short": "-d", "summary": "Decompress instead of compress" },
|
||||
"stdout": { "kind": "option", "short": "-c", "summary": "Write to the pipe instead of a file" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["decompress", "stdout"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to process" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||
{ "type": "reference", "symbol": "file" }
|
||||
]
|
||||
}
|
||||
}
|
||||
1
assets/disk0/tvdos/bin/help.alias
Normal file
1
assets/disk0/tvdos/bin/help.alias
Normal file
@@ -0,0 +1 @@
|
||||
synopsis $0
|
||||
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "hexdump",
|
||||
"summary": "Print a file as a hexadecimal dump",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to dump; reads from the pipe when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const net = require("net")
|
||||
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||
//// default searches from ProperName
|
||||
// hopper {install,in} query [-v version]
|
||||
// hopper {upgrade,up} [package...]
|
||||
//// no package names upgrades every user-installed package
|
||||
// hopper {remove,rm} query
|
||||
|
||||
// ============================================================
|
||||
@@ -372,14 +374,31 @@ function findProviders(idx, name) {
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
||||
function sortCandidates(cands) {
|
||||
// Sort candidates by preference. Normally installed-first (no churn) then
|
||||
// highest version; with `preferNewest` (used by `upgrade`) the installed
|
||||
// bias is dropped so the newest package version wins regardless of source.
|
||||
function sortCandidates(cands, preferNewest) {
|
||||
return cands.slice().sort((a, b) => {
|
||||
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||
if (!preferNewest && a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||
return -compareVersion(a.version, b.version)
|
||||
})
|
||||
}
|
||||
|
||||
// Highest *package* version (HopperPackageVersion) among the candidates that
|
||||
// ARE the package `name` -- installed or upstream -- or null if there are
|
||||
// none. `upgrade` uses this to decide whether a newer build exists. It
|
||||
// deliberately ignores HopperProvides versions (which advance independently
|
||||
// of the package version) and other packages that merely provide `name`;
|
||||
// the candidate index is keyed by package name, so idx.get(name) is exactly
|
||||
// the builds of that package.
|
||||
function latestInstallableVersion(idx, name) {
|
||||
const arr = idx.get(name)
|
||||
if (!arr || arr.length === 0) return null
|
||||
let best = null
|
||||
arr.forEach(c => { if (best === null || compareVersion(c.version, best) > 0) best = c.version })
|
||||
return best
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||
// ============================================================
|
||||
@@ -399,7 +418,7 @@ function sortCandidates(cands) {
|
||||
// recursive resolve() call over each requirement. Replacing this with
|
||||
// clause learning / a watched-literals scheme later would be local.
|
||||
|
||||
function resolveAll(idx, requirements) {
|
||||
function resolveAll(idx, requirements, upgradeSet, pkgVersionPins) {
|
||||
const chosen = new Map()
|
||||
const issues = []
|
||||
|
||||
@@ -407,10 +426,16 @@ function resolveAll(idx, requirements) {
|
||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||
|
||||
function _resolve(reqName, constraint, trail) {
|
||||
// A package-version pin (from `install -v`) constrains the chosen
|
||||
// build's HopperPackageVersion -- the version `search` shows -- and is
|
||||
// matched separately from `constraint`, which always works in the
|
||||
// HopperProvides capability space.
|
||||
const pin = (pkgVersionPins && pkgVersionPins.get(reqName)) || null
|
||||
|
||||
const existing = chosen.get(reqName)
|
||||
if (existing !== undefined) {
|
||||
const v = providedVersionOf(existing, reqName)
|
||||
return satisfies(v, constraint)
|
||||
return (satisfies(v, constraint) && (!pin || satisfies(existing.version, pin)))
|
||||
? { ok: true }
|
||||
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||
}
|
||||
@@ -421,9 +446,19 @@ function resolveAll(idx, requirements) {
|
||||
}
|
||||
// Satisfaction checks the virtual version the candidate exposes
|
||||
// for `reqName` (HopperProvides), not necessarily the package's
|
||||
// own HopperPackageVersion.
|
||||
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
||||
// own HopperPackageVersion. A package-version pin is applied on top,
|
||||
// against the build's own version.
|
||||
const preferNewest = !!(upgradeSet && upgradeSet.has(reqName))
|
||||
const provMatched = providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint))
|
||||
const matching = sortCandidates(provMatched.filter(c => !pin || satisfies(c.version, pin)), preferNewest)
|
||||
if (matching.length === 0) {
|
||||
// When the package-version pin is what eliminated the candidates,
|
||||
// report it in package-version space (matching `search`); a plain
|
||||
// capability mismatch stays in HopperProvides space.
|
||||
if (pin && provMatched.length > 0) {
|
||||
const versions = providers.map(p => `${p.version}[${p.source}]`).join(", ")
|
||||
return { ok: false, reason: `no build of "${reqName}" has version ${pin} (available: ${versions})` }
|
||||
}
|
||||
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||
}
|
||||
@@ -728,43 +763,11 @@ function _installOne(action, candidate) {
|
||||
return true
|
||||
}
|
||||
|
||||
function cmdInstall(args) {
|
||||
let query = undefined
|
||||
let version = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
||||
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||
else query = args[i]
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper install <package> [-v <version>]")
|
||||
return 1
|
||||
}
|
||||
|
||||
const targetConstraint = version || "*"
|
||||
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
|
||||
println(`Resolving ${query}${verSuffix} ...`)
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Sanity check: target must exist in the index (installed or upstream).
|
||||
if (findProviders(idx, query).length === 0) {
|
||||
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||
return 4
|
||||
}
|
||||
|
||||
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||
// constraints can drive upgrades of dependencies. The installed-set
|
||||
// requirements follow at "*" so the resolver still has to keep them
|
||||
// alive (preferring installed candidates when their version still fits,
|
||||
// otherwise upgrading or downgrading them).
|
||||
const seed = [{ name: query, constraint: targetConstraint }]
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m.HopperPackageName === query) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed)
|
||||
// Shared tail for `install` and `upgrade`: turn a resolver result into an
|
||||
// actual on-disk change. Prints the plan, runs the pre-flight checks
|
||||
// (system-package and missing-payload blockers, modem availability), asks
|
||||
// for confirmation, then fetches and writes every changing package.
|
||||
function commitResolution(idx, chosen, issues, planLabel, confirmMsg) {
|
||||
if (issues.length > 0) {
|
||||
printerrln("Resolution failed:")
|
||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||
@@ -774,7 +777,7 @@ function cmdInstall(args) {
|
||||
}
|
||||
|
||||
const plan = classifyPlan(idx, chosen)
|
||||
printPlan(plan, query)
|
||||
printPlan(plan, planLabel)
|
||||
|
||||
const changing = plan.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) return 0
|
||||
@@ -804,7 +807,7 @@ function cmdInstall(args) {
|
||||
}
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with installation?", true)) {
|
||||
if (!confirm(confirmMsg, true)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
@@ -835,6 +838,132 @@ function cmdInstall(args) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function cmdInstall(args) {
|
||||
let query = undefined
|
||||
let version = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
||||
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||
else query = args[i]
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper install <package> [-v <version>]")
|
||||
return 1
|
||||
}
|
||||
|
||||
const verSuffix = version ? ` (${version})` : ""
|
||||
println(`Resolving ${query}${verSuffix} ...`)
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Sanity check: target must exist in the index (installed or upstream).
|
||||
if (findProviders(idx, query).length === 0) {
|
||||
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||
return 4
|
||||
}
|
||||
|
||||
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||
// constraints can drive upgrades of dependencies. The installed-set
|
||||
// requirements follow at "*" so the resolver still has to keep them
|
||||
// alive (preferring installed candidates when their version still fits,
|
||||
// otherwise upgrading or downgrading them).
|
||||
//
|
||||
// A user-supplied `-v` pins the target's PACKAGE version (the version
|
||||
// `hopper search` displays), NOT its HopperProvides capability version
|
||||
// which the resolver otherwise matches against. So the seed constraint
|
||||
// stays "*" (capability space) and the version goes into a package-
|
||||
// version pin; dependencies keep resolving in capability space.
|
||||
const seed = [{ name: query, constraint: "*" }]
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m.HopperPackageName === query) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const pkgVersionPins = new Map()
|
||||
if (version) pkgVersionPins.set(query, version)
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed, null, pkgVersionPins)
|
||||
return commitResolution(idx, chosen, issues, query, "Proceed with installation?")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Upgrade
|
||||
// ============================================================
|
||||
//
|
||||
// `upgrade` is `install` without the "keep what's installed" bias. For
|
||||
// every named package -- or, with no names, every user-installed package
|
||||
// -- it forces the resolver to pick a version strictly newer than what is
|
||||
// installed, which makes it choose the latest build the mirrors offer
|
||||
// (the resolver still backtracks to a lower-but-newer version if the
|
||||
// newest one would break a dependency). Packages that are already at
|
||||
// their newest version are skipped, and system packages are read-only.
|
||||
|
||||
function cmdUpgrade(args) {
|
||||
const names = []
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith("-")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||
names.push(args[i])
|
||||
}
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Target set: the named packages, or -- when none are named -- every
|
||||
// user-installed package. System packages cannot be upgraded.
|
||||
const targets = []
|
||||
if (names.length === 0) {
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m._origin === "system") return
|
||||
targets.push({ name: m.HopperPackageName, installed: m.HopperPackageVersion || "0.0.0" })
|
||||
})
|
||||
if (targets.length === 0) {
|
||||
println("No user-installed packages to upgrade.")
|
||||
return 0
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const m = findInstalledManifest(names[i])
|
||||
if (m === undefined) { printerrln(`Package not installed: ${names[i]}`); return 2 }
|
||||
if (m._origin === "system") { printerrln(`Cannot upgrade ${names[i]}: it is a system package.`); return 6 }
|
||||
targets.push({ name: m.HopperPackageName, installed: m.HopperPackageVersion || "0.0.0" })
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only the targets that actually have a newer *package* version
|
||||
// available. Compare HopperPackageVersion (the real build), NOT the
|
||||
// HopperProvides capability version, which can advance independently.
|
||||
const upgradeNames = new Set()
|
||||
targets.forEach(t => {
|
||||
const latest = latestInstallableVersion(idx, t.name)
|
||||
if (latest !== null && compareVersion(latest, t.installed) > 0) upgradeNames.add(t.name)
|
||||
})
|
||||
|
||||
if (upgradeNames.size === 0) {
|
||||
println("Everything is up to date.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// Seed the resolver. Upgrade targets stay at "*" but join the upgrade
|
||||
// set, which flips their candidate preference from "keep what is
|
||||
// installed" to "pick the newest build". A version constraint cannot do
|
||||
// this: satisfaction is tested against the HopperProvides version, which
|
||||
// is decoupled from the package version, so an installed copy whose
|
||||
// provided version is already high would wrongly satisfy ">installed".
|
||||
// Everything else stays at "*" so it is kept unless a dependency drags
|
||||
// it along.
|
||||
const seed = []
|
||||
upgradeNames.forEach(n => seed.push({ name: n, constraint: "*" }))
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (upgradeNames.has(m.HopperPackageName)) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const label = (names.length === 1) ? names[0] : "the selected packages"
|
||||
println(`Resolving upgrade for ${label} ...`)
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed, upgradeNames)
|
||||
return commitResolution(idx, chosen, issues, label, "Proceed with upgrade?")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remove
|
||||
// ============================================================
|
||||
@@ -929,6 +1058,7 @@ function printUsage() {
|
||||
println("Usage:")
|
||||
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||
println(" hopper {install,in} <package> [-v <version>]")
|
||||
println(" hopper {upgrade,up} [<package>...]")
|
||||
println(" hopper {remove,rm} <package>")
|
||||
}
|
||||
|
||||
@@ -943,6 +1073,9 @@ switch (_hopperCmd) {
|
||||
case "install":
|
||||
case "in":
|
||||
return cmdInstall(_hopperRest)
|
||||
case "upgrade":
|
||||
case "up":
|
||||
return cmdUpgrade(_hopperRest)
|
||||
case "remove":
|
||||
case "rm":
|
||||
return cmdRemove(_hopperRest)
|
||||
|
||||
81
assets/disk0/tvdos/bin/hopper.js.synopsis
Normal file
81
assets/disk0/tvdos/bin/hopper.js.synopsis
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "hopper",
|
||||
"summary": "Package manager for TVDOS",
|
||||
"description": "Hopper resolves package dependencies across the installed set (system packages shipped with TVDOS plus user packages under A:/hopper) and any remote mirrors listed in A:/tvdos/hopper/mirrors.list, then installs, upgrades, downgrades or removes user packages. System packages are read-only: install and remove refuse to touch them. Versions are strict SemVer (MAJOR.MINOR.PATCH); constraints support *, X.*, X.Y.*, exact, ^, ~ and >=/>/<=/< operators, comma-separated for AND.",
|
||||
"symbols": {
|
||||
"search": { "kind": "subcommand", "name": "search", "summary": "Search installed packages and remote mirrors (alias: se)" },
|
||||
"install": { "kind": "subcommand", "name": "install", "summary": "Resolve dependencies and install a package (alias: in)" },
|
||||
"upgrade": { "kind": "subcommand", "name": "upgrade", "summary": "Upgrade packages to the latest available version; all user packages when none named (alias: up)" },
|
||||
"remove": { "kind": "subcommand", "name": "remove", "summary": "Remove a user-installed package (alias: rm)" },
|
||||
|
||||
"provides": { "kind": "option", "long": "--provides", "summary": "Match against the HopperProvides field instead of the name" },
|
||||
"requires": { "kind": "option", "long": "--requires", "summary": "Match against the HopperRequires field instead of the name" },
|
||||
"description": { "kind": "option", "long": "--description", "summary": "Match against the package description instead of the name" },
|
||||
"author": { "kind": "option", "long": "--author", "summary": "Match against the package author instead of the name" },
|
||||
"searchFields": {
|
||||
"kind": "group",
|
||||
"summary": "Search-field selectors",
|
||||
"members": ["provides", "requires", "description", "author"]
|
||||
},
|
||||
|
||||
"version": {
|
||||
"kind": "option",
|
||||
"short": "-v",
|
||||
"summary": "Install a specific package version or range",
|
||||
"value": {
|
||||
"name": "VERSION",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"summary": "Package version as shown by search, or a constraint, e.g. 1.2.0, ^1.2.0, ~1.2, 2.*"
|
||||
}
|
||||
},
|
||||
|
||||
"query": { "kind": "positional", "type": "string", "name": "QUERY", "summary": "Substring matched against the package name (or the selected field)" },
|
||||
"pkgInstall": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the package (or virtual capability) to install" },
|
||||
"pkgUpgrade": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Package(s) to upgrade; upgrades every user package when omitted" },
|
||||
"pkgRemove": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the user-installed package to remove" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "choice",
|
||||
"children": [
|
||||
{
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "search" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "searchFields" } },
|
||||
{ "type": "reference", "symbol": "query" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "install" },
|
||||
{ "type": "reference", "symbol": "pkgInstall" },
|
||||
{ "type": "optional", "child": { "type": "reference", "symbol": "version" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "upgrade" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "pkgUpgrade" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "remove" },
|
||||
{ "type": "reference", "symbol": "pkgRemove" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"constraints": [
|
||||
{
|
||||
"type": "cardinality",
|
||||
"symbols": ["provides", "requires", "description", "author"],
|
||||
"maximum": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "less",
|
||||
"summary": "View text a screen at a time",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to view; reads from the pipe when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "lfs",
|
||||
"summary": "Create, extract or list a Linear File Strip (.lfs) archive",
|
||||
"description": "Bundles a directory tree into a single TVDOS Linear File Strip archive, or unpacks one. Exactly one mode must be given: -c creates ARCHIVE from PATH, -x extracts ARCHIVE into PATH, and -t lists the files in ARCHIVE (PATH is not used). Individual files are stored uncompressed; gzip the whole .lfs to compress it.",
|
||||
"symbols": {
|
||||
"create": { "kind": "option", "short": "-c", "summary": "Create an archive from a directory" },
|
||||
"extract": { "kind": "option", "short": "-x", "summary": "Extract an archive into a directory" },
|
||||
"list": { "kind": "option", "short": "-t", "summary": "List the files stored in an archive" },
|
||||
"relative": { "kind": "option", "short": "-r", "summary": "Store paths relative to the source directory (with -c)" },
|
||||
"archive": { "kind": "positional", "type": "file", "name": "ARCHIVE", "summary": "The .lfs archive file" },
|
||||
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Source directory (-c) or destination directory (-x); unused for -t" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"type": "choice",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "create" },
|
||||
{ "type": "reference", "symbol": "extract" },
|
||||
{ "type": "reference", "symbol": "list" }
|
||||
]
|
||||
},
|
||||
{ "type": "optional", "child": { "type": "reference", "symbol": "relative" } },
|
||||
{ "type": "reference", "symbol": "archive" },
|
||||
{ "type": "optional", "child": { "type": "reference", "symbol": "path" } }
|
||||
]
|
||||
},
|
||||
"constraints": [
|
||||
{ "type": "cardinality", "symbols": ["create", "extract", "list"], "minimum": 1, "maximum": 1 },
|
||||
{ "type": "requires", "subject": "relative", "targets": ["create"] }
|
||||
]
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "movprobe",
|
||||
"summary": "Print metadata about a movie file",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to inspect" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
360
assets/disk0/tvdos/bin/playmov.js
Normal file
360
assets/disk0/tvdos/bin/playmov.js
Normal file
@@ -0,0 +1,360 @@
|
||||
// playmov — all-in-one movie player (MOV/iPF, TEV, TAV, TAP).
|
||||
//
|
||||
// Consolidates playmv1 / playtev / playtav behind one decode library
|
||||
// (mediadec.mjs) and one simple pipeline:
|
||||
//
|
||||
// loop:
|
||||
// read input (quit / pause / seek / volume / cue / ASCII-toggle)
|
||||
// [backend] dec.step() -> decode the next due frame into a RAM RGB888 frame
|
||||
// [player] hold the frame
|
||||
// [postprocessor] subtitle state resolved by the library
|
||||
// [draw] graphics: dec.blit() (upload RAM frame to adapter) + dec.bias()
|
||||
// ASCII: dec.sampleGray + aa.mjs straight off the RAM frame (no upload)
|
||||
// then subtitle overlay + playgui chrome
|
||||
//
|
||||
// Usage: playmov FILE [-i] [-ascii] [-colour] [-deblock] [-boundaryaware]
|
||||
// [-deinterlace=yadif|bwdif] [-debug-mv]
|
||||
// -i interactive (controls + on-screen chrome)
|
||||
// -ascii start in ASCII-render mode (proves the framebuffer flow; aa.mjs)
|
||||
// -colour colourise ASCII glyphs from the video (implies -ascii); -color alias
|
||||
// (others forwarded to the TEV backend, matching playtev)
|
||||
// Controls: Bksp quit | Space pause | Left/Right seek | Up/Down volume
|
||||
// PgUp/PgDn cue prev/next | A toggle ASCII | C toggle colour
|
||||
|
||||
const mediadec = require("mediadec")
|
||||
const gui = require("playgui")
|
||||
const K = require("keysym")
|
||||
|
||||
// aa.mjs (the ASCII renderer) is OPTIONAL. If it isn't installed, playmov still
|
||||
// plays everything normally; ASCII mode just isn't available (-ascii is ignored
|
||||
// and the A key is inert). require() throws when the module is missing, so guard it.
|
||||
let aa = null
|
||||
try { aa = require("aa") } catch (e) { aa = null } // hopper/include/aa.mjs
|
||||
|
||||
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
|
||||
const VOL_STEP = 16
|
||||
|
||||
// Text-plane palette indices: 0 = GUI background (translucent black), 240 = pure
|
||||
// opaque black, 255 = transparent (GraphicsAdapter: "palette 255 is always
|
||||
// transparent"). aa.mjs paints cell backgrounds with 255, so over live graphics
|
||||
// the picture bleeds through the ASCII; we force opaque 240 instead.
|
||||
const COL_TRANSPARENT = 255
|
||||
const COL_PURE_BLACK = 240
|
||||
const GUI_BG = 0
|
||||
|
||||
// Text fore/back-plane addressing (mirrors aa.mjs _TA_FORE / _TA_BACK / _TA_BASE),
|
||||
// VT-aware.
|
||||
const TXT_FORE_OFF = 2
|
||||
const TXT_BACK_OFF = 2562
|
||||
const TXT_AREA_BASE = 253950
|
||||
const AA_W = 80, AA_H = 32
|
||||
const asciiBackFill = new Uint8Array(AA_W * AA_H).fill(COL_PURE_BLACK)
|
||||
|
||||
// Resolve the address of text-area byte `off` for the current environment
|
||||
// (VT pane: forward from VT_TEXT_PLANE; physical: backward from the GPU base),
|
||||
// exactly as aa.mjs's _va() does, so writes land in the same plane aa.flush uses.
|
||||
function txtAddr(off) {
|
||||
if (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||
return globalThis.VT_TEXT_PLANE + off
|
||||
return graphics.getGpuMemBase() - TXT_AREA_BASE - off
|
||||
}
|
||||
|
||||
// Overwrite every text cell's background with opaque pure-black (240), so ASCII
|
||||
// glyphs sit on solid black instead of aa.mjs's transparent (255) cells.
|
||||
function paintAsciiBgOpaque() {
|
||||
sys.pokeBytes(txtAddr(TXT_BACK_OFF), asciiBackFill, asciiBackFill.length)
|
||||
}
|
||||
|
||||
// ── Colour postprocessor (-colour) ───────────────────────────────────────────
|
||||
// AAlib chooses each glyph from brightness; colour mode additionally tints the
|
||||
// glyph's FOREGROUND (never the background) with the nearest opaque colour of
|
||||
// the TSVM 256-palette, sampled from the video's RGB plane.
|
||||
//
|
||||
// That palette is a *separable* 6×8×5 RGB cube (indices 0–239, white corner at
|
||||
// 239) plus a 15-step grey ramp (indices 240–254 = 0,17,…,238; index 255 is
|
||||
// always transparent and cube index 0 is translucent, so both are excluded as
|
||||
// ink). Because the cube is separable, its nearest entry is just the independent
|
||||
// nearest level per channel; the global nearest opaque colour is then whichever
|
||||
// of {best cube, best grey} is closer — all via small precomputed LUTs, O(1)/cell.
|
||||
const CUBE_R = [0, 51, 102, 153, 204, 255]
|
||||
const CUBE_G = [0, 34, 68, 102, 153, 187, 221, 255]
|
||||
const CUBE_B = [0, 68, 136, 187, 255]
|
||||
|
||||
let _rNear = null, _gNear = null, _bNear = null // 0–255 value → cube level index
|
||||
let _greyIdx = null, _greyVal = null // 0–255 mean → grey palette idx / value
|
||||
const colourBuf = new Uint8Array(AA_W * AA_H * 3) // sampled R,G,B per cell
|
||||
const foreBuf = new Uint8Array(AA_W * AA_H) // resolved palette ink per cell
|
||||
|
||||
function _nearestLevel(levels) {
|
||||
const lut = new Uint8Array(256)
|
||||
for (let v = 0; v < 256; v++) {
|
||||
let best = 0, bestD = 1e9
|
||||
for (let k = 0; k < levels.length; k++) {
|
||||
const d = Math.abs(v - levels[k])
|
||||
if (d < bestD) { bestD = d; best = k }
|
||||
}
|
||||
lut[v] = best
|
||||
}
|
||||
return lut
|
||||
}
|
||||
|
||||
function ensureColourLuts() {
|
||||
if (_rNear) return
|
||||
_rNear = _nearestLevel(CUBE_R)
|
||||
_gNear = _nearestLevel(CUBE_G)
|
||||
_bNear = _nearestLevel(CUBE_B)
|
||||
// Grey-ramp candidates: palette idx 240+k holds grey value 17·k, k = 0..14
|
||||
// (idx 240 = black … 254 = 238; idx 255 is transparent, so it is excluded).
|
||||
const gv = [], gi = []
|
||||
for (let k = 0; k < 15; k++) { gv.push(17 * k); gi.push(240 + k) }
|
||||
_greyIdx = new Uint8Array(256)
|
||||
_greyVal = new Uint8Array(256)
|
||||
for (let m = 0; m < 256; m++) {
|
||||
let best = 0, bestD = 1e9
|
||||
for (let k = 0; k < gv.length; k++) {
|
||||
const d = Math.abs(m - gv[k])
|
||||
if (d < bestD) { bestD = d; best = k }
|
||||
}
|
||||
_greyIdx[m] = gi[best]; _greyVal[m] = gv[best]
|
||||
}
|
||||
}
|
||||
|
||||
function nearestPaletteIndex(r, g, b) {
|
||||
const ri = _rNear[r], gi = _gNear[g], bi = _bNear[b]
|
||||
const cr = CUBE_R[ri], cg = CUBE_G[gi], cb = CUBE_B[bi]
|
||||
const dCube = (r - cr) * (r - cr) + (g - cg) * (g - cg) + (b - cb) * (b - cb)
|
||||
// Nearest grey level sits at the rounded mean of the channels (the vertex of
|
||||
// the achromatic-distance parabola); rounding — not flooring — makes the
|
||||
// {cube vs grey} pick the exact global nearest opaque palette entry.
|
||||
const m = ((r + g + b) / 3 + 0.5) | 0
|
||||
const gvv = _greyVal[m]
|
||||
const dGrey = (r - gvv) * (r - gvv) + (g - gvv) * (g - gvv) + (b - gvv) * (b - gvv)
|
||||
// Prefer grey on ties (so near-black resolves to opaque grey idx 240, not the
|
||||
// translucent cube corner); `|| 240` is a belt-and-braces guard for idx 0.
|
||||
const cubeIdx = ri * 40 + gi * 5 + bi
|
||||
return (dGrey <= dCube) ? _greyIdx[m] : (cubeIdx || 240)
|
||||
}
|
||||
|
||||
// Sample the frame's colour per cell, map to nearest palette ink, and write the
|
||||
// foreground plane (over what aa.flush wrote). Background is left to
|
||||
// paintAsciiBgOpaque(); only the FG is colourised, per spec.
|
||||
function applyColourFore(dec) {
|
||||
dec.sampleColour(colourBuf, AA_W, AA_H)
|
||||
for (let i = 0, n = AA_W * AA_H; i < n; i++)
|
||||
foreBuf[i] = nearestPaletteIndex(colourBuf[i * 3], colourBuf[i * 3 + 1], colourBuf[i * 3 + 2])
|
||||
sys.pokeBytes(txtAddr(TXT_FORE_OFF), foreBuf, foreBuf.length)
|
||||
}
|
||||
|
||||
// ── Parse args ───────────────────────────────────────────────────────────────
|
||||
let interactive = false
|
||||
let asciiMode = false
|
||||
let colourMode = false
|
||||
const decOpts = { interactive: false, deinterlaceAlgorithm: "yadif" }
|
||||
|
||||
for (let i = 2; i < exec_args.length; i++) {
|
||||
const arg = ("" + exec_args[i]).toLowerCase()
|
||||
if (arg === "-i") { interactive = true; decOpts.interactive = true }
|
||||
else if (arg === "-ascii") asciiMode = true
|
||||
else if (arg === "-colour" || arg === "-color") { asciiMode = true; colourMode = true }
|
||||
else if (arg === "-debug-mv") decOpts.debugMotionVectors = true
|
||||
else if (arg === "-deblock") decOpts.enableDeblocking = true
|
||||
else if (arg === "-boundaryaware") decOpts.enableBoundaryAwareDecoding = true
|
||||
else if (arg.startsWith("-deinterlace=")) decOpts.deinterlaceAlgorithm = arg.substring(13)
|
||||
else if (arg.startsWith("--filter-film-grain")) {
|
||||
const parts = arg.split(/[=\s]/)
|
||||
if (parts.length > 1) { const lv = parseInt(parts[1]); if (!isNaN(lv)) decOpts.filmGrainLevel = lv }
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful degradation: ASCII (and therefore colour) mode needs aa.mjs.
|
||||
if (asciiMode && !aa) {
|
||||
serial.println("playmov: aa.mjs not found; ASCII mode unavailable, -ascii/-colour ignored")
|
||||
asciiMode = false
|
||||
colourMode = false
|
||||
}
|
||||
|
||||
if (!exec_args[1]) { printerrln("usage: playmov FILE [-i] [-ascii] [-colour] [options]"); return 1 }
|
||||
const fullPath = _G.shell.resolvePathInput(exec_args[1]).full
|
||||
|
||||
// ── ASCII-render state (aa.mjs) — lazily initialised on first use ────────────
|
||||
let aaCtx = null
|
||||
let aaParams = null
|
||||
function ensureAscii() {
|
||||
if (aaCtx) return
|
||||
const font = aa.loadChrFontROM(AA_FONT_PATH)
|
||||
aaCtx = aa.init(AA_W, AA_H, { font: font })
|
||||
aaParams = aa.getrenderparams()
|
||||
aaParams.dither = aa.AA_FLOYD_S
|
||||
ensureColourLuts() // cheap; keeps the C-key colour toggle ready
|
||||
}
|
||||
|
||||
// ── Open ─────────────────────────────────────────────────────────────────────
|
||||
let [cy, cx] = con.getyx()
|
||||
let errorlevel = 0
|
||||
let dec = null
|
||||
let stage = "open" // breadcrumb for the error log
|
||||
|
||||
try {
|
||||
dec = mediadec.open(fullPath, decOpts)
|
||||
const info = dec.info
|
||||
|
||||
// NB: palette 0 is translucent black by default — exactly what the playgui
|
||||
// chrome (bg colour 0) wants — so we never redefine it. (Backends must not
|
||||
// either, or the chrome turns opaque for the next file played.)
|
||||
|
||||
if (info.isStill) { con.move(1, 1); println("Push and hold Backspace to exit") }
|
||||
|
||||
let startNs = 0
|
||||
let lastKey = 0
|
||||
let quit = false
|
||||
|
||||
// Build the playgui status object for the on-screen chrome.
|
||||
function status() {
|
||||
const usingCues = dec.cues && dec.cues.length > 0
|
||||
const akku = startNs ? (sys.nanoTime() - startNs) / 1000000000.0 : 0.0001
|
||||
return {
|
||||
fps: info.fps,
|
||||
videoRate: dec.videoRate | 0,
|
||||
frameCount: dec.frameCount,
|
||||
totalFrames: info.totalFrames,
|
||||
frameMode: dec.frameMode,
|
||||
qY: dec.qY || 0, qCo: dec.qCo || 0, qCg: dec.qCg || 0,
|
||||
akku: akku,
|
||||
fileName: usingCues ? dec.cues[dec.currentCueIndex].name : fullPath,
|
||||
fileOrd: usingCues ? (dec.currentCueIndex + 1) : (dec.currentFileIndex || 1),
|
||||
resolution: `${info.width}x${info.height}${info.isInterlaced ? 'i' : ''}`,
|
||||
colourSpace: info.colourSpace,
|
||||
currentStatus: dec.isPaused() ? 2 : 1
|
||||
}
|
||||
}
|
||||
|
||||
// Entering ASCII: clear the text plane; the pixel framebuffer is left as-is and
|
||||
// simply covered each frame by solid-black (240) text cells (see draw()).
|
||||
// Bias lighting is pinned to pure black ONCE here and not updated again while
|
||||
// in ASCII (draw() skips the bias stage), so the backdrop stays steady.
|
||||
function enterAsciiVisual() {
|
||||
ensureAscii()
|
||||
graphics.setBackground(0, 0, 0)
|
||||
graphics.clearPixelsAll(0, 0, 0, 0)
|
||||
con.clear()
|
||||
}
|
||||
|
||||
// Leaving ASCII: fill the viewing area with transparency (255), NOT the GUI's
|
||||
// translucent-black (colour 0), so the resumed video shows through cleanly.
|
||||
function exitAsciiVisual() {
|
||||
con.color_pair(COL_TRANSPARENT, COL_TRANSPARENT)
|
||||
con.clear()
|
||||
}
|
||||
|
||||
function toggleAscii() {
|
||||
asciiMode = !asciiMode
|
||||
if (asciiMode) enterAsciiVisual()
|
||||
else exitAsciiVisual()
|
||||
}
|
||||
|
||||
// Colour only affects the foreground plane and is re-applied every drawn
|
||||
// frame, so toggling it just flips the flag; the next flush+draw reverts the
|
||||
// ink to aa.mjs's grey when off. Ensure the LUTs exist if A was never pressed.
|
||||
function toggleColour() {
|
||||
if (!aaCtx) ensureColourLuts()
|
||||
colourMode = !colourMode
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────
|
||||
// Bksp is hold-to-quit (like the old players); everything else is edge-
|
||||
// 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)
|
||||
if (key == K.BACKSPACE) { quit = true; return }
|
||||
if (key && key !== lastKey) {
|
||||
if (key == K.A) { if (aa) toggleAscii() } // inert when aa.mjs is absent
|
||||
else if (key == K.C) { if (aa) toggleColour() } // colour shows only while in ASCII
|
||||
else if (interactive) {
|
||||
switch (key) {
|
||||
case K.SPACE: dec.pause(!dec.isPaused()); break
|
||||
case K.LEFT: dec.seekSeconds(-5.5); break
|
||||
case K.RIGHT: dec.seekSeconds(5.0); break
|
||||
case K.UP: dec.setVolume(dec.getVolume() + VOL_STEP); break
|
||||
case K.DOWN: dec.setVolume(dec.getVolume() - VOL_STEP); break
|
||||
case K.PAGE_UP: dec.cue(-1); break
|
||||
case K.PAGE_DOWN: dec.cue(1); break
|
||||
}
|
||||
}
|
||||
}
|
||||
lastKey = key
|
||||
}
|
||||
|
||||
// ── Draw a decoded frame: RAM frame -> screen / ASCII -> overlays -> chrome ─
|
||||
function draw() {
|
||||
if (asciiMode) {
|
||||
// The decoded frame already sits in RAM (TEV/TAV) or on the display
|
||||
// planes (iPF), so sample it WITHOUT uploading to the video adapter,
|
||||
// then cover the picture with solid-black (240) text cells (cheaper
|
||||
// than clearing the pixel planes).
|
||||
dec.sampleGray(aaCtx.imagebuffer, aaCtx.imgW, aaCtx.imgH)
|
||||
aa.render(aaCtx, aaParams)
|
||||
aa.flush(aaCtx)
|
||||
if (colourMode) applyColourFore(dec) // recolour the FG plane from the video's RGB
|
||||
paintAsciiBgOpaque() // cover with opaque 240 (not transparent 255)
|
||||
} else {
|
||||
dec.blit() // upload the RAM frame to the video adapter
|
||||
dec.bias() // bias lighting (player-owned stage; graphics only)
|
||||
}
|
||||
|
||||
// Postprocessor output: subtitle overlay (text plane, on top of the frame).
|
||||
if (asciiMode) {
|
||||
// aa.flush rewrote the whole text plane, so redraw the subtitle each frame.
|
||||
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
|
||||
dec.subtitle.dirty = false
|
||||
} else if (dec.subtitle.dirty) {
|
||||
gui.clearSubtitleArea()
|
||||
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
|
||||
dec.subtitle.dirty = false
|
||||
}
|
||||
|
||||
if (interactive) { gui.printBottomBar(status()); gui.printTopBar(status(), 1) }
|
||||
}
|
||||
|
||||
// Start in ASCII if requested (-ascii). Done here, after the helpers above are
|
||||
// defined, since they are block-scoped function declarations.
|
||||
if (asciiMode) enterAsciiVisual()
|
||||
|
||||
// ── Main loop ───────────────────────────────────────────────────────────
|
||||
while (!quit) {
|
||||
stage = "input"; readInput()
|
||||
if (quit) break
|
||||
|
||||
stage = "step"
|
||||
const ev = dec.step()
|
||||
if (ev.type === 'eof') break
|
||||
if (ev.type === 'error') { errorlevel = 1; break }
|
||||
if (ev.type === 'frame') {
|
||||
if (!startNs) startNs = sys.nanoTime()
|
||||
stage = "draw"; draw()
|
||||
} else {
|
||||
// 'idle' or 'newfile' — nothing to draw this turn.
|
||||
sys.sleep(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Log to serial too (persists in the console log next to errorlevel) and
|
||||
// keep it on screen — con.clear() in finally only runs on success.
|
||||
serial.printerr("playmov failed at stage [" + stage + "]: " + e)
|
||||
if (e && e.message) serial.println(" message: " + e.message)
|
||||
if (e && e.stack) serial.println(" stack: " + e.stack)
|
||||
if (e && e.printStackTrace) e.printStackTrace()
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
if (dec) dec.close()
|
||||
if (aa && aaCtx) aa.close(aaCtx)
|
||||
if (errorlevel === 0) con.clear()
|
||||
con.curs_set(1)
|
||||
con.move(cy, cx)
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
38
assets/disk0/tvdos/bin/playmov.js.synopsis
Normal file
38
assets/disk0/tvdos/bin/playmov.js.synopsis
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playmov",
|
||||
"summary": "Play a movie file (MOV/iPF, TEV, TAV or TAP)",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (controls + on-screen info)" },
|
||||
"ascii": { "kind": "option", "long": "-ascii", "summary": "Start in ASCII-render mode" },
|
||||
"colour": { "kind": "option", "long": "-colour", "summary": "Colourise ASCII glyphs from the video (implies -ascii); -color alias" },
|
||||
"deblock": { "kind": "option", "long": "-deblock", "summary": "TEV: enable deblocking filter" },
|
||||
"boundaryAware": { "kind": "option", "long": "-boundaryaware", "summary": "TEV: boundary-aware decoding" },
|
||||
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "TEV: show motion-vector debug overlay" },
|
||||
"deinterlace": {
|
||||
"kind": "option",
|
||||
"long": "-deinterlace",
|
||||
"summary": "TEV: deinterlacing algorithm",
|
||||
"value": { "name": "ALGO", "type": "enum", "values": ["yadif", "bwdif"], "required": true, "summary": "Deinterlacer" }
|
||||
},
|
||||
"filmGrain": {
|
||||
"kind": "option",
|
||||
"long": "--filter-film-grain",
|
||||
"summary": "TAV: apply a film-grain filter",
|
||||
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||
},
|
||||
"options": {
|
||||
"kind": "group",
|
||||
"summary": "Options",
|
||||
"members": ["interactive", "ascii", "colour", "deblock", "boundaryAware", "debugMv", "deinterlace", "filmGrain"]
|
||||
},
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -57,13 +57,17 @@ let decodedLength = 0
|
||||
|
||||
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||
// when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.setPcmMode(PLAYHEAD)
|
||||
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.play(PLAYHEAD)
|
||||
audio.mp2Init()
|
||||
|
||||
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||
@@ -91,8 +95,8 @@ try {
|
||||
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||
}
|
||||
|
||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
||||
if (audio.getPosition(PLAYHEAD) >= QUEUE_MAX) {
|
||||
while (audio.getPosition(PLAYHEAD) >= (QUEUE_MAX >>> 1)) {
|
||||
if (interactive) gui.audioRender()
|
||||
sys.sleep(bufRealTimeLen)
|
||||
}
|
||||
|
||||
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playmp2",
|
||||
"summary": "Play an MP2 audio file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "MP2 file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -97,10 +97,14 @@ let startTime = sys.nanoTime()
|
||||
let framesRead = 0
|
||||
let audioFired = false
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||
// when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.setPcmMode(PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
|
||||
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
||||
for (let k = 0; k < length; k+=2) {
|
||||
@@ -204,7 +208,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
||||
|
||||
// defer audio playback until a first frame is sent
|
||||
if (!audioFired) {
|
||||
audio.play(0)
|
||||
audio.play(PLAYHEAD)
|
||||
audioFired = true
|
||||
}
|
||||
|
||||
@@ -263,7 +267,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
||||
|
||||
// defer audio playback until a first frame is sent
|
||||
if (!audioFired) {
|
||||
audio.play(0)
|
||||
audio.play(PLAYHEAD)
|
||||
audioFired = true
|
||||
}
|
||||
|
||||
@@ -326,9 +330,9 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
||||
// RAW PCM packets (decode on the fly)
|
||||
else if (packetType == 0x1000 || packetType == 0x1001) {
|
||||
let frame = seqread.readBytes(readLength)
|
||||
audio.putPcmDataByPtr(0, frame, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
audio.putPcmDataByPtr(PLAYHEAD, frame, readLength, 0)
|
||||
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||
audio.startSampleUpload(PLAYHEAD)
|
||||
sys.free(frame)
|
||||
}
|
||||
else {
|
||||
@@ -382,14 +386,14 @@ finally {
|
||||
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
||||
|
||||
}
|
||||
//audio.stop(0)
|
||||
//audio.stop(PLAYHEAD)
|
||||
|
||||
let timeTook = (endTime - startTime) / 1000000000.0
|
||||
|
||||
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
||||
|
||||
audio.stop(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.stop(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
|
||||
if (interactive) {
|
||||
con.clear()
|
||||
|
||||
@@ -23,10 +23,14 @@ function bytesToSec(i) { return i / byterate }
|
||||
seqread.prepare(filePath)
|
||||
|
||||
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||
// when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.setPcmMode(PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
@@ -42,7 +46,7 @@ try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
const queueSize = audio.getPosition(0)
|
||||
const queueSize = audio.getPosition(PLAYHEAD)
|
||||
if (queueSize <= 1) {
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
@@ -54,13 +58,13 @@ try {
|
||||
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||
|
||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
audio.putPcmDataByPtr(PLAYHEAD, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||
audio.startSampleUpload(PLAYHEAD)
|
||||
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
}
|
||||
audio.play(0)
|
||||
audio.play(PLAYHEAD)
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
|
||||
@@ -109,13 +109,17 @@ function bytesToSec(i) {
|
||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||
}
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||
// when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.setPcmMode(PLAYHEAD)
|
||||
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.play(PLAYHEAD)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
@@ -163,7 +167,7 @@ try {
|
||||
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||
|
||||
audio.tadDecode()
|
||||
audio.tadUploadDecoded(0, sampleCount)
|
||||
audio.tadUploadDecoded(PLAYHEAD, sampleCount)
|
||||
// After upload tadDecodedBin still holds the chunk until the next
|
||||
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||
// during the playback wait below.
|
||||
|
||||
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playtad",
|
||||
"summary": "Play a TAD audio file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser and progress bar)" },
|
||||
"dump": { "kind": "option", "short": "-d", "summary": "Dump coefficients (diagnostic)" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "dump"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAD file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
},
|
||||
"constraints": [
|
||||
{ "type": "conflicts", "symbols": ["interactive", "dump"] }
|
||||
]
|
||||
}
|
||||
@@ -81,14 +81,14 @@ const PITCH_RANGE_HI = 0xA000 // ~C9
|
||||
// Colours — TSVM palette indices. Picked to read as amber/CRT chrome with
|
||||
// archetype-coded events. Background-transparent (255) lets the cell colour
|
||||
// fall through to the terminal default for ergonomic resize behaviour.
|
||||
const COL_BG = 0 // solid black panel background
|
||||
const COL_BG = 240 // solid black panel background
|
||||
const COL_BORDER = 250 // light grey panel chrome
|
||||
const COL_LABEL = 220 // amber panel label
|
||||
const COL_DIM = 235 // muted text
|
||||
const COL_TITLE = 230 // bright white-yellow song title
|
||||
const COL_VALUE = 254 // bright white numeric values
|
||||
const COL_TICK_LIVE = 46 // green tick light
|
||||
const COL_TICK_DEAD = 22 // dim green
|
||||
const COL_TICK_LIVE = 76 // green tick light
|
||||
const COL_TICK_DEAD = 20 // dim green
|
||||
const COL_ORDER_PAST = 235
|
||||
const COL_ORDER_CUR = 226 // bright yellow active cue
|
||||
const COL_ORDER_FUT = 250
|
||||
@@ -304,9 +304,13 @@ function parseTaud(path, songIndex) {
|
||||
const song = parseTaud(filePath, songArg)
|
||||
|
||||
// ── Hand the file to the audio adapter ─────────────────────────────────────
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
taud.uploadTaudFile(filePath, songArg, 0)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so launching
|
||||
// playtaud doesn't cut off music already playing on another playhead. Falls
|
||||
// back to #0 when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
taud.uploadTaudFile(filePath, songArg, PLAYHEAD)
|
||||
|
||||
// ── Instrument archetype classification ─────────────────────────────────────
|
||||
//
|
||||
@@ -565,8 +569,8 @@ function pad(n, w) {
|
||||
|
||||
let lastStatus = ''
|
||||
function drawStatus(curCue) {
|
||||
const bpm = audio.getBPM(0) || song.bpm
|
||||
const tick = audio.getTickRate(0) || song.tickRate
|
||||
const bpm = audio.getBPM(PLAYHEAD) || song.bpm
|
||||
const tick = audio.getTickRate(PLAYHEAD) || song.tickRate
|
||||
const cueStr = pad(curCue, 3) + '/' + pad(song.lastCue, 3)
|
||||
const s = 'BPM ' + pad(bpm,3) + ' Tick ' + pad(tick,2) +
|
||||
' Voices ' + pad(song.numVoices,2) + ' Cue ' + cueStr
|
||||
@@ -714,7 +718,7 @@ function spawnEventsForRow(cueIdx, rowIdx) {
|
||||
const arch = archByInst[effInst]
|
||||
let pan = 128
|
||||
if (panSel === 0) pan = (panVal / 63 * 255) | 0
|
||||
const livePan = audio.getVoiceEffectivePan(0, v)
|
||||
const livePan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||
if (typeof livePan === 'number' && livePan !== 128) pan = livePan
|
||||
// Replace whatever was in voice v's slot. peakVol seeds at 0 and is
|
||||
// tracked per-frame so the colour ramp normalises by attack peak,
|
||||
@@ -731,20 +735,188 @@ function spawnEventsForRow(cueIdx, rowIdx) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-lane rendering ──────────────────────────────────────────────────────
|
||||
// ── Dynamic matrix background ────────────────────────────────────────────────
|
||||
//
|
||||
// The renderer is structured as: each frame, blank the visualiser rows of all
|
||||
// five lanes, walk the active events, and draw each into its lane. Painting
|
||||
// is reasonably cheap because (a) the bordered side columns stay untouched,
|
||||
// and (b) blanking inside is two cells per row × ~14 rows = 30 cells.
|
||||
// Behind the event lanes runs a "terminal matrix" of the raw tracker data,
|
||||
// re-spelled as pseudo-opcodes and streamed one row's worth at a time in
|
||||
// lock-step with the playhead's row cadence. Each tracker cell on the current
|
||||
// row contributes up to four 7-char tokens (only for the sub-fields it carries):
|
||||
//
|
||||
// NT:nnnn note (4-hex noteVal)
|
||||
// VO:i.jj volume column (i = selector 0..3, jj = 2-digit value 00..63)
|
||||
// PN:k.ll pan column (k = selector 0..3, ll = 2-digit value 00..63)
|
||||
// Fs:eeee effect (s = base-36 opcode symbol, eeee = 4-hex argument)
|
||||
//
|
||||
// Tokens flow left-to-right and wrap at the canvas edge; when the print head
|
||||
// runs off the bottom the whole matrix scrolls up one row so the head stays on
|
||||
// the bottom line — the oldest line rolls off the top, like a terminal. A cue
|
||||
// change instead wraps the print head straight back to the top, so each cue
|
||||
// opens a fresh page over the ageing tail of the last. Column
|
||||
// wrapping only ever breaks between a token's three 2-char atoms AA / bb / cc —
|
||||
// never mid-atom — and a colon that would land at a line edge is dropped, so a
|
||||
// line never starts or ends with ':' (it may start with a single separator
|
||||
// space). Each freshly printed cell is brightest and decays one palette step
|
||||
// per row, trailing a comet tail behind the head.
|
||||
const BG_TOP = ROW_TONAL_TOP // matrix shares the whole visuals canvas
|
||||
const BG_BOT = ROW_DRUMS_BOT
|
||||
const BG_ROWS = BG_BOT - BG_TOP + 1
|
||||
const BG_L = COL_INSIDE_L
|
||||
const BG_COLS = LANE_W
|
||||
const BG_BLANK = ' '.repeat(BG_COLS)
|
||||
|
||||
function blankLanes() {
|
||||
colour(COL_DIM, COL_BG)
|
||||
const blank = ' '.repeat(LANE_W)
|
||||
for (let r = ROW_TONAL_TOP; r <= ROW_TONAL_BOT; r++)
|
||||
mvtext(r, COL_INSIDE_L, blank)
|
||||
for (let r = ROW_DRUMS_TOP; r <= ROW_DRUMS_BOT; r++)
|
||||
mvtext(r, COL_INSIDE_L, blank)
|
||||
// Palette runs dim → bright per the spec; fresh text takes the bright end.
|
||||
const BG_PALETTE = [244,243,242,241] // index 0 = freshest .. last = oldest
|
||||
const BG_LIFE = 32 // rows a cell stays lit before going dark
|
||||
|
||||
const bgChar = new Uint8Array(BG_ROWS * BG_COLS)
|
||||
const bgLvl = new Int8Array(BG_ROWS * BG_COLS) // 0 = dark, BG_LIFE = freshest
|
||||
const bgDith = new Uint8Array(BG_ROWS * BG_COLS) // per-cell ordered-dither threshold 0..15
|
||||
|
||||
// Ordered colour dithering. Each opcode atom (the AA / bb / cc of an "AA:bbcc"
|
||||
// token) is stamped with ONE 4×4 Bayer threshold taken from its start cell, so
|
||||
// the atom dithers as a coherent unit while neighbouring atoms differ — this
|
||||
// stipples the otherwise-flat palette bands of the ageing tail into a smooth
|
||||
// gradient. The threshold biases the floor() that picks between the two palette
|
||||
// entries bracketing a cell's fractional colour index.
|
||||
const BG_BAYER = [
|
||||
0, 8, 2, 10,
|
||||
12, 4, 14, 6,
|
||||
3, 11, 1, 9,
|
||||
15, 7, 13, 5
|
||||
]
|
||||
const BG_DITHER_N = 16
|
||||
function bgBayerAt(gr, gc) { return BG_BAYER[(gr & 3) * 4 + (gc & 3)] }
|
||||
|
||||
// BG_PALETTE[0] is reserved for the freshest row — the cells appended this very
|
||||
// row (lvl == BG_LIFE) — no matter how large BG_LIFE is. Its continuous index
|
||||
// is pinned to exactly 0, which no dither bias can lift, so it stays solid.
|
||||
// Ageing levels carry a *fractional* palette index in [1, BG_LAST]; the dither
|
||||
// resolves that fraction into a spatial mix of the two bracketing entries.
|
||||
const BG_LAST = BG_PALETTE.length - 1
|
||||
const bgContLut = new Float32Array(BG_LIFE + 1)
|
||||
bgContLut[BG_LIFE] = 0
|
||||
for (let lvl = 1; lvl < BG_LIFE; lvl++) {
|
||||
const span = BG_LIFE - 2 // ageing steps between the endpoints
|
||||
const age = (BG_LIFE - 1) - lvl // 0 = freshest aged .. span = oldest
|
||||
const t = span > 0 ? age / span : 0
|
||||
let f = 1 + t * (BG_LAST - 1) // continuous index in [1, BG_LAST]
|
||||
if (f > BG_LAST) f = BG_LAST
|
||||
if (f < 1) f = 1
|
||||
bgContLut[lvl] = f
|
||||
}
|
||||
|
||||
let bgHeadR = 0, bgHeadC = 0
|
||||
|
||||
// Scroll the whole matrix up one row: every row inherits the one below it, the
|
||||
// top line rolls off, and the freed bottom line is cleared. Levels and dither
|
||||
// travel with their cells, so the comet tail stays intact and the decay reads
|
||||
// as a continuous upward drift rather than a wrap-around jump.
|
||||
function bgScrollUp() {
|
||||
bgChar.copyWithin(0, BG_COLS)
|
||||
bgLvl.copyWithin(0, BG_COLS)
|
||||
bgDith.copyWithin(0, BG_COLS)
|
||||
const last = (BG_ROWS - 1) * BG_COLS
|
||||
bgChar.fill(0, last)
|
||||
bgLvl.fill(0, last)
|
||||
bgDith.fill(0, last)
|
||||
}
|
||||
|
||||
function bgNewline() {
|
||||
if (bgHeadR + 1 >= BG_ROWS) bgScrollUp() // at the bottom: scroll instead of wrapping to the top
|
||||
else bgHeadR++
|
||||
bgHeadC = 0
|
||||
}
|
||||
|
||||
function bgPut(code) { // single glue char; caller guarantees room
|
||||
const idx = bgHeadR * BG_COLS + bgHeadC
|
||||
bgChar[idx] = code; bgLvl[idx] = BG_LIFE; bgDith[idx] = bgBayerAt(bgHeadR, bgHeadC)
|
||||
bgHeadC++
|
||||
}
|
||||
|
||||
function bgPutAtom(c0, c1) { // 2-char atom; wraps as a unit, dithers as a unit
|
||||
if (bgHeadC + 2 > BG_COLS) bgNewline()
|
||||
const base = bgHeadR * BG_COLS
|
||||
const d = bgBayerAt(bgHeadR, bgHeadC) // one threshold for the whole atom
|
||||
bgChar[base + bgHeadC] = c0; bgLvl[base + bgHeadC] = BG_LIFE; bgDith[base + bgHeadC] = d; bgHeadC++
|
||||
bgChar[base + bgHeadC] = c1; bgLvl[base + bgHeadC] = BG_LIFE; bgDith[base + bgHeadC] = d; bgHeadC++
|
||||
}
|
||||
|
||||
// Lay out one "AA:bbcc" token (prefix2 = 2 chars, val4 = 4 chars) with the
|
||||
// break rules above.
|
||||
function bgEmitToken(prefix2, val4) {
|
||||
if (bgHeadC > 0) { // separator space between tokens
|
||||
if (bgHeadC + 3 > BG_COLS) bgNewline() // ...carried to the next line if needed
|
||||
bgPut(0x20)
|
||||
}
|
||||
bgPutAtom(prefix2.charCodeAt(0), prefix2.charCodeAt(1)) // AA
|
||||
if (bgHeadC + 3 <= BG_COLS) { // colon + bb both fit on this line
|
||||
bgPut(0x3A) // ':'
|
||||
bgPutAtom(val4.charCodeAt(0), val4.charCodeAt(1)) // bb
|
||||
} else { // drop the colon, bb opens the next line
|
||||
bgNewline()
|
||||
bgPutAtom(val4.charCodeAt(0), val4.charCodeAt(1)) // bb
|
||||
}
|
||||
bgPutAtom(val4.charCodeAt(2), val4.charCodeAt(3)) // cc (may wrap)
|
||||
}
|
||||
|
||||
// Advance the matrix by one tracker row: decay every lit cell one step, then
|
||||
// stream the pseudo-opcodes for whatever the row's cells carry. Within a cue
|
||||
// the head marches down and the matrix scrolls under it (see bgNewline); a cue
|
||||
// change wraps the head back to the top to open a fresh page.
|
||||
function bgAdvanceRow(cueIdx, rowIdx, cueChanged) {
|
||||
for (let i = 0; i < bgLvl.length; i++) {
|
||||
if (bgLvl[i] > 0) bgLvl[i]--
|
||||
}
|
||||
if (cueChanged) { bgHeadR = 0; bgHeadC = 0 }
|
||||
const cue = song.cues[cueIdx]
|
||||
if (!cue) return
|
||||
const off = rowIdx * 8
|
||||
for (let v = 0; v < song.numVoices; v++) {
|
||||
const patIdx = cue.ptns[v]
|
||||
if (patIdx === CUE_EMPTY || patIdx >= song.numPats) continue
|
||||
const pat = song.patterns[patIdx]
|
||||
if (!pat) continue
|
||||
const note = pat[off] | (pat[off + 1] << 8)
|
||||
const voleff = pat[off + 3]
|
||||
const paneff = pat[off + 4]
|
||||
const effop = pat[off + 5]
|
||||
const effarg = pat[off + 6] | (pat[off + 7] << 8)
|
||||
if (note !== 0)
|
||||
bgEmitToken('NT', note.toString(16).toUpperCase().padStart(4, '0'))
|
||||
if (voleff !== 0 && voleff !== 0xC0)
|
||||
bgEmitToken('VO', (voleff >>> 6) + '.' + (voleff & 63).toString(10).padStart(2, '0'))
|
||||
if (paneff !== 0 && paneff !== 0xC0)
|
||||
bgEmitToken('PN', (paneff >>> 6) + '.' + (paneff & 63).toString(10).padStart(2, '0'))
|
||||
if (effop !== 0)
|
||||
bgEmitToken('F' + effop.toString(36).toUpperCase()[0],
|
||||
effarg.toString(16).toUpperCase().padStart(4, '0'))
|
||||
}
|
||||
}
|
||||
|
||||
// Paint the matrix as the canvas backdrop; the event lanes draw over it. Each
|
||||
// strip is blanked in one shot, then its lit cells are overlaid (spaces and dark
|
||||
// cells skipped), batching colour switches so same-age runs share one call.
|
||||
function drawBackground() {
|
||||
let curFg = -1
|
||||
for (let gr = 0; gr < BG_ROWS; gr++) {
|
||||
const sr = BG_TOP + gr
|
||||
colour(COL_DIM, COL_BG); curFg = COL_DIM
|
||||
con.move(sr, BG_L)
|
||||
print(BG_BLANK)
|
||||
const base = gr * BG_COLS
|
||||
for (let gc = 0; gc < BG_COLS; gc++) {
|
||||
const lvl = bgLvl[base + gc]
|
||||
if (lvl <= 0) continue
|
||||
const ch = bgChar[base + gc]
|
||||
if (ch === 0x20) continue
|
||||
let idx = Math.floor(bgContLut[lvl] + (bgDith[base + gc] + 0.5) / BG_DITHER_N)
|
||||
if (idx > BG_LAST) idx = BG_LAST
|
||||
if (idx < 0) idx = 0
|
||||
const fg = BG_PALETTE[idx]
|
||||
if (fg !== curFg) { colour(fg, COL_BG); curFg = fg }
|
||||
mvprn(sr, BG_L + gc, ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function envColour(arch, volFrac) {
|
||||
@@ -849,9 +1021,12 @@ function drawEventLead(ev, stage, volFrac, livePan, liveNote) {
|
||||
: stage === STAGE_RELEASE ? 0xF9 /*·*/
|
||||
: 0xC4 /*─*/
|
||||
for (let i = 1; i <= tailLen; i++) {
|
||||
const x = cx - i
|
||||
if (x >= COL_INSIDE_L && x <= COL_INSIDE_R)
|
||||
mvprn(y, x, trailChar)
|
||||
const xl = cx - i
|
||||
if (xl >= COL_INSIDE_L && xl <= COL_INSIDE_R)
|
||||
mvprn(y, xl, trailChar)
|
||||
const xr = cx + i
|
||||
if (xr >= COL_INSIDE_L && xr <= COL_INSIDE_R)
|
||||
mvprn(y, xr, trailChar)
|
||||
}
|
||||
const head = stage === STAGE_ATTACK ? 0xFE /*■*/
|
||||
: stage === STAGE_RELEASE ? 0x09 /*°*/
|
||||
@@ -880,18 +1055,18 @@ function drawEventMetal(ev, stage, volFrac, liveNote) {
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
blankLanes()
|
||||
drawBackground()
|
||||
for (let v = 0; v < song.numVoices; v++) {
|
||||
const ev = events[v]
|
||||
if (!ev) continue
|
||||
// The engine's `active` flag is the source of truth — set by note-on,
|
||||
// cleared by note-cut, sample-end, envelope-end-of-decay, or NNA cut.
|
||||
// Once it drops, the voice is genuinely silent so the visual goes too.
|
||||
if (!audio.getVoiceActive(0, v)) { events[v] = null; continue }
|
||||
if (!audio.getVoiceActive(PLAYHEAD, v)) { events[v] = null; continue }
|
||||
|
||||
const liveVol = audio.getVoiceEffectiveVolume(0, v) || 0
|
||||
const livePan = audio.getVoiceEffectivePan(0, v)
|
||||
const liveNote = audio.getVoiceNote(0, v)
|
||||
const liveVol = audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0
|
||||
const livePan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||
const liveNote = audio.getVoiceNote(PLAYHEAD, v)
|
||||
|
||||
if (liveVol > ev.peakVol) ev.peakVol = liveVol
|
||||
ev.ageFrames++
|
||||
@@ -923,10 +1098,10 @@ function drawStereo() {
|
||||
const W = LANE_W
|
||||
const bins = new Float32Array(W)
|
||||
for (let v = 0; v < song.numVoices; v++) {
|
||||
if (!audio.getVoiceActive(0, v)) continue
|
||||
const vol = Math.pow(audio.getVoiceEffectiveVolume(0, v) || 0, 0.125)
|
||||
if (!audio.getVoiceActive(PLAYHEAD, v)) continue
|
||||
const vol = Math.pow(audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0, 0.125)
|
||||
if (vol <= 0) continue
|
||||
const pan = audio.getVoiceEffectivePan(0, v)
|
||||
const pan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||
let col = Math.round((pan / 255) * (W - 1))
|
||||
if (col < 0) col = 0
|
||||
if (col >= W) col = W - 1
|
||||
@@ -972,7 +1147,7 @@ function drawTickLights(tickInRow, tickRate) {
|
||||
// Voice activity counter on the right.
|
||||
let nActive = 0
|
||||
for (let v = 0; v < song.numVoices; v++) {
|
||||
if (audio.getVoiceActive(0, v)) nActive++
|
||||
if (audio.getVoiceActive(PLAYHEAD, v)) nActive++
|
||||
}
|
||||
colour(COL_DIM, COL_BG)
|
||||
const s = 'ACTIVE ' + pad(nActive, 2) + '/' + pad(song.numVoices, 2)
|
||||
@@ -980,16 +1155,17 @@ function drawTickLights(tickInRow, tickRate) {
|
||||
}
|
||||
|
||||
// ── Initial paint ───────────────────────────────────────────────────────────
|
||||
graphics.setBackground(0,0,0)
|
||||
drawFrame()
|
||||
drawTitle()
|
||||
drawStatus(0)
|
||||
drawOrderStrip(0)
|
||||
|
||||
// ── Playback ────────────────────────────────────────────────────────────────
|
||||
audio.setCuePosition(0, 0)
|
||||
audio.setTrackerRow(0, 0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
audio.setCuePosition(PLAYHEAD, 0)
|
||||
audio.setTrackerRow(PLAYHEAD, 0)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.play(PLAYHEAD)
|
||||
|
||||
let stopReq = false
|
||||
let errorlevel = 0
|
||||
@@ -1003,22 +1179,25 @@ let errorlevel = 0
|
||||
let ticksPerRow = Math.max(1, song.tickRate)
|
||||
let synthTick = 0 // tick within current row, 0..ticksPerRow-1
|
||||
try {
|
||||
while (audio.isPlaying(0) && !stopReq) {
|
||||
while (audio.isPlaying(PLAYHEAD) && !stopReq) {
|
||||
// Backspace polling (mirrors playtad).
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) === 67) stopReq = true
|
||||
|
||||
const curCue = audio.getCuePosition(0)
|
||||
const curRow = audio.getTrackerRow(0)
|
||||
const curCue = audio.getCuePosition(PLAYHEAD)
|
||||
const curRow = audio.getTrackerRow(PLAYHEAD)
|
||||
if (curCue !== lastSeenCue || curRow !== lastSeenRow) {
|
||||
// Row boundary — spawn new events, reset synthetic tick counter.
|
||||
// Row boundary — spawn new events, advance the matrix background
|
||||
// (scrolls within a cue, wraps to the top on a cue change), reset
|
||||
// tick count.
|
||||
spawnEventsForRow(curCue, curRow)
|
||||
bgAdvanceRow(curCue, curRow, curCue !== lastSeenCue)
|
||||
lastSeenCue = curCue
|
||||
lastSeenRow = curRow
|
||||
synthTick = 0
|
||||
// Pull a fresh tickRate read here in case a T effect changed it
|
||||
// mid-song.
|
||||
ticksPerRow = Math.max(1, audio.getTickRate(0) || song.tickRate)
|
||||
ticksPerRow = Math.max(1, audio.getTickRate(PLAYHEAD) || song.tickRate)
|
||||
} else {
|
||||
// Same row — advance the synthetic tick counter against wall time.
|
||||
// Tick period (ms) = (60000 / BPM) / 24 ... but the spec is
|
||||
@@ -1038,7 +1217,7 @@ try {
|
||||
drawStereo()
|
||||
drawTickLights(synthTick, ticksPerRow)
|
||||
|
||||
sys.sleep((2500 / audio.getBPM(0))|0) // one visual frame = one tick
|
||||
sys.sleep((2500 / audio.getBPM(PLAYHEAD))|0) // one visual frame = one tick
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
@@ -1046,7 +1225,7 @@ catch (e) {
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
audio.stop(0)
|
||||
audio.stop(PLAYHEAD)
|
||||
con.move(ROW_BOT_BORDER + 1, 1)
|
||||
con.curs_set(1)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const ADDRESSING_INTERNAL = 0x02
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
const pcm = require("pcm")
|
||||
const AUDIO_DEVICE = 0
|
||||
const AUDIO_DEVICE = audio.getFreePlayhead(0)
|
||||
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||
const TAV_TEMPORAL_LEVELS = 2
|
||||
|
||||
|
||||
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playtav",
|
||||
"summary": "Play a TAV video file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||
"filmGrain": {
|
||||
"kind": "option",
|
||||
"long": "--filter-film-grain",
|
||||
"summary": "Apply a film-grain filter",
|
||||
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||
},
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "filmGrain"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAV file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -100,10 +100,14 @@ graphics.clearPixels(0)
|
||||
graphics.clearPixels2(0)
|
||||
|
||||
// Initialize audio
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||
// when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.setPcmMode(PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
|
||||
// set colour zero as half-opaque black
|
||||
graphics.setPalette(0, 0, 0, 0, 9)
|
||||
@@ -791,14 +795,14 @@ try {
|
||||
if (isInterlaced) {
|
||||
// fire audio after frame 1
|
||||
if (!audioFired && frameCount > 0) {
|
||||
audio.play(0)
|
||||
audio.play(PLAYHEAD)
|
||||
audioFired = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
// fire audio after frame 0
|
||||
if (!audioFired) {
|
||||
audio.play(0)
|
||||
audio.play(PLAYHEAD)
|
||||
audioFired = true
|
||||
}
|
||||
}
|
||||
@@ -900,8 +904,8 @@ finally {
|
||||
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
||||
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
||||
|
||||
audio.stop(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.stop(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
|
||||
if (interactive) {
|
||||
//con.clear()
|
||||
|
||||
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playtev",
|
||||
"summary": "Play a TEV video file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "Show motion-vector debug overlay" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "debugMv"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TEV file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -131,16 +131,20 @@ try {
|
||||
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
// Occupy the first idle playhead rather than always grabbing #0, so
|
||||
// playback doesn't cut off audio already running on another playhead.
|
||||
// Falls back to #0 when all four are busy.
|
||||
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.setPcmMode(PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (audio.getPosition(0) <= 1) {
|
||||
if (audio.getPosition(PLAYHEAD) <= 1) {
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
@@ -153,13 +157,13 @@ try {
|
||||
// before queueing — the buffer is reused next iteration.
|
||||
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||
|
||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||
audio.startSampleUpload(0)
|
||||
audio.putPcmDataByPtr(PLAYHEAD, decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(PLAYHEAD, decodedSampleLength)
|
||||
audio.startSampleUpload(PLAYHEAD)
|
||||
|
||||
sys.spin()
|
||||
}
|
||||
audio.play(0)
|
||||
audio.play(PLAYHEAD)
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
|
||||
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "playwav",
|
||||
"summary": "Play a WAV audio file",
|
||||
"symbols": {
|
||||
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "WAV file to play" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "file" },
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "printfile",
|
||||
"summary": "Print a text file with line numbers",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Text file to print" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* synopsis.js -- system-wide help / tldr.
|
||||
*
|
||||
* Prints a command's human-targeted one-line summary and an auto-generated
|
||||
* synopsis (usage line, arguments, options and constraints) derived from its
|
||||
* TSF .synopsis document via the `synopsis` library (synopsis.mjs).
|
||||
*
|
||||
* Usage: synopsis PROGRAM
|
||||
* synopsis (describes itself)
|
||||
*/
|
||||
|
||||
let syn
|
||||
try {
|
||||
syn = require("synopsis")
|
||||
} catch (e) {
|
||||
printerrln("synopsis: the 'synopsis' library is not installed")
|
||||
return 1
|
||||
}
|
||||
|
||||
const termW = (con.getmaxyx()[1]) || 80
|
||||
|
||||
// Word-wrap plain text to `width`, returning an array of lines.
|
||||
function wrap(text, width) {
|
||||
if (!text) return []
|
||||
if (width < 8) width = 8
|
||||
let words = ('' + text).split(/\s+/).filter(function (w) { return w.length })
|
||||
let lines = [], line = ''
|
||||
words.forEach(function (w) {
|
||||
if (line.length === 0) line = w
|
||||
else if (line.length + 1 + w.length <= width) line += ' ' + w
|
||||
else { lines.push(line); line = w }
|
||||
})
|
||||
if (line.length) lines.push(line)
|
||||
return lines
|
||||
}
|
||||
|
||||
// Print a "left summary" row: the summary is wrapped into the right column and
|
||||
// continuation lines are aligned under it. An over-wide `left` spills onto its
|
||||
// own line.
|
||||
function row(left, summary, leftW, indent) {
|
||||
let pad = ' '.repeat(indent)
|
||||
let gap = 2
|
||||
let sumW = Math.max(8, termW - indent - leftW - gap)
|
||||
let wrapped = wrap(summary, sumW)
|
||||
if (left.length > leftW) {
|
||||
println(pad + left)
|
||||
wrapped.forEach(function (l) { println(pad + ' '.repeat(leftW + gap) + l) })
|
||||
} else {
|
||||
let first = wrapped.length ? wrapped[0] : ''
|
||||
println(pad + left + ' '.repeat(leftW - left.length + gap) + first)
|
||||
for (let i = 1; i < wrapped.length; i++)
|
||||
println(pad + ' '.repeat(leftW + gap) + wrapped[i])
|
||||
}
|
||||
}
|
||||
|
||||
// ---- resolve the target ----------------------------------------------------
|
||||
let token = (exec_args[1] !== undefined && exec_args[1] !== '') ? exec_args[1] : "synopsis"
|
||||
|
||||
let model = syn.getModel(token)
|
||||
if (!model) {
|
||||
printerrln(`synopsis: no synopsis found for '${token}'`)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Display name for a referenced symbol id (used by the constraints section).
|
||||
function symDisplay(id) {
|
||||
let s = model.symbols[id]
|
||||
if (!s) return id
|
||||
if (s.kind === 'option') return s.long || s.short || id
|
||||
if (s.kind === 'positional') return s.name || id
|
||||
if (s.kind === 'subcommand') return s.name || id
|
||||
return id
|
||||
}
|
||||
|
||||
// Append a "{a, b, c}" hint of permitted values to a summary, if any.
|
||||
function withValues(summary, values) {
|
||||
if (!values || !values.length) return summary || ''
|
||||
let vs = values.map(function (v) {
|
||||
return (v && typeof v === 'object' && ('value' in v)) ? v.value : v
|
||||
}).join(', ')
|
||||
return (summary ? summary + ' ' : '') + '{' + vs + '}'
|
||||
}
|
||||
|
||||
// Left-column text for an option, e.g. "-o, --output=FILE".
|
||||
function optionLeft(e) {
|
||||
let forms = []
|
||||
if (e.short) forms.push(e.short)
|
||||
if (e.long) forms.push(e.long)
|
||||
let s = forms.join(', ')
|
||||
if (e.hasValue) {
|
||||
let vn = (e.value && (e.value.name || e.value.type)) || 'VALUE'
|
||||
if (e.long) s += e.valueRequired ? '=' + vn : '[=' + vn + ']'
|
||||
else s += e.valueRequired ? ' ' + vn : ' [' + vn + ']'
|
||||
}
|
||||
return s
|
||||
}
|
||||
function optionSummary(e) {
|
||||
let s = e.summary || ''
|
||||
if (e.negatable) s += (s ? ' ' : '') + '(negatable)'
|
||||
if (e.value && e.value.values && e.value.values.length) s = withValues(s, e.value.values)
|
||||
return s
|
||||
}
|
||||
|
||||
function constraintText(c) {
|
||||
let names = (c.symbols || []).map(symDisplay)
|
||||
if (c.type === 'conflicts') return 'Mutually exclusive: ' + names.join(', ')
|
||||
if (c.type === 'requires') return symDisplay(c.subject) + ' requires ' + (c.targets || []).map(symDisplay).join(', ')
|
||||
if (c.type === 'implies') return symDisplay(c.subject) + ' implies ' + (c.targets || []).map(symDisplay).join(', ')
|
||||
if (c.type === 'cardinality') {
|
||||
let mn = c.minimum, mx = c.maximum, q
|
||||
if (mn === 1 && mx === 1) q = 'Exactly one of'
|
||||
else if (mn === 1 && mx === undefined) q = 'At least one of'
|
||||
else if (mn === undefined && mx === 1) q = 'At most one of'
|
||||
else q = `Between ${mn} and ${mx} of`
|
||||
return q + ': ' + names.join(', ')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- gather rows -----------------------------------------------------------
|
||||
let argEntries = model.positionals.map(function (p) {
|
||||
return { left: (p.name || p.id) + (p.repeatable ? '...' : ''), summary: withValues(p.summary, p.values) }
|
||||
})
|
||||
let optEntries = model.flags.map(function (e) {
|
||||
return { left: optionLeft(e), summary: optionSummary(e) }
|
||||
})
|
||||
let subEntries = model.subcommands.map(function (s) {
|
||||
return { left: s.name, summary: s.summary || '' }
|
||||
})
|
||||
|
||||
// shared left-column width (capped so a long flag does not push everything out)
|
||||
let leftW = 4
|
||||
argEntries.concat(optEntries, subEntries).forEach(function (e) { if (e.left.length > leftW) leftW = e.left.length })
|
||||
if (leftW > 30) leftW = 30
|
||||
|
||||
// ---- render ----------------------------------------------------------------
|
||||
let title = model.name || token
|
||||
println(model.summary ? `${title} - ${model.summary}` : title)
|
||||
println()
|
||||
|
||||
let usage = syn.getUsage(token)
|
||||
if (usage) {
|
||||
println("Usage:")
|
||||
println(" " + usage)
|
||||
println()
|
||||
}
|
||||
|
||||
if (model.description) {
|
||||
wrap(model.description, termW).forEach(function (l) { println(l) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (subEntries.length) {
|
||||
println("Commands:")
|
||||
subEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (argEntries.length) {
|
||||
println("Arguments:")
|
||||
argEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (optEntries.length) {
|
||||
println("Options:")
|
||||
optEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||
println()
|
||||
}
|
||||
|
||||
if (model.constraints && model.constraints.length) {
|
||||
let lines = model.constraints.map(constraintText).filter(function (t) { return t })
|
||||
if (lines.length) {
|
||||
println("Constraints:")
|
||||
lines.forEach(function (l) { println(" " + l) })
|
||||
println()
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "synopsis",
|
||||
"summary": "Print a command's summary and auto-generated synopsis",
|
||||
"description": "Prints the one-line summary and an auto-generated usage line for PROGRAM, derived from its TSF .synopsis document, together with its arguments, options and constraints. With no PROGRAM, describes itself.",
|
||||
"symbols": {
|
||||
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command to describe; describes synopsis itself when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "program" }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ let ts = require("typesetter")
|
||||
/*
|
||||
Tags:
|
||||
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
||||
<s> - print the text in deemphasis colour (248)
|
||||
<c> - centre the line. If the line spans multiple lines, centre each line
|
||||
<r> - align right
|
||||
<l> - align left
|
||||
@@ -145,7 +146,7 @@ Mixer flags define how should the mixer behave.
|
||||
|
||||
// assemble help text pieces to complete help message
|
||||
|
||||
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
|
||||
const HRULE = '<s>' + '\u00B3'.repeat(_G.TAUT.HELPMSG_WIDTH) + '</s>\n'
|
||||
|
||||
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
||||
// The shared typesetter module owns the palette and the markup expander.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "tee",
|
||||
"summary": "Copy a pipe's stream to a file and pass it on",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "touch",
|
||||
"summary": "Update a file's modification time, creating it if absent",
|
||||
"symbols": {
|
||||
"noCreate": { "kind": "option", "short": "-c", "summary": "Do not create the file if it does not exist" },
|
||||
"options": { "kind": "group", "summary": "Options", "members": ["noCreate"] },
|
||||
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to touch" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||
{ "type": "reference", "symbol": "file" }
|
||||
]
|
||||
}
|
||||
}
|
||||
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "writeto",
|
||||
"summary": "Write a pipe's stream to a file",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
@@ -54,12 +54,12 @@ const EXEC_FUNS = {
|
||||
"adpcm": (f) => _G.shell.execute(`playwav "${f}" -i`),
|
||||
// "mp3": (f) => _G.shell.execute(`playmp3 "${f}" -i`),
|
||||
"mp2": (f) => _G.shell.execute(`playmp2 "${f}" -i`),
|
||||
"mv1": (f) => _G.shell.execute(`playmv1 "${f}" -i`),
|
||||
"mv2": (f) => _G.shell.execute(`playtev "${f}" -i`),
|
||||
"mv3": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
||||
"tav": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
||||
"im3": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
||||
"tap": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
||||
"mv1": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||
"mv2": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||
"mv3": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||
"tav": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||
"im3": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||
"tap": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||
"tad": (f) => _G.shell.execute(`playtad "${f}" -i`),
|
||||
"pcm": (f) => _G.shell.execute(`playpcm "${f}" -i`),
|
||||
"ipf": (f) => _G.shell.execute(`decodeipf "${f}" -i`),
|
||||
|
||||
12
assets/disk0/tvdos/hopper/libmediadec.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libmediadec.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libmediadec
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libmediadec
|
||||
HopperRequires:libseqread 1.*
|
||||
ProperName:LibMediaDec
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Video decoding library for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/mediadec.mjs;/tvdos/include/mediadec_common.mjs;/tvdos/include/mediadec_ipf.mjs;/tvdos/include/mediadec_tav.mjs;/tvdos/include/mediadec_tev.mjs
|
||||
86
assets/disk0/tvdos/include/mediadec.mjs
Normal file
86
assets/disk0/tvdos/include/mediadec.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* mediadec.mjs — the all-in-one media-decoding library for TVDOS movie players.
|
||||
*
|
||||
* One simple public API, three internal backends (iPF/MOV, TEV, TAV/TAP),
|
||||
* sharing the front-end utilities in mediadec_common.mjs. Used by playmov.js.
|
||||
*
|
||||
* const mediadec = require("mediadec")
|
||||
* const dec = mediadec.open("A:\\film.tav", { interactive: true })
|
||||
* while (true) {
|
||||
* const ev = dec.step() // [backend] decode the next due frame to RAM
|
||||
* if (ev.type === 'eof') break
|
||||
* if (ev.type !== 'frame') { sys.sleep(1); continue }
|
||||
* dec.blit() // [draw] upload the RAM frame to the screen
|
||||
* // ...or in ASCII mode (no upload): dec.sampleGray(buf,w,h); aa.render/flush
|
||||
* // ...or grab the frame yourself: sys.peek(dec.frameBuffer + ...)
|
||||
* }
|
||||
* dec.close()
|
||||
*
|
||||
* step() decodes the next due frame into a generic RAM RGB888 buffer (exposed as
|
||||
* .frameBuffer); the caller decides what to do with it — upload it with .blit(),
|
||||
* sample it for ASCII, or read it directly. (iPF is the exception: it decodes
|
||||
* straight to the 4bpp display planes, so .frameBuffer is 0 and .sampleGray/.blit
|
||||
* operate on the planes — see mediadec_ipf.mjs.)
|
||||
*
|
||||
* The decoder object every backend returns exposes a uniform interface:
|
||||
* .info {format,width,height,fps,totalFrames,hasAudio,hasSubtitles,
|
||||
* isInterlaced,colourSpace,graphicsMode,isStill}
|
||||
* .step() -> { type:'frame'|'idle'|'eof'|'newfile'|'error', frameCount }
|
||||
* .frameBuffer RAM RGB888 address of the current frame (0 for iPF; see above)
|
||||
* .frameWidth/.frameHeight dimensions of the frame in .frameBuffer
|
||||
* .blit() upload the current RAM frame to the screen (adapter)
|
||||
* .sampleGray(dst,w,h) fill an ASCII brightness buffer from the RAM frame
|
||||
* .sampleColour(dst,w,h) fill a per-cell RGB buffer (w*h*3) from the RAM frame
|
||||
* .subtitle {visible,text,position,useUnicode,dirty} (resolved by the lib)
|
||||
* .pause(b)/.isPaused() .setVolume(v)/.getVolume()
|
||||
* .seekSeconds(n) .cue(d) .cues
|
||||
* .frameCount .currentTimecodeNs .videoRate .frameMode [.qY/.qCo/.qCg]
|
||||
* .close()
|
||||
*/
|
||||
|
||||
// NOTE: every require() below is deliberately made at call time (inside open()),
|
||||
// never at module top level. TVDOS's require() loads a module by eval()-ing it,
|
||||
// and requiring one module *while another module is still being eval()-ed* nests
|
||||
// the evals — which can collide on the loader's `let exports` binding and throw
|
||||
// "Identifier 'exports' has already been declared" at load, breaking every file.
|
||||
// Keeping requires at runtime means each is a single, non-nested eval.
|
||||
|
||||
// Open a movie file: sniff the magic, then hand off to the matching backend.
|
||||
// `opts` (all optional): interactive, debugMotionVectors, enableDeblocking,
|
||||
// enableBoundaryAwareDecoding, deinterlaceAlgorithm, filmGrainLevel.
|
||||
function open(fullPathStr, opts) {
|
||||
opts = opts || {}
|
||||
|
||||
const common = require("mediadec_common")
|
||||
|
||||
// IMPORTANT: query the file size via files.open() BEFORE preparing seqread.
|
||||
// On the real disk driver both share the drive's serial port, so a files.open()
|
||||
// *after* seqread.prepare() clobbers the read position and the first readBytes()
|
||||
// returns driver leftovers (the size as an ASCII string) instead of the file's
|
||||
// bytes — which made every file fail the magic check. Every original player
|
||||
// reads the size first, then prepares seqread.
|
||||
const fileLength = files.open(fullPathStr).size
|
||||
const sr = common.openSeqread(fullPathStr)
|
||||
const magic = common.readMagic(sr)
|
||||
const fmt = common.detectFormat(magic)
|
||||
|
||||
con.clear()
|
||||
con.curs_set(0)
|
||||
|
||||
switch (fmt) {
|
||||
case 'mov': return require("mediadec_ipf").create(magic, sr, fileLength, opts, common)
|
||||
case 'tev': return require("mediadec_tev").create(magic, sr, fileLength, opts, common)
|
||||
case 'tav': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, false)
|
||||
case 'tap': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, true)
|
||||
case 'ucf':
|
||||
throw Error("UCF cue files are not directly playable; play the TAV stream they index")
|
||||
default:
|
||||
throw Error("Unrecognised movie file (magic: " + magic.map(b => b.toString(16)).join(' ') + ")")
|
||||
}
|
||||
}
|
||||
|
||||
exports = {
|
||||
open: open,
|
||||
// Lazy require so this module never requires another at load time (see note above).
|
||||
detectFormat: function (magic) { return require("mediadec_common").detectFormat(magic) }
|
||||
}
|
||||
448
assets/disk0/tvdos/include/mediadec_common.mjs
Normal file
448
assets/disk0/tvdos/include/mediadec_common.mjs
Normal file
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* mediadec_common.mjs — shared front-end utilities for the mediadec library.
|
||||
*
|
||||
* Holds everything the three movie backends (iPF/MOV, TEV, TAV) duplicated in
|
||||
* the old standalone players: magic constants, packet-type / SSF-opcode tables,
|
||||
* the TAV quality LUT, seqread selection, the audio router, the subtitle
|
||||
* engine, bias lighting, and the `sampleGray` / `sampleColour` source samplers
|
||||
* used by the player's ASCII-render path — both a *Screen pair (read the GPU
|
||||
* display planes, for iPF) and a *RGB pair (read a RAM RGB888 frame, for the
|
||||
* decode-into-RAM backends TEV / TAV).
|
||||
*
|
||||
* Runs in the same GraalVM context as the player, so the host globals
|
||||
* (sys/graphics/audio/con/serial/files/gzip) are visible directly, exactly as
|
||||
* in seqread.mjs / playgui.mjs.
|
||||
*/
|
||||
|
||||
// ── Magic numbers ───────────────────────────────────────────────────────────
|
||||
const MAGIC_MOV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x4D, 0x4F, 0x56] // "\x1FTSVMMOV"
|
||||
const MAGIC_TEV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x45, 0x56] // "\x1FTSVMTEV"
|
||||
const MAGIC_TAV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x56] // "\x1FTSVMTAV"
|
||||
const MAGIC_TAP = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x50] // "\x1FTSVMTAP"
|
||||
const MAGIC_UCF = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x55, 0x43, 0x46] // "\x1FTSVMUCF"
|
||||
|
||||
// ── MP2 frame-size table (shared by iPF/TEV/TAV) ────────────────────────────
|
||||
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||
|
||||
// ── SSF subtitle opcodes (shared) ───────────────────────────────────────────
|
||||
const SSF_OP_NOP = 0x00
|
||||
const SSF_OP_SHOW = 0x01
|
||||
const SSF_OP_HIDE = 0x02
|
||||
const SSF_OP_MOVE = 0x03
|
||||
const SSF_OP_UPLOAD_LOW_FONT = 0x80
|
||||
const SSF_OP_UPLOAD_HIGH_FONT = 0x81
|
||||
|
||||
// ── TAV quality LUT (index → quantiser) ─────────────────────────────────────
|
||||
const QLUT = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,264,272,280,288,296,304,312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,496,504,512,528,544,560,576,592,608,624,640,656,672,688,704,720,736,752,768,784,800,816,832,848,864,880,896,912,928,944,960,976,992,1008,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2112,2176,2240,2304,2368,2432,2496,2560,2624,2688,2752,2816,2880,2944,3008,3072,3136,3200,3264,3328,3392,3456,3520,3584,3648,3712,3776,3840,3904,3968,4032,4096]
|
||||
|
||||
// ── Display-plane addresses (4bpp / mode 4) ─────────────────────────────────
|
||||
const DISP_RG = -1048577
|
||||
const DISP_BA = -1310721
|
||||
const DISP_PLANE3 = -1310721 - 262144 // mode-8 third plane base (for getRGBfromScr)
|
||||
|
||||
// ── seqread selection ───────────────────────────────────────────────────────
|
||||
// Mirrors the tape-vs-disk branch every old player carried. Returns a prepared
|
||||
// seqread module instance (a stateful singleton — only one decoder at a time).
|
||||
function openSeqread(fullPathStr) {
|
||||
let sr
|
||||
if (fullPathStr.startsWith('$:/TAPE') || fullPathStr.startsWith('$:\\TAPE')) {
|
||||
sr = require("seqreadtape")
|
||||
sr.prepare(fullPathStr)
|
||||
sr.seek(0)
|
||||
} else {
|
||||
sr = require("seqread")
|
||||
sr.prepare(fullPathStr)
|
||||
}
|
||||
return sr
|
||||
}
|
||||
|
||||
// Read the 8-byte magic into a JS array (frees the scratch buffer).
|
||||
function readMagic(sr) {
|
||||
let p = sr.readBytes(8)
|
||||
let out = []
|
||||
for (let i = 0; i < 8; i++) out.push(sys.peek(p + i) & 255)
|
||||
sys.free(p)
|
||||
return out
|
||||
}
|
||||
|
||||
function magicEquals(got, want) {
|
||||
for (let i = 0; i < 8; i++) if (got[i] !== want[i]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Detect container format from the 8-byte magic. Returns 'mov'|'tev'|'tav'|'tap'|'ucf'|null.
|
||||
function detectFormat(magic) {
|
||||
if (magicEquals(magic, MAGIC_MOV)) return 'mov'
|
||||
if (magicEquals(magic, MAGIC_TEV)) return 'tev'
|
||||
if (magicEquals(magic, MAGIC_TAV)) return 'tav'
|
||||
if (magicEquals(magic, MAGIC_TAP)) return 'tap'
|
||||
if (magicEquals(magic, MAGIC_UCF)) return 'ucf'
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Luma ─────────────────────────────────────────────────────────────────────
|
||||
// BT.601 integer luma from 8-bit RGB.
|
||||
function luma8(r, g, b) { return (r * 77 + g * 150 + b * 29) >> 8 }
|
||||
|
||||
// ── Audio router ─────────────────────────────────────────────────────────────
|
||||
// One playhead, deferred play(). Handles the per-packet audio codecs shared by
|
||||
// the backends. TAV's bundled-MP2 (0x40) pre-decode/streaming stays in the TAV
|
||||
// backend because it interleaves with the GOP display loop.
|
||||
function makeAudioRouter(sr) {
|
||||
const playhead = audio.getFreePlayhead(0)
|
||||
const SND_BASE = audio.getBaseAddr()
|
||||
const SND_MEM = audio.getMemAddr()
|
||||
audio.resetParams(playhead)
|
||||
audio.purgeQueue(playhead)
|
||||
audio.setPcmMode(playhead)
|
||||
let volume = 255
|
||||
audio.setMasterVolume(playhead, volume)
|
||||
|
||||
let mp2Init = false
|
||||
let fired = false
|
||||
|
||||
return {
|
||||
playhead: playhead,
|
||||
sndBase: SND_BASE,
|
||||
sndMem: SND_MEM,
|
||||
|
||||
// Fire playback once, on the first displayed frame.
|
||||
fire() { if (!fired) { audio.play(playhead); fired = true } },
|
||||
isFired() { return fired },
|
||||
|
||||
stop() { audio.stop(playhead) },
|
||||
resume() { audio.play(playhead) },
|
||||
purge() { audio.purgeQueue(playhead); fired = false },
|
||||
|
||||
setVolume(v) { volume = (v < 0) ? 0 : (v > 255) ? 255 : v; audio.setMasterVolume(playhead, volume) },
|
||||
getVolume() { return volume },
|
||||
|
||||
// MP2 packet: payload already length-known by caller; reads `len` bytes.
|
||||
mp2(len) {
|
||||
if (!mp2Init) { mp2Init = true; audio.mp2Init() }
|
||||
sr.readBytes(len, SND_BASE - 2368)
|
||||
audio.mp2Decode()
|
||||
audio.mp2UploadDecoded(playhead)
|
||||
},
|
||||
// MP2 frame whose size is implicit in the iPF packet type.
|
||||
ensureMp2() { if (!mp2Init) { mp2Init = true; audio.mp2Init() } },
|
||||
|
||||
// TAD packet.
|
||||
tad(sampleLen, payloadLen) {
|
||||
sr.readBytes(payloadLen, SND_MEM - 917504)
|
||||
audio.tadDecode()
|
||||
audio.tadUploadDecoded(playhead, sampleLen)
|
||||
},
|
||||
// Native (zstd PCMu8) packet.
|
||||
nativePcm(zstdLen) {
|
||||
let zstdPtr = sys.malloc(zstdLen)
|
||||
sr.readBytes(zstdLen, zstdPtr)
|
||||
let pcmPtr = sys.malloc(65536)
|
||||
let pcmLen = gzip.decompFromTo(zstdPtr, zstdLen, pcmPtr)
|
||||
if (pcmLen > 65536) { sys.free(zstdPtr); sys.free(pcmPtr); throw Error(`PCM data too long -- got ${pcmLen} bytes`) }
|
||||
audio.putPcmDataByPtr(playhead, pcmPtr, pcmLen, 0)
|
||||
audio.setSampleUploadLength(playhead, pcmLen)
|
||||
audio.startSampleUpload(playhead)
|
||||
sys.free(zstdPtr)
|
||||
sys.free(pcmPtr)
|
||||
},
|
||||
// Raw PCM (iPF 0x1000/0x1001): payload bytes streamed directly.
|
||||
rawPcm(len) {
|
||||
let frame = sr.readBytes(len)
|
||||
audio.putPcmDataByPtr(playhead, frame, len, 0)
|
||||
audio.setSampleUploadLength(playhead, len)
|
||||
audio.startSampleUpload(playhead)
|
||||
sys.free(frame)
|
||||
},
|
||||
|
||||
close() { audio.stop(playhead); audio.purgeQueue(playhead) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subtitle engine ──────────────────────────────────────────────────────────
|
||||
// Parses SSF (frame-locked 0x30) and SSF-TC (timecode 0x31) packets and exposes
|
||||
// the *active* subtitle as state; the player renders it (the "postprocessor"
|
||||
// stage). Font-ROM uploads are hardware writes, so the engine performs them.
|
||||
// fontUploadBase: -1300607 (TEV) or -133121 (TAV) — kept per-format for parity.
|
||||
function makeSubtitleEngine(sr, fontUploadBase) {
|
||||
const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false }
|
||||
let events = []
|
||||
let nextIndex = 0
|
||||
let fontUploaded = false
|
||||
|
||||
function uploadFont(opcode, remainingBytes) {
|
||||
if (remainingBytes >= 3) {
|
||||
let payloadLen = sr.readShort()
|
||||
if (remainingBytes >= payloadLen + 2) {
|
||||
let fontData = sr.readBytes(payloadLen)
|
||||
for (let i = 0; i < Math.min(payloadLen, 1920); i++) sys.poke(fontUploadBase - i, sys.peek(fontData + i))
|
||||
sys.poke(-1299460, (opcode == SSF_OP_UPLOAD_LOW_FONT) ? 18 : 19)
|
||||
sys.free(fontData)
|
||||
}
|
||||
fontUploaded = true
|
||||
subtitle.useUnicode = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subtitle: subtitle,
|
||||
get fontUploaded() { return fontUploaded },
|
||||
|
||||
// Frame-locked subtitle packet (0x30): applies immediately.
|
||||
parseLegacy(packetSize) {
|
||||
sr.readOneByte(); sr.readOneByte(); sr.readOneByte() // 24-bit index
|
||||
let opcode = sr.readOneByte()
|
||||
let remainingBytes = packetSize - 4
|
||||
switch (opcode) {
|
||||
case SSF_OP_SHOW: {
|
||||
if (remainingBytes > 1) {
|
||||
let tb = sr.readBytes(remainingBytes)
|
||||
let s = ""
|
||||
for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; s += String.fromCharCode(b) }
|
||||
sys.free(tb)
|
||||
subtitle.text = s; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true
|
||||
}
|
||||
break
|
||||
}
|
||||
case SSF_OP_HIDE: { subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break }
|
||||
case SSF_OP_MOVE: {
|
||||
if (remainingBytes >= 2) {
|
||||
let pos = sr.readOneByte(); sr.readOneByte()
|
||||
if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true }
|
||||
}
|
||||
break
|
||||
}
|
||||
case SSF_OP_UPLOAD_LOW_FONT:
|
||||
case SSF_OP_UPLOAD_HIGH_FONT: { uploadFont(opcode, remainingBytes); break }
|
||||
default: { if (remainingBytes > 0) { let s = sr.readBytes(remainingBytes); sys.free(s) } break }
|
||||
}
|
||||
},
|
||||
|
||||
// Timecode subtitle packet (0x31): buffered, applied by poll().
|
||||
parseTC(packetSize) {
|
||||
let i0 = sr.readOneByte(), i1 = sr.readOneByte(), i2 = sr.readOneByte()
|
||||
let index = i0 | (i1 << 8) | (i2 << 16)
|
||||
let tc = 0
|
||||
for (let i = 0; i < 8; i++) { tc += sr.readOneByte() * Math.pow(2, i * 8) }
|
||||
let opcode = sr.readOneByte()
|
||||
let remainingBytes = packetSize - 12
|
||||
let text = null
|
||||
if (remainingBytes > 1 && (opcode === SSF_OP_SHOW || (opcode >= 0x10 && opcode <= 0x2F))) {
|
||||
let tb = sr.readBytes(remainingBytes)
|
||||
text = ""
|
||||
for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; text += String.fromCharCode(b) }
|
||||
sys.free(tb)
|
||||
} else if (remainingBytes > 0) {
|
||||
let s = sr.readBytes(remainingBytes); sys.free(s)
|
||||
}
|
||||
events.push({ timecode_ns: tc, index: index, opcode: opcode, text: text })
|
||||
},
|
||||
|
||||
// Advance through timecode events whose time has been reached.
|
||||
poll(currentTimeNs) {
|
||||
while (nextIndex < events.length) {
|
||||
let ev = events[nextIndex]
|
||||
if (ev.timecode_ns > currentTimeNs) break
|
||||
switch (ev.opcode) {
|
||||
case SSF_OP_SHOW: subtitle.text = ev.text || ""; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true; break
|
||||
case SSF_OP_HIDE: subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break
|
||||
case SSF_OP_MOVE:
|
||||
if (ev.text && ev.text.length > 0) {
|
||||
let pos = ev.text.charCodeAt(0)
|
||||
if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true }
|
||||
}
|
||||
break
|
||||
}
|
||||
nextIndex++
|
||||
}
|
||||
},
|
||||
|
||||
// After a seek: jump the event cursor to the first event at/after `tc`.
|
||||
resetTo(tc) {
|
||||
nextIndex = 0
|
||||
for (let i = 0; i < events.length; i++) { if (events[i].timecode_ns >= tc) { nextIndex = i; break } }
|
||||
subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true
|
||||
},
|
||||
|
||||
hasEvents() { return events.length > 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bias lighting ────────────────────────────────────────────────────────────
|
||||
// Samples the screen borders and drifts the background colour toward them —
|
||||
// the "ambilight" the old players ran after each frame upload. Mode-aware
|
||||
// (4/5/8 bpp) read-back, matching playtav's getRGBfromScr.
|
||||
function makeBias(width, height, graphicsMode) {
|
||||
const BIAS_MIN = 1.0 / 16.0
|
||||
let old = [BIAS_MIN, BIAS_MIN, BIAS_MIN]
|
||||
const nativeWidth = graphics.getPixelDimension()[0]
|
||||
const nativeHeight = graphics.getPixelDimension()[1]
|
||||
const STRIDE = 560
|
||||
|
||||
function rgbFromScr(x, y) {
|
||||
let off = y * STRIDE + x
|
||||
let fb1 = sys.peek(DISP_RG - off)
|
||||
let fb2 = sys.peek(DISP_BA - off)
|
||||
if (graphicsMode == 5) {
|
||||
let fb3 = sys.peek(DISP_PLANE3 - off)
|
||||
return [((fb1 >>> 2) & 31) / 31.0, (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) / 31.0, (fb2 & 31) / 31.0]
|
||||
} else if (graphicsMode == 4) {
|
||||
return [(fb1 >>> 4) / 15.0, (fb1 & 15) / 15.0, (fb2 >>> 4) / 15.0]
|
||||
} else {
|
||||
let fb3 = sys.peek(DISP_PLANE3 - off)
|
||||
return [fb1 / 255.0, fb2 / 255.0, fb3 / 255.0]
|
||||
}
|
||||
}
|
||||
|
||||
return function setBiasLighting() {
|
||||
let samples = []
|
||||
let offsetX = Math.floor((nativeWidth - width) / 2)
|
||||
let offsetY = Math.floor((nativeHeight - height) / 2)
|
||||
let stepX = Math.max(8, Math.floor(width / 18))
|
||||
let stepY = Math.max(8, Math.floor(height / 17))
|
||||
let margin = Math.min(8, Math.floor(width / 70))
|
||||
|
||||
for (let x = margin; x < width - margin; x += stepX) {
|
||||
samples.push(rgbFromScr(x + offsetX, margin + offsetY))
|
||||
samples.push(rgbFromScr(x + offsetX, height - margin - 1 + offsetY))
|
||||
}
|
||||
for (let y = margin; y < height - margin; y += stepY) {
|
||||
samples.push(rgbFromScr(margin + offsetX, y + offsetY))
|
||||
samples.push(rgbFromScr(width - margin - 1 + offsetX, y + offsetY))
|
||||
}
|
||||
|
||||
let out = [0.0, 0.0, 0.0]
|
||||
samples.forEach(rgb => { out[0] += rgb[0]; out[1] += rgb[1]; out[2] += rgb[2] })
|
||||
out[0] = BIAS_MIN + (out[0] / samples.length / 2.0)
|
||||
out[1] = BIAS_MIN + (out[1] / samples.length / 2.0)
|
||||
out[2] = BIAS_MIN + (out[2] / samples.length / 2.0)
|
||||
|
||||
let bgr = (old[0] * 5 + out[0]) / 6.0
|
||||
let bgg = (old[1] * 5 + out[1]) / 6.0
|
||||
let bgb = (old[2] * 5 + out[2]) / 6.0
|
||||
old = [bgr, bgg, bgb]
|
||||
graphics.setBackground(Math.round(bgr * 255), Math.round(bgg * 255), Math.round(bgb * 255))
|
||||
}
|
||||
}
|
||||
|
||||
// ── sampleGray source ────────────────────────────────────────────────────────
|
||||
// Fill an ASCII brightness buffer (dst, dstW×dstH) by nearest-sampling the GPU
|
||||
// framebuffer (the shared "player framebuffer" the backend has just blit()ted
|
||||
// to). Reading the screen — rather than each backend's private frame store —
|
||||
// keeps one sampler for every format/kind (TAV's GOP videoBuffer is Java-heap
|
||||
// and has no JS-addressable VM address, so reading it directly is impossible).
|
||||
//
|
||||
// Only ~dstW·dstH peeks per call, so it is cheap regardless of frame size.
|
||||
// Pixel `off` is backward-addressed (DISP_RG-off / DISP_BA-off), matching how
|
||||
// every decoder writes the framebuffer. `mode` selects 4/5/8-bpp unpacking
|
||||
// (mirrors playtav's getRGBfromScr).
|
||||
function sampleGrayScreen(width, height, dst, dstW, dstH, mode) {
|
||||
for (let y = 0; y < dstH; y++) {
|
||||
let sy = (y * height / dstH) | 0
|
||||
let dstRow = y * dstW
|
||||
for (let x = 0; x < dstW; x++) {
|
||||
let sx = (x * width / dstW) | 0
|
||||
let off = sy * 560 + sx
|
||||
let fb1 = sys.peek(DISP_RG - off) & 255
|
||||
let fb2 = sys.peek(DISP_BA - off) & 255
|
||||
let r, g, b
|
||||
if (mode == 5) {
|
||||
r = ((fb1 >>> 2) & 31) * 255 / 31
|
||||
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
|
||||
b = (fb2 & 31) * 255 / 31
|
||||
} else if (mode == 8) {
|
||||
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
|
||||
} else { // mode 4
|
||||
r = (fb1 >>> 4) * 17
|
||||
g = (fb1 & 15) * 17
|
||||
b = (fb2 >>> 4) * 17
|
||||
}
|
||||
dst[dstRow + x] = luma8(r | 0, g | 0, b | 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── sampleColour source ──────────────────────────────────────────────────────
|
||||
// Companion to sampleGrayScreen: fill an RGB buffer (dst, length dstW·dstH·3,
|
||||
// laid out R,G,B per cell) by point-sampling the GPU framebuffer at the CENTRE
|
||||
// of each cell. Used by the player's colour-ASCII postprocessor — aa.mjs picks
|
||||
// each glyph from brightness, this supplies the per-cell ink colour. Same
|
||||
// backend-specific `mode` (4/5/8-bpp unpacking) and same cheap ~dstW·dstH peek
|
||||
// count as sampleGrayScreen.
|
||||
function sampleColourScreen(width, height, dst, dstW, dstH, mode) {
|
||||
for (let y = 0; y < dstH; y++) {
|
||||
let sy = ((y + 0.5) * height / dstH) | 0
|
||||
if (sy >= height) sy = height - 1
|
||||
let dstRow = y * dstW * 3
|
||||
for (let x = 0; x < dstW; x++) {
|
||||
let sx = ((x + 0.5) * width / dstW) | 0
|
||||
if (sx >= width) sx = width - 1
|
||||
let off = sy * 560 + sx
|
||||
let fb1 = sys.peek(DISP_RG - off) & 255
|
||||
let fb2 = sys.peek(DISP_BA - off) & 255
|
||||
let r, g, b
|
||||
if (mode == 5) {
|
||||
r = ((fb1 >>> 2) & 31) * 255 / 31
|
||||
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
|
||||
b = (fb2 & 31) * 255 / 31
|
||||
} else if (mode == 8) {
|
||||
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
|
||||
} else { // mode 4
|
||||
r = (fb1 >>> 4) * 17
|
||||
g = (fb1 & 15) * 17
|
||||
b = (fb2 >>> 4) * 17
|
||||
}
|
||||
let di = dstRow + x * 3
|
||||
dst[di] = r | 0; dst[di + 1] = g | 0; dst[di + 2] = b | 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── sampleGray / sampleColour from a RAM RGB888 frame ─────────────────────────
|
||||
// Companions to the *Screen samplers that read a decoded frame straight out of a
|
||||
// JS-addressable RGB888 RAM buffer (3 bytes/pixel, forward-addressed) instead of
|
||||
// the GPU display planes. Backends that decode into RAM (TEV / TAV) use these so
|
||||
// the ASCII renderer can sample the frame WITHOUT it ever being uploaded to the
|
||||
// video adapter — the whole point of the generic RAM-frame model. Same cheap
|
||||
// ~dstW·dstH·3 peek count and the same nearest-sampling geometry as the *Screen
|
||||
// versions (sampleGrayRGB row-aligned; sampleColourRGB at the cell centre).
|
||||
function sampleGrayRGB(srcPtr, width, height, dst, dstW, dstH) {
|
||||
for (let y = 0; y < dstH; y++) {
|
||||
let sy = (y * height / dstH) | 0
|
||||
let dstRow = y * dstW
|
||||
for (let x = 0; x < dstW; x++) {
|
||||
let sx = (x * width / dstW) | 0
|
||||
let o = srcPtr + (sy * width + sx) * 3
|
||||
let r = sys.peek(o) & 255, g = sys.peek(o + 1) & 255, b = sys.peek(o + 2) & 255
|
||||
dst[dstRow + x] = luma8(r, g, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sampleColourRGB(srcPtr, width, height, dst, dstW, dstH) {
|
||||
for (let y = 0; y < dstH; y++) {
|
||||
let sy = ((y + 0.5) * height / dstH) | 0
|
||||
if (sy >= height) sy = height - 1
|
||||
let dstRow = y * dstW * 3
|
||||
for (let x = 0; x < dstW; x++) {
|
||||
let sx = ((x + 0.5) * width / dstW) | 0
|
||||
if (sx >= width) sx = width - 1
|
||||
let o = srcPtr + (sy * width + sx) * 3
|
||||
let di = dstRow + x * 3
|
||||
dst[di] = sys.peek(o) & 255; dst[di + 1] = sys.peek(o + 1) & 255; dst[di + 2] = sys.peek(o + 2) & 255
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports = {
|
||||
MAGIC_MOV, MAGIC_TEV, MAGIC_TAV, MAGIC_TAP, MAGIC_UCF,
|
||||
MP2_FRAME_SIZE, QLUT,
|
||||
SSF_OP_NOP, SSF_OP_SHOW, SSF_OP_HIDE, SSF_OP_MOVE,
|
||||
SSF_OP_UPLOAD_LOW_FONT, SSF_OP_UPLOAD_HIGH_FONT,
|
||||
DISP_RG, DISP_BA,
|
||||
openSeqread, readMagic, detectFormat, magicEquals,
|
||||
luma8,
|
||||
makeAudioRouter, makeSubtitleEngine, makeBias,
|
||||
sampleGrayScreen, sampleColourScreen,
|
||||
sampleGrayRGB, sampleColourRGB
|
||||
}
|
||||
192
assets/disk0/tvdos/include/mediadec_ipf.mjs
Normal file
192
assets/disk0/tvdos/include/mediadec_ipf.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* mediadec_ipf.mjs — legacy MOV / iPF backend for the mediadec library.
|
||||
*
|
||||
* Ported from assets/disk0/tvdos/bin/playmv1.js. Decodes iPF1 / iPF1a /
|
||||
* iPF2 / iPF2a / iPF1-delta video packets straight to the 4bpp display planes
|
||||
* (the proven, fast path), plus MP2 and raw-PCM audio and the background-colour
|
||||
* packet. Presents at decode time (so blit() is a no-op); bias lighting is a
|
||||
* separate player-driven stage via the bias() method; the ASCII path reads the
|
||||
* planes back via common.sampleGrayScreen.
|
||||
*/
|
||||
|
||||
const WIDTH = 560
|
||||
const HEIGHT = 448
|
||||
const FBUF_SIZE = WIDTH * HEIGHT
|
||||
|
||||
function create(magic, sr, fileLength, opts, common) {
|
||||
const audioR = common.makeAudioRouter(sr)
|
||||
|
||||
// Header (after the 8-byte magic): w, h, fps, frameCount, queue info.
|
||||
let width = sr.readShort()
|
||||
let height = sr.readShort()
|
||||
let fps = sr.readShort(); if (fps == 0) fps = 9999
|
||||
const FRAME_COUNT = sr.readInt() % 16777216
|
||||
sr.readShort() // skip unused
|
||||
sr.readShort() // audioQueueInfo (unused for playback)
|
||||
sr.skip(10)
|
||||
|
||||
graphics.setGraphicsMode(4)
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(240)
|
||||
|
||||
const FRAME_TIME = 1.0 / fps
|
||||
const applyBias = common.makeBias(width, height, 4)
|
||||
|
||||
const ipfbuf = sys.malloc(FBUF_SIZE)
|
||||
|
||||
const info = {
|
||||
format: 'ipf', width: width, height: height, fps: fps,
|
||||
totalFrames: FRAME_COUNT, hasAudio: true, hasSubtitles: false,
|
||||
isInterlaced: false, colourSpace: 'YCoCg', graphicsMode: 4, isStill: false
|
||||
}
|
||||
|
||||
// No subtitles in iPF; expose an inert state object for the uniform API.
|
||||
const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false }
|
||||
|
||||
let akku = FRAME_TIME
|
||||
let lastT = sys.nanoTime()
|
||||
let doFrameskip = true
|
||||
let autoBg = true
|
||||
let framesRead = 0
|
||||
let frameCount = 0
|
||||
let paused = false
|
||||
|
||||
function setBackgroundPacket() {
|
||||
autoBg = false
|
||||
let rgbx = sr.readInt()
|
||||
graphics.setBackground((rgbx & 0xFF000000) >>> 24, (rgbx & 0x00FF0000) >>> 16, (rgbx & 0x0000FF00) >>> 8)
|
||||
}
|
||||
|
||||
function step() {
|
||||
const now = sys.nanoTime()
|
||||
if (paused) { lastT = now; return { type: 'idle' } }
|
||||
akku += (now - lastT) / 1000000000.0
|
||||
lastT = now
|
||||
|
||||
if (sr.getReadCount() >= fileLength) return { type: 'eof' }
|
||||
if (akku < FRAME_TIME) return { type: 'idle' }
|
||||
|
||||
// Drain accumulated time into a frame budget (frameskip drops late frames).
|
||||
let frameUnit = 0
|
||||
while (akku >= FRAME_TIME) { akku -= FRAME_TIME; frameUnit += 1 }
|
||||
if (!doFrameskip) frameUnit = 1
|
||||
|
||||
let displayed = false
|
||||
while (frameUnit >= 1 && sr.getReadCount() < fileLength) {
|
||||
let packetType = sr.readShort()
|
||||
|
||||
if (0xFFFF === packetType) { // sync — one frame boundary
|
||||
frameUnit -= 1
|
||||
}
|
||||
else if (0xFEFF === packetType) { // explicit background colour
|
||||
setBackgroundPacket()
|
||||
}
|
||||
else if (packetType < 2047) { // video
|
||||
if (packetType == 4 || packetType == 5 || packetType == 260 || packetType == 261) {
|
||||
let decodefun = (packetType > 255) ? graphics.decodeIpf2 : graphics.decodeIpf1
|
||||
let payloadLen = sr.readInt()
|
||||
if (framesRead >= FRAME_COUNT) return { type: 'eof' }
|
||||
framesRead += 1
|
||||
let gz = sr.readBytes(payloadLen)
|
||||
if (frameUnit == 1) {
|
||||
gzip.decompFromTo(gz, payloadLen, ipfbuf)
|
||||
decodefun(ipfbuf, common.DISP_RG, common.DISP_BA, width, height, (packetType & 255) == 5)
|
||||
audioR.fire()
|
||||
displayed = true
|
||||
frameCount += 1
|
||||
}
|
||||
sys.free(gz)
|
||||
}
|
||||
else if (packetType == 516) { // iPF1-delta
|
||||
doFrameskip = false
|
||||
let payloadLen = sr.readInt()
|
||||
if (framesRead >= FRAME_COUNT) return { type: 'eof' }
|
||||
framesRead += 1
|
||||
let gz = sr.readBytes(payloadLen)
|
||||
if (frameUnit == 1) {
|
||||
gzip.decompFromTo(gz, payloadLen, ipfbuf)
|
||||
graphics.applyIpf1d(ipfbuf, common.DISP_RG, common.DISP_BA, width, height)
|
||||
audioR.fire()
|
||||
displayed = true
|
||||
frameCount += 1
|
||||
}
|
||||
sys.free(gz)
|
||||
}
|
||||
else {
|
||||
throw Error(`Unknown iPF video packet type ${packetType} at ${sr.getReadCount() - 2}`)
|
||||
}
|
||||
}
|
||||
else if (4096 <= packetType && packetType <= 6143) { // audio
|
||||
let readLength = (packetType >>> 8 == 17)
|
||||
? common.MP2_FRAME_SIZE[(packetType & 255) >>> 1]
|
||||
: sr.readInt()
|
||||
if (readLength == 0) throw Error("iPF audio read length is zero")
|
||||
if (packetType >>> 8 == 17) { // MP2
|
||||
audioR.ensureMp2()
|
||||
sr.readBytes(readLength, audioR.sndBase - 2368)
|
||||
audio.mp2Decode()
|
||||
audio.mp2UploadDecoded(0)
|
||||
}
|
||||
else if (packetType == 0x1000 || packetType == 0x1001) { // raw PCM
|
||||
audioR.rawPcm(readLength)
|
||||
}
|
||||
else {
|
||||
throw Error(`iPF audio packet type ${packetType} at ${sr.getReadCount() - 2}`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Unknown — stop to avoid desync (matches old players' break).
|
||||
return { type: 'eof' }
|
||||
}
|
||||
}
|
||||
|
||||
return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' }
|
||||
}
|
||||
|
||||
// The frame is already on the display planes (decoded there in step()), so
|
||||
// presenting is a no-op. Bias lighting is a separate, player-driven stage
|
||||
// (bias() below) and is skipped when an explicit background packet disabled it.
|
||||
function blit() { }
|
||||
|
||||
// iPF decodes straight to the 4bpp display planes (no fast JS planar->RGB
|
||||
// path), so — unlike TEV / TAV — there is no RAM RGB888 frame: the planes ARE
|
||||
// the frame. sampleGray/sampleColour therefore read the planes back; this still
|
||||
// costs no extra upload in ASCII mode, since decoding already wrote the planes.
|
||||
function sampleGray(dst, w, h) { common.sampleGrayScreen(width, height, dst, w, h, 4) }
|
||||
function sampleColour(dst, w, h) { common.sampleColourScreen(width, height, dst, w, h, 4) }
|
||||
|
||||
return {
|
||||
info: info,
|
||||
subtitle: subtitle,
|
||||
get frameCount() { return frameCount },
|
||||
get currentTimecodeNs() { return Math.floor(frameCount * (1000000000.0 / fps)) },
|
||||
get videoRate() { return 0 },
|
||||
get frameMode() { return ' ' },
|
||||
cues: [],
|
||||
|
||||
// No generic RAM frame for iPF: it decodes straight to the display planes,
|
||||
// so frameBuffer is 0. Use sampleGray/sampleColour to read the frame instead.
|
||||
get frameBuffer() { return 0 },
|
||||
get frameWidth() { return width },
|
||||
get frameHeight() { return height },
|
||||
|
||||
step: step,
|
||||
blit: blit,
|
||||
bias() { if (autoBg) applyBias() }, // skipped when an explicit bg packet set the colour
|
||||
sampleGray: sampleGray,
|
||||
sampleColour: sampleColour,
|
||||
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
||||
isPaused() { return paused },
|
||||
setVolume(v) { audioR.setVolume(v) },
|
||||
getVolume() { return audioR.getVolume() },
|
||||
seekSeconds(_n) { /* iPF has no index; seeking unsupported */ },
|
||||
cue(_d) { /* no cues */ },
|
||||
|
||||
close() {
|
||||
sys.free(ipfbuf)
|
||||
audioR.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports = { create }
|
||||
757
assets/disk0/tvdos/include/mediadec_tav.mjs
Normal file
757
assets/disk0/tvdos/include/mediadec_tav.mjs
Normal file
@@ -0,0 +1,757 @@
|
||||
/*
|
||||
* mediadec_tav.mjs — TAV (TSVM Advanced Video) backend for the mediadec library.
|
||||
*
|
||||
* Ported from assets/disk0/tvdos/bin/playtav.js — the heaviest backend. DWT
|
||||
* codec with: I/P frames, unified 3D-DWT GOPs (async triple-buffer + overflow
|
||||
* queue), interlaced fields (yadif), TAP still images, UCF cue files +
|
||||
* multi-file concatenation, Left/Right + cue seeking, screen masking, videotex
|
||||
* (text-mode video), bundled MP2, and MP2/TAD/native-PCM audio, plus extended
|
||||
* headers (XFPS) and timecode-driven subtitles.
|
||||
*
|
||||
* The original main-loop body becomes step(): each call performs one iteration
|
||||
* (optional packet read + GOP state machine + a time-gated display) and, when a
|
||||
* frame is due, materialises it into PRESENT_RGB (an RGB888 RAM buffer) before
|
||||
* returning 'frame'. This is the one structural change from the original: every
|
||||
* source (I/P ping-pong, progressive GOP in the Java-heap videoBuffer, interlaced
|
||||
* GOP) is funnelled into one RAM frame, so blit() (upload to the adapter) and the
|
||||
* ASCII sampler both read from RAM — neither reads pixels back off the display
|
||||
* planes, and `frameBuffer` exposes the frame for arbitrary reuse.
|
||||
*/
|
||||
|
||||
const TAV_VERSION = 1
|
||||
const UCF_VERSION = 1
|
||||
const ADDRESSING_EXTERNAL = 0x01
|
||||
const ADDRESSING_INTERNAL = 0x02
|
||||
const TAV_TEMPORAL_LEVELS = 2
|
||||
|
||||
const TAV_PACKET_IFRAME = 0x10
|
||||
const TAV_PACKET_PFRAME = 0x11
|
||||
const TAV_PACKET_GOP_UNIFIED = 0x12
|
||||
const TAV_PACKET_AUDIO_MP2 = 0x20
|
||||
const TAV_PACKET_AUDIO_NATIVE = 0x21
|
||||
const TAV_PACKET_AUDIO_PCM_16LE = 0x22
|
||||
const TAV_PACKET_AUDIO_ADPCM = 0x23
|
||||
const TAV_PACKET_AUDIO_TAD = 0x24
|
||||
const TAV_PACKET_SUBTITLE = 0x30
|
||||
const TAV_PACKET_SUBTITLE_TC = 0x31
|
||||
const TAV_PACKET_VIDEOTEX = 0x3F
|
||||
const TAV_PACKET_AUDIO_BUNDLED = 0x40
|
||||
const TAV_PACKET_EXTENDED_HDR = 0xEF
|
||||
const TAV_PACKET_SCREEN_MASK = 0xF2
|
||||
const TAV_PACKET_GOP_SYNC = 0xFC
|
||||
const TAV_PACKET_TIMECODE = 0xFD
|
||||
const TAV_PACKET_SYNC_NTSC = 0xFE
|
||||
const TAV_PACKET_SYNC = 0xFF
|
||||
const TAV_FILE_HEADER_FIRST = 0x1F
|
||||
|
||||
const BLIP = '\x847u'
|
||||
|
||||
const BUFFER_SLOTS = 3
|
||||
const MAX_GOP_SIZE = 24
|
||||
|
||||
function create(magic, sr, fileLength, opts, common, isTap) {
|
||||
const QLUT = common.QLUT
|
||||
const audioR = common.makeAudioRouter(sr)
|
||||
const subEngine = common.makeSubtitleEngine(sr, -133121) // TAV font-ROM base
|
||||
const SND_BASE = audioR.sndBase
|
||||
const AUDIO_DEVICE = audioR.playhead
|
||||
|
||||
// ── Header (32 bytes incl. magic) ───────────────────────────────────────
|
||||
let version = sr.readOneByte()
|
||||
let width = sr.readShort()
|
||||
let height = sr.readShort()
|
||||
let fps = sr.readOneByte()
|
||||
let fps_num = fps, fps_den = 1
|
||||
let totalFrames = sr.readInt()
|
||||
let waveletFilter = sr.readOneByte()
|
||||
let decompLevels = sr.readOneByte()
|
||||
let qualityY = sr.readOneByte()
|
||||
let qualityCo = sr.readOneByte()
|
||||
let qualityCg = sr.readOneByte()
|
||||
let extraFlags = sr.readOneByte()
|
||||
let videoFlags = sr.readOneByte()
|
||||
let qualityLevel = sr.readOneByte()
|
||||
let channelLayout = sr.readOneByte()
|
||||
let entropyCoder = sr.readOneByte()
|
||||
let encoderPreset = sr.readOneByte()
|
||||
sr.skip(2) // reserved + device orientation
|
||||
let fileRole = sr.readOneByte()
|
||||
|
||||
let baseVersion = (version > 8) ? (version - 8) : version
|
||||
let temporalMotionCoder = (version > 8) ? 1 : 0
|
||||
if (baseVersion < 1 || baseVersion > 8) throw Error(`Unsupported TAV base version ${baseVersion}`)
|
||||
|
||||
const hasAudio = (extraFlags & 0x01) !== 0
|
||||
const hasSubtitles = (extraFlags & 0x02) !== 0
|
||||
let isInterlaced = (videoFlags & 0x01) !== 0
|
||||
let isNTSC = (videoFlags & 0x02) !== 0
|
||||
let isLossless = (videoFlags & 0x04) !== 0
|
||||
let colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg"
|
||||
|
||||
// ── Graphics ─────────────────────────────────────────────────────────────
|
||||
graphics.setGraphicsMode(4)
|
||||
graphics.setGraphicsMode(5)
|
||||
graphics.clearPixels(0); graphics.clearPixels2(0); graphics.clearPixels3(0); graphics.clearPixels4(0)
|
||||
let gpuGraphicsMode = graphics.getGraphicsMode()
|
||||
|
||||
let decodeHeight = isInterlaced ? (height >> 1) : height
|
||||
let frametime = 1000000000.0 / fps
|
||||
let FRAME_TIME = 1.0 / fps
|
||||
let applyBias = common.makeBias(width, height, gpuGraphicsMode)
|
||||
|
||||
// ── Frame buffers ────────────────────────────────────────────────────────
|
||||
let FRAME_SIZE = width * height * 3
|
||||
const SLOT_SIZE = MAX_GOP_SIZE * width * height * 3
|
||||
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
|
||||
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
|
||||
sys.memset(RGB_BUFFER_A, 0, FRAME_SIZE)
|
||||
sys.memset(RGB_BUFFER_B, 0, FRAME_SIZE)
|
||||
let CURRENT_RGB = RGB_BUFFER_A
|
||||
let PREV_RGB = RGB_BUFFER_B
|
||||
|
||||
// Canonical decoded-frame buffer: every displayed frame is materialised here
|
||||
// as RGB888, whatever its source (I/P ping-pong, progressive GOP in the
|
||||
// Java-heap videoBuffer, or an interlaced GOP that needs deinterlacing). This
|
||||
// is the one ~735 kB buffer the generic RAM-frame model costs: blit() uploads
|
||||
// it, the ASCII path samples it, and `frameBuffer` exposes it to callers — so
|
||||
// a frame can be reused without ever round-tripping through the display planes.
|
||||
const PRESENT_RGB = sys.malloc(FRAME_SIZE)
|
||||
sys.memset(PRESENT_RGB, 0, FRAME_SIZE)
|
||||
|
||||
const FIELD_SIZE = width * decodeHeight * 3
|
||||
const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||
const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||
const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||
if (isInterlaced) { sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE) }
|
||||
let prevField = PREV_FIELD, curField = CURR_FIELD, nextField = NEXT_FIELD
|
||||
|
||||
const info = {
|
||||
format: isTap ? 'tap' : 'tav', width: width, height: height, fps: fps,
|
||||
totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitles,
|
||||
isInterlaced: isInterlaced, colourSpace: colourSpace, graphicsMode: gpuGraphicsMode,
|
||||
isStill: !!isTap
|
||||
}
|
||||
|
||||
// ── Playback / GOP state ─────────────────────────────────────────────────
|
||||
let frameCount = 0, trueFrameCount = 0
|
||||
let akku = FRAME_TIME, akku2 = 0.0
|
||||
let firstFrameIssued = false
|
||||
let nextFrameTime = 0
|
||||
let paused = false
|
||||
let decoderDbgInfo = {}
|
||||
let videoRate = 0
|
||||
let videoRateBin = []
|
||||
|
||||
let currentGopBufferSlot = 0, currentGopSize = 0, currentGopFrameIndex = 0
|
||||
let readyGopData = null, decodingGopData = null
|
||||
let asyncDecodeInProgress = false, asyncDecodeSlot = 0, asyncDecodeGopSize = 0
|
||||
let asyncDecodePtr = 0, asyncDecodeStartTime = 0
|
||||
let iframeReady = false
|
||||
let shouldReadPackets = true
|
||||
let overflowQueue = []
|
||||
|
||||
let predecodedPcmBuffer = null, predecodedPcmSize = 0, predecodedPcmOffset = 0
|
||||
const PCM_UPLOAD_CHUNK = 2304
|
||||
|
||||
let cueElements = [], currentCueIndex = -1, skipped = false
|
||||
let iframePositions = []
|
||||
let currentFileIndex = 1
|
||||
|
||||
// Subtitle/timecode
|
||||
let currentTimecodeNs = 0, baseTimecodeNs = 0, baseTimecodeFrameCount = 0
|
||||
|
||||
// Screen mask
|
||||
let screenMaskEntries = [], screenMaskTop = 0, screenMaskRight = 0, screenMaskBottom = 0, screenMaskLeft = 0
|
||||
|
||||
// Deferred-display descriptor consumed by blit()/sampleGray().
|
||||
let pending = { kind: null, src: 0, frameIndex: 0, bufferOffset: 0, frameNo: 0, gopSize: 0 }
|
||||
|
||||
let lastT = sys.nanoTime()
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
function updateDataRateBin(rate) { videoRateBin.push(rate); if (videoRateBin.length > 10) videoRateBin.shift() }
|
||||
function getVideoRate() { let b = videoRateBin.reduce((a, c) => a + c, 0); return b * fps / videoRateBin.length }
|
||||
|
||||
function parseXFPS(s) {
|
||||
let p = s.split("/")
|
||||
if (p.length === 2) { let n = parseInt(p[0], 10), d = parseInt(p[1], 10); if (!isNaN(n) && !isNaN(d) && d > 0) { fps_num = n; fps_den = d; fps = n / d; return true } }
|
||||
return false
|
||||
}
|
||||
|
||||
function updateScreenMask(frameNum) {
|
||||
if (screenMaskEntries.length === 0) return
|
||||
for (let i = screenMaskEntries.length - 1; i >= 0; i--) {
|
||||
if (screenMaskEntries[i].frameNum <= frameNum) {
|
||||
screenMaskTop = screenMaskEntries[i].top; screenMaskRight = screenMaskEntries[i].right
|
||||
screenMaskBottom = screenMaskEntries[i].bottom; screenMaskLeft = screenMaskEntries[i].left
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
function fillMaskedRegions() { return } // disabled upstream; kept for parity
|
||||
|
||||
function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t }
|
||||
|
||||
function cleanupAsyncDecode() {
|
||||
// asyncDecodePtr ALIASES readyGopData.compressedPtr / decodingGopData.compressedPtr:
|
||||
// startAsyncGop records the same compressedPtr in both the asyncDecodePtr tracker and
|
||||
// the GOP record (handleGopPacket cases + overflow drain). The normal free paths know
|
||||
// this (free via one var, zero the other); a blind free of all three here double-frees
|
||||
// and sys.free throws "No allocation for pointer", aborting close() before it frees the
|
||||
// RGB frame buffers (leaking two width*height*3 allocations). Free each pointer once.
|
||||
let freed = {}
|
||||
function freeOnce(p) { if (p && !freed[p]) { freed[p] = true; sys.free(p) } }
|
||||
if (asyncDecodeInProgress) freeOnce(asyncDecodePtr)
|
||||
if (readyGopData) freeOnce(readyGopData.compressedPtr)
|
||||
if (decodingGopData) freeOnce(decodingGopData.compressedPtr)
|
||||
asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0
|
||||
readyGopData = null; decodingGopData = null
|
||||
if (predecodedPcmBuffer !== null) { sys.free(predecodedPcmBuffer); predecodedPcmBuffer = null; predecodedPcmSize = 0; predecodedPcmOffset = 0 }
|
||||
currentGopSize = 0; currentGopFrameIndex = 0; nextFrameTime = 0; shouldReadPackets = true
|
||||
}
|
||||
|
||||
function findNearestIframe(targetFrame) {
|
||||
if (iframePositions.length === 0) return null
|
||||
let result = null
|
||||
for (let i = iframePositions.length - 1; i >= 0; i--) { if (iframePositions[i].frameNum <= targetFrame) { result = iframePositions[i]; break } }
|
||||
return result || iframePositions[0]
|
||||
}
|
||||
|
||||
function scanForwardToIframe(targetFrame) {
|
||||
let savedPos = sr.getReadCount()
|
||||
try {
|
||||
let scanFrameCount = frameCount
|
||||
while (sr.getReadCount() < fileLength) {
|
||||
let packetPos = sr.getReadCount()
|
||||
let pType = sr.readOneByte()
|
||||
if (pType === TAV_PACKET_SYNC || pType === TAV_PACKET_SYNC_NTSC) { if (pType === TAV_PACKET_SYNC) scanFrameCount++; continue }
|
||||
if (pType === TAV_PACKET_IFRAME && scanFrameCount >= targetFrame) { iframePositions.push({ offset: packetPos, frameNum: scanFrameCount }); return { offset: packetPos, frameNum: scanFrameCount } }
|
||||
if (pType !== TAV_PACKET_SYNC && pType !== TAV_PACKET_SYNC_NTSC && pType !== TAV_FILE_HEADER_FIRST) { let s = sr.readInt(); sr.skip(s) }
|
||||
else if (pType === TAV_FILE_HEADER_FIRST) break
|
||||
}
|
||||
return null
|
||||
} catch (e) { serial.printerr(`Scan error: ${e}`); return null }
|
||||
finally { sr.seek(savedPos) }
|
||||
}
|
||||
|
||||
function applyNewHeader(h) {
|
||||
version = h.version; width = h.width; height = h.height; fps = h.fps
|
||||
totalFrames = h.totalFrames; waveletFilter = h.waveletFilter; decompLevels = h.decompLevels
|
||||
qualityY = h.qualityY; qualityCo = h.qualityCo; qualityCg = h.qualityCg
|
||||
extraFlags = h.extraFlags; videoFlags = h.videoFlags; qualityLevel = h.qualityLevel
|
||||
channelLayout = h.channelLayout
|
||||
baseVersion = (version > 8) ? (version - 8) : version
|
||||
temporalMotionCoder = (version > 8) ? 1 : 0
|
||||
isInterlaced = (videoFlags & 0x01) !== 0; isNTSC = (videoFlags & 0x02) !== 0; isLossless = (videoFlags & 0x04) !== 0
|
||||
colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg"
|
||||
decodeHeight = isInterlaced ? (height >> 1) : height
|
||||
frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps
|
||||
applyBias = common.makeBias(width, height, gpuGraphicsMode)
|
||||
info.width = width; info.height = height; info.fps = fps; info.totalFrames = totalFrames
|
||||
info.isInterlaced = isInterlaced; info.colourSpace = colourSpace
|
||||
}
|
||||
|
||||
// Returns a header object on success, or null/error code.
|
||||
function tryReadNextTAVHeader() {
|
||||
let newMagic = new Array(7)
|
||||
try {
|
||||
for (let i = 0; i < newMagic.length; i++) newMagic[i] = sr.readOneByte()
|
||||
while (newMagic[0] == 255) { newMagic.shift(); newMagic[newMagic.length - 1] = sr.readOneByte() }
|
||||
|
||||
let isValidTAV = true, isValidUCF = true
|
||||
for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_TAV[i + 1]) isValidTAV = false }
|
||||
for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_UCF[i + 1]) isValidUCF = false }
|
||||
if (!isValidTAV && !isValidUCF) { serial.printerr("Header mismatch: got " + newMagic.join()); return null }
|
||||
|
||||
if (isValidTAV) {
|
||||
let h = {
|
||||
version: sr.readOneByte(), width: sr.readShort(), height: sr.readShort(),
|
||||
fps: sr.readOneByte(), totalFrames: sr.readInt(), waveletFilter: sr.readOneByte(),
|
||||
decompLevels: sr.readOneByte(), qualityY: sr.readOneByte(), qualityCo: sr.readOneByte(),
|
||||
qualityCg: sr.readOneByte(), extraFlags: sr.readOneByte(), videoFlags: sr.readOneByte(),
|
||||
qualityLevel: sr.readOneByte(), channelLayout: sr.readOneByte(), fileRole: sr.readOneByte()
|
||||
}
|
||||
for (let i = 0; i < 4; i++) sr.readOneByte() // reserved
|
||||
return h
|
||||
}
|
||||
// UCF cue file: parse cue table then recurse to the following TAV header.
|
||||
let uver = sr.readOneByte()
|
||||
if (uver !== UCF_VERSION) { serial.println(`Unsupported UCF version ${uver}`); return null }
|
||||
let numElements = sr.readShort()
|
||||
let cueSize = sr.readInt()
|
||||
sr.skip(1)
|
||||
for (let i = 0; i < numElements; i++) {
|
||||
let el = {}
|
||||
el.addressingModeAndIntent = sr.readOneByte()
|
||||
el.addressingMode = el.addressingModeAndIntent & 15
|
||||
let nameLen = sr.readShort()
|
||||
el.name = sr.readString(nameLen)
|
||||
if (el.addressingMode === ADDRESSING_EXTERNAL) { let pl = sr.readShort(); el.path = sr.readString(pl) }
|
||||
else if (el.addressingMode === ADDRESSING_INTERNAL) {
|
||||
let ob = []
|
||||
for (let j = 0; j < 6; j++) ob.push(sr.readOneByte())
|
||||
let low32 = 0; for (let j = 0; j < 4; j++) low32 |= (ob[j] << (j * 8))
|
||||
let high16 = 0; for (let j = 4; j < 6; j++) high16 |= (ob[j] << ((j - 4) * 8))
|
||||
el.offset = (high16 * 0x100000000) + (low32 >>> 0)
|
||||
} else { serial.println(`Unknown addressing mode ${el.addressingMode}`); return null }
|
||||
cueElements.push(el)
|
||||
}
|
||||
let rc = sr.getReadCount()
|
||||
sr.skip(cueSize - rc + 1)
|
||||
currentFileIndex -= 1
|
||||
return tryReadNextTAVHeader()
|
||||
} catch (e) { serial.printerr(e); return null }
|
||||
}
|
||||
|
||||
function feedPredecodedPcm() {
|
||||
if (predecodedPcmBuffer !== null && predecodedPcmOffset < predecodedPcmSize) {
|
||||
let remaining = predecodedPcmSize - predecodedPcmOffset
|
||||
let uploadSize = Math.min(PCM_UPLOAD_CHUNK, remaining)
|
||||
sys.memcpy(predecodedPcmBuffer + predecodedPcmOffset, SND_BASE, uploadSize)
|
||||
audio.setSampleUploadLength(AUDIO_DEVICE, uploadSize)
|
||||
audio.startSampleUpload(AUDIO_DEVICE)
|
||||
predecodedPcmOffset += uploadSize
|
||||
}
|
||||
}
|
||||
|
||||
function startAsyncGop(d) {
|
||||
graphics.tavDecodeGopToVideoBufferAsync(
|
||||
d.compressedPtr, d.compressedSize, d.gopSize,
|
||||
width, decodeHeight, baseVersion >= 5, qualityLevel,
|
||||
QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout,
|
||||
waveletFilter, decompLevels, TAV_TEMPORAL_LEVELS, entropyCoder,
|
||||
d.slot * SLOT_SIZE, temporalMotionCoder, encoderPreset
|
||||
)
|
||||
asyncDecodeInProgress = true; asyncDecodeSlot = d.slot; asyncDecodeGopSize = d.gopSize
|
||||
asyncDecodePtr = d.compressedPtr; asyncDecodeStartTime = sys.nanoTime()
|
||||
}
|
||||
|
||||
// ── Decode one I/P video packet into CURRENT_RGB (or field buffer) ───────
|
||||
function decodeIPFrame(packetType, packetOffset) {
|
||||
updateScreenMask(frameCount)
|
||||
if (packetType === TAV_PACKET_IFRAME) iframePositions.push({ offset: packetOffset, frameNum: frameCount })
|
||||
const compressedSize = sr.readInt()
|
||||
let compressedPtr = sr.readBytes(compressedSize)
|
||||
updateDataRateBin(compressedSize)
|
||||
videoRate = compressedSize
|
||||
try {
|
||||
let decodeTarget = isInterlaced ? curField : CURRENT_RGB
|
||||
decoderDbgInfo = graphics.tavDecodeCompressed(
|
||||
compressedPtr, compressedSize, decodeTarget, PREV_RGB,
|
||||
width, decodeHeight, qualityLevel,
|
||||
QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout,
|
||||
trueFrameCount, waveletFilter, decompLevels, isLossless, version, entropyCoder, encoderPreset
|
||||
)
|
||||
if (isInterlaced) {
|
||||
graphics.tavDeinterlace(trueFrameCount, width, decodeHeight, prevField, curField, nextField, CURRENT_RGB, "yadif")
|
||||
rotateFields()
|
||||
}
|
||||
iframeReady = true
|
||||
} catch (e) { console.log(`TAV frame ${frameCount}: decode failed: ${e}`) }
|
||||
finally { sys.free(compressedPtr) }
|
||||
}
|
||||
|
||||
// ── GOP packet handling (Cases 1–5 + overflow) ──────────────────────────
|
||||
function handleGopPacket() {
|
||||
const gopSize = sr.readOneByte()
|
||||
const compressedSize = sr.readInt()
|
||||
let compressedPtr = sr.readBytes(compressedSize)
|
||||
updateDataRateBin(compressedSize / gopSize)
|
||||
decoderDbgInfo.frameMode = " "
|
||||
|
||||
if (gopSize > MAX_GOP_SIZE) { sys.free(compressedPtr); return }
|
||||
|
||||
if (currentGopSize === 0 && !asyncDecodeInProgress) {
|
||||
if (asyncDecodePtr !== 0) { sys.free(asyncDecodePtr); asyncDecodePtr = 0 }
|
||||
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: currentGopBufferSlot })
|
||||
}
|
||||
else if (currentGopSize === 0 && asyncDecodeInProgress) {
|
||||
if (readyGopData === null) {
|
||||
readyGopData = { gopSize, slot: (currentGopBufferSlot + 1) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 }
|
||||
} else if (decodingGopData === null) {
|
||||
decodingGopData = { gopSize, slot: (currentGopBufferSlot + 2) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 }
|
||||
shouldReadPackets = false
|
||||
} else { sys.free(compressedPtr) }
|
||||
}
|
||||
else if (currentGopSize > 0 && readyGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||
let nextSlot = (currentGopBufferSlot + 1) % BUFFER_SLOTS
|
||||
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: nextSlot })
|
||||
readyGopData = { gopSize, slot: nextSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||
shouldReadPackets = false
|
||||
}
|
||||
else if (currentGopSize > 0 && readyGopData !== null && decodingGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||
let decodingSlot = (currentGopBufferSlot + 2) % BUFFER_SLOTS
|
||||
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: decodingSlot })
|
||||
decodingGopData = { gopSize, slot: decodingSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||
shouldReadPackets = false
|
||||
}
|
||||
else {
|
||||
overflowQueue.push({ gopSize, compressedPtr, compressedSize })
|
||||
}
|
||||
}
|
||||
|
||||
// ── One packet ───────────────────────────────────────────────────────────
|
||||
// Returns true if a multi-file header switch happened (caller emits 'newfile').
|
||||
function readOnePacket() {
|
||||
let packetOffset = sr.getReadCount()
|
||||
let packetType = sr.readOneByte()
|
||||
let newfile = false
|
||||
|
||||
if (packetType == TAV_FILE_HEADER_FIRST) {
|
||||
let nh = tryReadNextTAVHeader()
|
||||
if (nh) {
|
||||
applyNewHeader(nh)
|
||||
frameCount = 0; akku = 0.0; akku2 = 0.0; firstFrameIssued = false
|
||||
baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
currentFileIndex++
|
||||
if (skipped) skipped = false; else currentCueIndex++
|
||||
packetType = sr.readOneByte()
|
||||
newfile = true
|
||||
} else { return { eof: true } }
|
||||
}
|
||||
|
||||
if (packetType === TAV_PACKET_SYNC || packetType == TAV_PACKET_SYNC_NTSC) {
|
||||
// vestigial in TAV's time-based model
|
||||
}
|
||||
else if (packetType === TAV_PACKET_IFRAME || packetType === TAV_PACKET_PFRAME) {
|
||||
decodeIPFrame(packetType, packetOffset)
|
||||
}
|
||||
else if (packetType === TAV_PACKET_GOP_UNIFIED) {
|
||||
handleGopPacket()
|
||||
}
|
||||
else if (packetType === TAV_PACKET_GOP_SYNC) {
|
||||
sr.readOneByte() // frames-in-GOP (ignored; time-based)
|
||||
if (currentGopSize > 0 && readyGopData !== null && decodingGopData !== null) shouldReadPackets = false
|
||||
}
|
||||
else if (packetType === TAV_PACKET_AUDIO_BUNDLED) {
|
||||
let totalAudioSize = sr.readInt()
|
||||
audioR.ensureMp2()
|
||||
let mp2Buffer = sys.malloc(totalAudioSize)
|
||||
sr.readBytes(totalAudioSize, mp2Buffer)
|
||||
const estimatedPcmSize = totalAudioSize * 12
|
||||
predecodedPcmBuffer = sys.malloc(estimatedPcmSize); predecodedPcmSize = 0; predecodedPcmOffset = 0
|
||||
const MP2_DECODE_CHUNK = 2304
|
||||
let srcOffset = 0
|
||||
while (srcOffset < totalAudioSize) {
|
||||
let chunkSize = Math.min(MP2_DECODE_CHUNK, totalAudioSize - srcOffset)
|
||||
sys.memcpy(mp2Buffer + srcOffset, SND_BASE - 2368, chunkSize)
|
||||
audio.mp2Decode()
|
||||
sys.memcpy(SND_BASE, predecodedPcmBuffer + predecodedPcmSize, 2304)
|
||||
predecodedPcmSize += 2304
|
||||
srcOffset += chunkSize
|
||||
}
|
||||
sys.free(mp2Buffer)
|
||||
}
|
||||
else if (packetType === TAV_PACKET_AUDIO_MP2) { let len = sr.readInt(); audioR.mp2(len) }
|
||||
else if (packetType === TAV_PACKET_AUDIO_TAD) { let sampleLen = sr.readShort(); let payloadLen = sr.readInt(); audioR.tad(sampleLen, payloadLen) }
|
||||
else if (packetType === TAV_PACKET_AUDIO_NATIVE) { let zstdLen = sr.readInt(); audioR.nativePcm(zstdLen) }
|
||||
else if (packetType === TAV_PACKET_SUBTITLE) { let size = sr.readInt(); subEngine.parseLegacy(size) }
|
||||
else if (packetType === TAV_PACKET_SUBTITLE_TC) { let size = sr.readInt(); subEngine.parseTC(size) }
|
||||
else if (packetType === TAV_PACKET_VIDEOTEX) {
|
||||
let compressedSize = sr.readInt()
|
||||
let compressedPtr = sr.readBytes(compressedSize)
|
||||
let decompressedPtr = sys.malloc(8192)
|
||||
gzip.decompFromTo(compressedPtr, compressedSize, decompressedPtr)
|
||||
let rows = sys.peek(decompressedPtr), cols = sys.peek(decompressedPtr + 1)
|
||||
let gridSize = rows * cols
|
||||
sys.memcpy(decompressedPtr + 2, -1302529, gridSize * 3)
|
||||
sys.free(compressedPtr); sys.free(decompressedPtr)
|
||||
iframeReady = true // displayed via the I/P path (uploads CURRENT_RGB under the text)
|
||||
}
|
||||
else if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||
let numPairs = sr.readShort()
|
||||
for (let i = 0; i < numPairs; i++) {
|
||||
let keyBytes = sr.readBytes(4); let key = ""
|
||||
for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(keyBytes + j))
|
||||
sys.free(keyBytes)
|
||||
let valueType = sr.readOneByte()
|
||||
if (valueType === 0x04) { sr.readInt(); sr.readInt() }
|
||||
else if (valueType === 0x10) {
|
||||
let length = sr.readShort(); let dataBytes = sr.readBytes(length); let dataStr = ""
|
||||
for (let j = 0; j < length; j++) dataStr += String.fromCharCode(sys.peek(dataBytes + j))
|
||||
sys.free(dataBytes)
|
||||
if (key === "XFPS" && parseXFPS(dataStr)) { frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps }
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (packetType === TAV_PACKET_SCREEN_MASK) {
|
||||
let frameNum = sr.readInt()
|
||||
let top = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||
let right = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||
let bottom = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||
let left = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||
screenMaskEntries.push({ frameNum, top, right, bottom, left })
|
||||
}
|
||||
else if (packetType === TAV_PACKET_TIMECODE) {
|
||||
let lo = sr.readInt(), hi = sr.readInt()
|
||||
let tc = hi * 0x100000000 + (lo >>> 0)
|
||||
baseTimecodeNs = tc; baseTimecodeFrameCount = frameCount; currentTimecodeNs = tc
|
||||
decoderDbgInfo.frameMode = BLIP
|
||||
}
|
||||
else if (packetType == 0x00) { /* stray arg-terminator byte */ }
|
||||
else { serial.println(`TAV unknown packet 0x${packetType.toString(16)}`); return { eof: true } }
|
||||
|
||||
return { newfile: newfile }
|
||||
}
|
||||
|
||||
// ── step(): one main-loop iteration ─────────────────────────────────────
|
||||
function step() {
|
||||
// TAP still: show the pre-decoded frame once, then idle.
|
||||
if (isTap) {
|
||||
if (!firstFrameIssued) { firstFrameIssued = true; pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: 0 }; materializeFrame(); return { type: 'frame', frameCount: 1 } }
|
||||
return { type: 'idle' }
|
||||
}
|
||||
|
||||
// EOF: stream exhausted and nothing buffered.
|
||||
if (sr.getReadCount() >= fileLength && currentGopSize === 0 && readyGopData === null && decodingGopData === null && !asyncDecodeInProgress && overflowQueue.length === 0) {
|
||||
return { type: 'eof' }
|
||||
}
|
||||
|
||||
let newfileEvent = false
|
||||
|
||||
// 1) Gated packet read.
|
||||
if (shouldReadPackets && !paused && sr.getReadCount() < fileLength) {
|
||||
let r = readOnePacket()
|
||||
if (r.eof) return { type: 'eof' }
|
||||
if (r.newfile) newfileEvent = true
|
||||
}
|
||||
|
||||
// Time accumulation (only while a GOP plays / after first frame).
|
||||
let t2 = sys.nanoTime()
|
||||
if (!paused && firstFrameIssued) {
|
||||
let dt = (t2 - lastT) / 1000000000.0
|
||||
if (currentGopSize > 0) akku += dt
|
||||
akku2 += dt
|
||||
}
|
||||
lastT = t2
|
||||
|
||||
let displayed = false
|
||||
|
||||
// Step 1: first-GOP decode wait.
|
||||
if (asyncDecodeInProgress && currentGopSize === 0) {
|
||||
if (!graphics.tavDecodeGopIsComplete()) { sys.sleep(1) }
|
||||
else {
|
||||
const res = graphics.tavDecodeGopGetResult(); decoderDbgInfo = res[1]
|
||||
currentGopSize = asyncDecodeGopSize; currentGopFrameIndex = 0; currentGopBufferSlot = asyncDecodeSlot
|
||||
asyncDecodeInProgress = false
|
||||
if (nextFrameTime === 0) nextFrameTime = sys.nanoTime()
|
||||
if (!(currentGopSize > 0 && readyGopData !== null && decodingGopData !== null)) shouldReadPackets = true
|
||||
sys.free(asyncDecodePtr); asyncDecodePtr = 0; asyncDecodeGopSize = 0
|
||||
if (readyGopData !== null && readyGopData.needsDecode) {
|
||||
startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = asyncDecodeStartTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2a: display I/P frame when due.
|
||||
if (!paused && iframeReady && currentGopSize === 0) {
|
||||
if (nextFrameTime === 0) nextFrameTime = sys.nanoTime()
|
||||
while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1)
|
||||
if (!paused) {
|
||||
pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: trueFrameCount }
|
||||
materializeFrame()
|
||||
audioR.fire()
|
||||
firstFrameIssued = true
|
||||
frameCount++; trueFrameCount++; iframeReady = false
|
||||
currentTimecodeNs = Math.floor(akku2 * 1000000000)
|
||||
if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs)
|
||||
let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t
|
||||
nextFrameTime += frametime
|
||||
displayed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2&3: display GOP frame when due.
|
||||
if (!paused && currentGopSize > 0 && currentGopFrameIndex < currentGopSize) {
|
||||
while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1)
|
||||
if (!paused) {
|
||||
if (isInterlaced) pending = { kind: 'gop-interlaced', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize }
|
||||
else pending = { kind: 'gop', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize }
|
||||
materializeFrame()
|
||||
audioR.fire()
|
||||
firstFrameIssued = true
|
||||
currentGopFrameIndex++; frameCount++; trueFrameCount++
|
||||
currentTimecodeNs = Math.floor(akku2 * 1000000000)
|
||||
if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs)
|
||||
feedPredecodedPcm()
|
||||
if (decodingGopData !== null && decodingGopData.needsDecode && graphics.tavDecodeGopIsComplete()) {
|
||||
startAsyncGop(decodingGopData); decodingGopData.needsDecode = false; decodingGopData.startTime = asyncDecodeStartTime
|
||||
}
|
||||
nextFrameTime += frametime
|
||||
displayed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4–7: GOP finished → transition to ready GOP (triple-buffer rotate).
|
||||
if (!paused && currentGopSize > 0 && currentGopFrameIndex >= currentGopSize) {
|
||||
if (readyGopData !== null) {
|
||||
if (readyGopData.needsDecode) { startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = sys.nanoTime() }
|
||||
while (!graphics.tavDecodeGopIsComplete() && !paused) sys.sleep(1)
|
||||
if (!paused) {
|
||||
graphics.tavDecodeGopGetResult()
|
||||
sys.free(readyGopData.compressedPtr)
|
||||
currentGopBufferSlot = readyGopData.slot; currentGopSize = readyGopData.gopSize; currentGopFrameIndex = 0
|
||||
readyGopData = decodingGopData; decodingGopData = null
|
||||
if (graphics.tavDecodeGopIsComplete()) { asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0 }
|
||||
shouldReadPackets = true
|
||||
// Drain overflow queue into a free slot.
|
||||
if (overflowQueue.length > 0 && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||
const ov = overflowQueue.shift()
|
||||
let targetSlot = (readyGopData === null) ? (currentGopBufferSlot + 1) % BUFFER_SLOTS
|
||||
: (decodingGopData === null) ? (currentGopBufferSlot + 2) % BUFFER_SLOTS : -1
|
||||
if (targetSlot < 0) overflowQueue.unshift(ov)
|
||||
else {
|
||||
startAsyncGop({ compressedPtr: ov.compressedPtr, compressedSize: ov.compressedSize, gopSize: ov.gopSize, slot: targetSlot })
|
||||
let rec = { gopSize: ov.gopSize, slot: targetSlot, compressedPtr: ov.compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||
if (readyGopData === null) readyGopData = rec; else decodingGopData = rec
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentGopSize = 0; currentGopFrameIndex = 0; shouldReadPackets = true
|
||||
}
|
||||
}
|
||||
|
||||
sys.sleep(1)
|
||||
|
||||
if (newfileEvent) return { type: 'newfile', frameCount: frameCount }
|
||||
return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' }
|
||||
}
|
||||
|
||||
// ── Materialise / present / sample ───────────────────────────────────────
|
||||
// Land the just-decoded frame in PRESENT_RGB (RGB888 RAM), whatever its source.
|
||||
// Called by step() the moment a frame becomes due, so blit() (upload) and the
|
||||
// ASCII sampler can both consume it from RAM and neither path has to read the
|
||||
// pixels back off the display planes.
|
||||
// rgb : I/P (or TAP still) — already RGB888 in CURRENT_RGB; copy in.
|
||||
// gop : progressive GOP frame in the Java-heap videoBuffer; copy out.
|
||||
// gop-interlaced : interlaced GOP fields; deinterlace into PRESENT_RGB.
|
||||
function materializeFrame() {
|
||||
if (pending.kind === 'rgb') {
|
||||
sys.memcpy(pending.src, PRESENT_RGB, FRAME_SIZE)
|
||||
} else if (pending.kind === 'gop') {
|
||||
graphics.tavCopyGopFrameToRGB(pending.frameIndex, width, height, pending.bufferOffset, PRESENT_RGB)
|
||||
} else if (pending.kind === 'gop-interlaced') {
|
||||
graphics.tavDeinterlaceGopFrameToRGB(pending.frameIndex, pending.gopSize, width, decodeHeight, height, pending.frameNo, pending.bufferOffset, prevField, curField, nextField, PRESENT_RGB)
|
||||
}
|
||||
}
|
||||
|
||||
// Present the materialised RAM frame to the display planes (with dithering).
|
||||
// bias lighting is a separate, player-driven stage (bias() below).
|
||||
function blit() {
|
||||
graphics.uploadRGBToFramebuffer(PRESENT_RGB, width, height, pending.frameNo, false)
|
||||
if (pending.kind === 'gop' || pending.kind === 'gop-interlaced') { updateScreenMask(frameCount); fillMaskedRegions() }
|
||||
}
|
||||
|
||||
// The current frame already sits in PRESENT_RGB (materialised in step()), so
|
||||
// sampling never touches the display planes — ASCII mode needs no blit().
|
||||
function sampleGray(dst, w, h) { common.sampleGrayRGB(PRESENT_RGB, width, height, dst, w, h) }
|
||||
function sampleColour(dst, w, h) { common.sampleColourRGB(PRESENT_RGB, width, height, dst, w, h) }
|
||||
|
||||
// ── TAP still: decode the single image now ──────────────────────────────
|
||||
if (isTap) {
|
||||
let packetType = sr.readOneByte()
|
||||
while (packetType !== TAV_PACKET_IFRAME && sr.getReadCount() < fileLength) {
|
||||
if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||
let numPairs = sr.readShort()
|
||||
for (let i = 0; i < numPairs; i++) {
|
||||
let kb = sr.readBytes(4); let key = ""; for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(kb + j)); sys.free(kb)
|
||||
let vt = sr.readOneByte()
|
||||
if (vt === 0x04) sr.skip(8)
|
||||
else if (vt === 0x10) { let len = sr.readShort(); let db = sr.readBytes(len); if (key === "XFPS") { let s = ""; for (let j = 0; j < len; j++) s += String.fromCharCode(sys.peek(db + j)); parseXFPS(s) } sys.free(db) }
|
||||
}
|
||||
} else if (packetType === TAV_PACKET_SCREEN_MASK) { sr.skip(12) }
|
||||
else if (packetType === TAV_PACKET_TIMECODE) { sr.skip(8) }
|
||||
else { let size = sr.readInt(); sr.skip(size) }
|
||||
packetType = sr.readOneByte()
|
||||
}
|
||||
if (packetType === TAV_PACKET_IFRAME) {
|
||||
const compressedSize = sr.readInt()
|
||||
const compressedPtr = sr.readBytes(compressedSize)
|
||||
graphics.tavDecodeCompressed(compressedPtr, compressedSize, CURRENT_RGB, PREV_RGB, width, height, qualityLevel, QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout, 0, waveletFilter, decompLevels, isLossless, version, entropyCoder, 2)
|
||||
sys.free(compressedPtr)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
info: info,
|
||||
subtitle: subEngine.subtitle,
|
||||
get frameCount() { return frameCount },
|
||||
get currentTimecodeNs() { return currentTimecodeNs },
|
||||
get akku() { return akku2 },
|
||||
get videoRate() { return getVideoRate() },
|
||||
get frameMode() { return decoderDbgInfo.frameMode || ' ' },
|
||||
get qY() { return decoderDbgInfo.qY }, get qCo() { return decoderDbgInfo.qCo }, get qCg() { return decoderDbgInfo.qCg },
|
||||
get cues() { return cueElements },
|
||||
get currentCueIndex() { return currentCueIndex },
|
||||
get currentFileIndex() { return currentFileIndex },
|
||||
|
||||
// Generic RAM frame: RGB888 buffer holding the current decoded frame,
|
||||
// valid after step() returns 'frame'. Callers may read it for their own use.
|
||||
get frameBuffer() { return PRESENT_RGB },
|
||||
get frameWidth() { return width },
|
||||
get frameHeight() { return height },
|
||||
|
||||
step: step,
|
||||
blit: blit,
|
||||
bias() { applyBias() },
|
||||
sampleGray: sampleGray,
|
||||
sampleColour: sampleColour,
|
||||
|
||||
pause(p) {
|
||||
paused = p
|
||||
if (p) audioR.stop()
|
||||
else { audioR.resume(); lastT = sys.nanoTime() }
|
||||
},
|
||||
isPaused() { return paused },
|
||||
setVolume(v) { audioR.setVolume(v) },
|
||||
getVolume() { return audioR.getVolume() },
|
||||
|
||||
seekSeconds(n) {
|
||||
if (isTap) return
|
||||
let target
|
||||
if (n < 0) target = Math.max(0, frameCount - Math.floor(fps * (-n)))
|
||||
else target = Math.min(totalFrames - 1, frameCount + Math.floor(fps * n))
|
||||
let seekTarget = findNearestIframe(target)
|
||||
if (n > 0 && (!seekTarget || seekTarget.frameNum <= frameCount)) seekTarget = scanForwardToIframe(target)
|
||||
if (!seekTarget) return
|
||||
if (n > 0 && seekTarget.frameNum <= frameCount) return
|
||||
cleanupAsyncDecode()
|
||||
sr.seek(seekTarget.offset)
|
||||
frameCount = seekTarget.frameNum; akku = FRAME_TIME; akku2 += n; firstFrameIssued = false
|
||||
baseTimecodeNs = Math.floor(seekTarget.frameNum * frametime); baseTimecodeFrameCount = seekTarget.frameNum; currentTimecodeNs = baseTimecodeNs
|
||||
subEngine.resetTo(baseTimecodeNs)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
skipped = true
|
||||
},
|
||||
|
||||
cue(d) {
|
||||
if (cueElements.length === 0) return
|
||||
currentCueIndex = (d < 0)
|
||||
? ((currentCueIndex <= 0) ? cueElements.length - 1 : currentCueIndex - 1)
|
||||
: ((currentCueIndex >= cueElements.length - 1) ? 0 : currentCueIndex + 1)
|
||||
let cue = cueElements[currentCueIndex]
|
||||
if (cue.addressingMode !== ADDRESSING_INTERNAL) return
|
||||
cleanupAsyncDecode()
|
||||
sr.seek(cue.offset)
|
||||
frameCount = 0; akku = FRAME_TIME; akku2 = 0.0; firstFrameIssued = false
|
||||
baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0
|
||||
subEngine.resetTo(0)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
skipped = true
|
||||
},
|
||||
|
||||
close() {
|
||||
cleanupAsyncDecode()
|
||||
sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B); sys.free(PRESENT_RGB)
|
||||
if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) }
|
||||
while (overflowQueue.length > 0) { const ov = overflowQueue.shift(); sys.free(ov.compressedPtr) }
|
||||
audioR.close()
|
||||
sys.poke(-1299460, 20); sys.poke(-1299460, 21) // reset font ROM
|
||||
graphics.resetPalette()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports = { create }
|
||||
233
assets/disk0/tvdos/include/mediadec_tev.mjs
Normal file
233
assets/disk0/tvdos/include/mediadec_tev.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* mediadec_tev.mjs — TEV (TSVM Enhanced Video) backend for the mediadec library.
|
||||
*
|
||||
* Ported from assets/disk0/tvdos/bin/playtev.js. DCT codec, YCoCg-R / ICtCp,
|
||||
* motion compensation, optional deblock / boundary-aware decoding, interlaced
|
||||
* (yadif/bwdif) support, NTSC frame duplication, MP2 audio, SSF + SSF-TC
|
||||
* subtitles. Decodes into an off-screen RGB888 ping-pong buffer (the generic
|
||||
* RAM frame): blit() uploads it to the adapter, while the ASCII path samples it
|
||||
* straight from RAM, and `frameBuffer` exposes it for arbitrary reuse.
|
||||
*/
|
||||
|
||||
const TEV_VERSION_YCOCG = 2
|
||||
const TEV_VERSION_ICtCp = 3
|
||||
|
||||
const TEV_PACKET_IFRAME = 0x10
|
||||
const TEV_PACKET_PFRAME = 0x11
|
||||
const TEV_PACKET_AUDIO_MP2 = 0x20
|
||||
const TEV_PACKET_SUBTITLE = 0x30
|
||||
const TEV_PACKET_SUBTITLE_TC = 0x31
|
||||
const TEV_PACKET_SYNC = 0xFF
|
||||
|
||||
function create(magic, sr, fileLength, opts, common) {
|
||||
const audioR = common.makeAudioRouter(sr)
|
||||
const subEngine = common.makeSubtitleEngine(sr, -1300607) // TEV font-ROM base
|
||||
|
||||
// Header
|
||||
let version = sr.readOneByte()
|
||||
if (version !== TEV_VERSION_YCOCG && version !== TEV_VERSION_ICtCp) {
|
||||
throw Error(`Unsupported TEV version: ${version}`)
|
||||
}
|
||||
let width = sr.readShort()
|
||||
let height = sr.readShort()
|
||||
let fps = sr.readOneByte()
|
||||
let totalFrames = sr.readInt()
|
||||
let qualityY = sr.readOneByte()
|
||||
let qualityCo = sr.readOneByte()
|
||||
let qualityCg = sr.readOneByte()
|
||||
let flags = sr.readOneByte()
|
||||
let videoFlags = sr.readOneByte()
|
||||
sr.readOneByte() // unused
|
||||
const hasAudio = !!(flags & 1)
|
||||
const hasSubtitle = !!(flags & 2)
|
||||
const isInterlaced = !!(videoFlags & 1)
|
||||
const isNTSC = !!(videoFlags & 2)
|
||||
const colorSpace = (version === TEV_VERSION_ICtCp) ? "ICtCp" : "YCoCg"
|
||||
|
||||
// Options
|
||||
const debugMV = !!opts.debugMotionVectors
|
||||
const enableDeblock = !!opts.enableDeblocking
|
||||
const enableBoundaryAware = !!opts.enableBoundaryAwareDecoding
|
||||
const deinterlaceAlgo = opts.deinterlaceAlgorithm || "yadif"
|
||||
|
||||
graphics.setGraphicsMode(4)
|
||||
graphics.clearPixels(0)
|
||||
graphics.clearPixels2(0)
|
||||
// NB: palette 0 is translucent black by default (used by the playgui chrome);
|
||||
// we deliberately do NOT redefine it, nor reset it on close.
|
||||
|
||||
const FRAME_PIXELS = width * height
|
||||
const FRAME_SIZE = 560 * 448 * 3
|
||||
const FIELD_SIZE = 560 * 224 * 3
|
||||
|
||||
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
|
||||
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
|
||||
sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3)
|
||||
sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3)
|
||||
let CURRENT_RGB = RGB_BUFFER_A
|
||||
let PREV_RGB = RGB_BUFFER_B
|
||||
|
||||
const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||
const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||
const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||
if (isInterlaced) {
|
||||
sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE)
|
||||
}
|
||||
let curField = CURR_FIELD, prevField = PREV_FIELD, nextField = NEXT_FIELD
|
||||
|
||||
sys.memset(common.DISP_RG, 0, FRAME_PIXELS)
|
||||
sys.memset(common.DISP_BA, 15, FRAME_PIXELS)
|
||||
|
||||
const FRAME_TIME = 1.0 / fps
|
||||
const FRAME_TIME_NS = 1000000000.0 / fps
|
||||
const applyBias = common.makeBias(width, height, 4)
|
||||
|
||||
const info = {
|
||||
format: 'tev', width: width, height: height, fps: fps,
|
||||
totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitle,
|
||||
isInterlaced: isInterlaced, colourSpace: colorSpace, graphicsMode: 4, isStill: false
|
||||
}
|
||||
|
||||
let akku = FRAME_TIME
|
||||
let lastT = sys.nanoTime()
|
||||
let frameCount = 0
|
||||
let trueFrameCount = 0
|
||||
let frameDuped = false
|
||||
let paused = false
|
||||
let currentFrameType = "I"
|
||||
let videoRate = 0
|
||||
let currentFrameSrc = CURRENT_RGB
|
||||
|
||||
const blockDataPtr = sys.malloc(FRAME_SIZE)
|
||||
|
||||
function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t }
|
||||
|
||||
function decodeVideo(packetType) {
|
||||
let payloadLen = sr.readInt()
|
||||
videoRate = payloadLen
|
||||
let compressedPtr = sr.readBytes(payloadLen)
|
||||
currentFrameType = (packetType == TEV_PACKET_IFRAME) ? "I" : "P"
|
||||
|
||||
// NTSC frame duplication: drop one decode every 1000 frames (≈29.97).
|
||||
if (isNTSC && frameCount % 1000 == 501 && !frameDuped) {
|
||||
frameDuped = true
|
||||
sys.free(compressedPtr)
|
||||
return false // keep previous frame on screen
|
||||
}
|
||||
frameDuped = false
|
||||
|
||||
let actualSize
|
||||
try { actualSize = gzip.decompFromTo(compressedPtr, payloadLen, blockDataPtr) }
|
||||
catch (e) { sys.free(compressedPtr); serial.println(`TEV frame ${frameCount}: gzip failed: ${e}`); return false }
|
||||
|
||||
let decodingHeight = isInterlaced ? (height / 2) | 0 : height
|
||||
if (isInterlaced) {
|
||||
graphics.tevDecode(blockDataPtr, nextField, curField, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware)
|
||||
graphics.tevDeinterlace(trueFrameCount, width, decodingHeight, prevField, curField, nextField, CURRENT_RGB, deinterlaceAlgo)
|
||||
rotateFields()
|
||||
} else {
|
||||
graphics.tevDecode(blockDataPtr, CURRENT_RGB, PREV_RGB, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware)
|
||||
}
|
||||
currentFrameSrc = CURRENT_RGB
|
||||
sys.free(compressedPtr)
|
||||
return true
|
||||
}
|
||||
|
||||
function step() {
|
||||
const now = sys.nanoTime()
|
||||
if (paused) { lastT = now; return { type: 'idle' } }
|
||||
akku += (now - lastT) / 1000000000.0
|
||||
lastT = now
|
||||
|
||||
if (sr.getReadCount() >= fileLength) return { type: 'eof' }
|
||||
if (akku < FRAME_TIME) return { type: 'idle' }
|
||||
|
||||
let packetType = sr.readOneByte()
|
||||
|
||||
if (packetType == TEV_PACKET_SYNC) {
|
||||
akku -= FRAME_TIME
|
||||
frameCount++
|
||||
trueFrameCount++
|
||||
// Swap ping-pong: the just-shown frame becomes the reference.
|
||||
let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t
|
||||
return { type: 'idle' }
|
||||
}
|
||||
else if (packetType == TEV_PACKET_IFRAME || packetType == TEV_PACKET_PFRAME) {
|
||||
let shown = decodeVideo(packetType)
|
||||
if (shown) {
|
||||
// audio after frame 0 (progressive) / frame 1 (interlaced)
|
||||
if (!isInterlaced || frameCount > 0) audioR.fire()
|
||||
if (subEngine.hasEvents()) subEngine.poll(frameCount * FRAME_TIME_NS)
|
||||
return { type: 'frame', frameCount: frameCount }
|
||||
}
|
||||
return { type: 'idle' }
|
||||
}
|
||||
else if (packetType == TEV_PACKET_AUDIO_MP2) {
|
||||
let audioLen = sr.readInt()
|
||||
audioR.mp2(audioLen)
|
||||
return { type: 'idle' }
|
||||
}
|
||||
else if (packetType == TEV_PACKET_SUBTITLE) {
|
||||
let size = sr.readInt(); subEngine.parseLegacy(size); return { type: 'idle' }
|
||||
}
|
||||
else if (packetType == TEV_PACKET_SUBTITLE_TC) {
|
||||
let size = sr.readInt(); subEngine.parseTC(size); return { type: 'idle' }
|
||||
}
|
||||
else if (packetType == 0x00) {
|
||||
return { type: 'idle' } // stray arg-terminator byte
|
||||
}
|
||||
else {
|
||||
serial.println(`TEV unknown packet type 0x${packetType.toString(16)}`)
|
||||
return { type: 'eof' }
|
||||
}
|
||||
}
|
||||
|
||||
// Present the decoded RAM frame to the display planes (with dithering).
|
||||
// bias lighting is a separate, player-driven stage (bias() below).
|
||||
function blit() {
|
||||
graphics.uploadRGBToFramebuffer(currentFrameSrc, width, height, frameCount, false)
|
||||
}
|
||||
|
||||
// The decoded frame already sits in currentFrameSrc (RGB888 RAM), so sampling
|
||||
// reads RAM directly — ASCII mode needs no blit() / display-plane round-trip.
|
||||
function sampleGray(dst, w, h) { common.sampleGrayRGB(currentFrameSrc, width, height, dst, w, h) }
|
||||
function sampleColour(dst, w, h) { common.sampleColourRGB(currentFrameSrc, width, height, dst, w, h) }
|
||||
|
||||
return {
|
||||
info: info,
|
||||
subtitle: subEngine.subtitle,
|
||||
get frameCount() { return frameCount },
|
||||
get currentTimecodeNs() { return Math.floor(frameCount * FRAME_TIME_NS) },
|
||||
get videoRate() { return videoRate * fps },
|
||||
get frameMode() { return currentFrameType },
|
||||
get qY() { return qualityY }, get qCo() { return qualityCo }, get qCg() { return qualityCg },
|
||||
cues: [],
|
||||
|
||||
// Generic RAM frame: the current decoded frame as RGB888 (the live
|
||||
// ping-pong buffer), valid after step() returns 'frame'. Callers may read it.
|
||||
get frameBuffer() { return currentFrameSrc },
|
||||
get frameWidth() { return width },
|
||||
get frameHeight() { return height },
|
||||
|
||||
step: step,
|
||||
blit: blit,
|
||||
bias() { applyBias() },
|
||||
sampleGray: sampleGray,
|
||||
sampleColour: sampleColour,
|
||||
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
||||
isPaused() { return paused },
|
||||
setVolume(v) { audioR.setVolume(v) },
|
||||
getVolume() { return audioR.getVolume() },
|
||||
seekSeconds(_n) { /* TEV has no index; seeking unsupported */ },
|
||||
cue(_d) {},
|
||||
|
||||
close() {
|
||||
sys.free(blockDataPtr)
|
||||
sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B)
|
||||
if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) }
|
||||
audioR.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports = { create }
|
||||
@@ -319,7 +319,7 @@ const AG_VIS_H = AG_ROW_VIS_BOT - AG_ROW_VIS_TOP + 1 // 24
|
||||
const AG_VIS_W = AG_LANE_W // 78
|
||||
|
||||
// Palette (TSVM 256-colour indices)
|
||||
const AG_COL_BG = 0
|
||||
const AG_COL_BG = 240
|
||||
const AG_COL_BORDER = 250
|
||||
const AG_COL_LABEL = 220
|
||||
const AG_COL_DIM = 235
|
||||
@@ -1228,6 +1228,7 @@ function audioInit(params) {
|
||||
|
||||
con.curs_set(0)
|
||||
con.clear()
|
||||
graphics.setBackground(0,0,0)
|
||||
ag_drawFrame()
|
||||
ag_drawTitle()
|
||||
}
|
||||
|
||||
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
@@ -0,0 +1,581 @@
|
||||
/*
|
||||
* synopsis.mjs -- TVDOS Synopsis Format (TSF) loader, cache and completion
|
||||
* resolver.
|
||||
*
|
||||
* A TSF document (see the "Command Synopsis Format" chapter of the manual and
|
||||
* tvdos_synopsis_format_draft.md) is a JSON file describing a command's
|
||||
* command-line interface: its options, positional arguments, subcommands,
|
||||
* argument types, completion sources and validation constraints. This module
|
||||
* turns those documents into the answers command.js needs while the user is
|
||||
* typing -- chiefly "what can come next at the caret?".
|
||||
*
|
||||
* Where the documents live
|
||||
* ------------------------
|
||||
* * Apps : colocated with the executable, full filename + ".synopsis"
|
||||
* e.g. \tvdos\bin\geturl.js -> \tvdos\bin\geturl.js.synopsis
|
||||
* * Built-in : the shell coreutils are not files, so their synopses live
|
||||
* coreutils in a dedicated directory, \tvdos\synopsis\<name>.synopsis.
|
||||
* Aliases (ls -> dir, rm -> del, ...) resolve to the
|
||||
* canonical command's file automatically.
|
||||
*
|
||||
* Caching (two layers)
|
||||
* --------------------
|
||||
* Parsing JSON and compiling a completion model on every TAB would be wasteful,
|
||||
* so results are cached:
|
||||
* 1. In memory, for the life of the shell session (command.js keeps the
|
||||
* require() handle, so this object persists across keystrokes).
|
||||
* 2. On disk, under \tvdos\cache\synopsis\, as a compiled-model blob. The
|
||||
* TSVM file layer exposes no reliable modification time, so the cache is
|
||||
* validated against the source file's *byte size* plus a CACHE_VERSION
|
||||
* stamp. A source edit that preserves the byte count will not invalidate
|
||||
* the disk cache -- an accepted trade-off. Every disk operation is
|
||||
* best-effort: a failure never breaks completion, it just falls back to
|
||||
* re-parsing.
|
||||
*
|
||||
* Public API
|
||||
* ----------
|
||||
* getCompletion(commandToken, prefixTokens, word) -> result | { ok:false }
|
||||
* getModel(commandToken) -> compiled model | null
|
||||
* getSummary(commandToken) -> one-line summary | null
|
||||
* getUsage(commandToken) -> generated usage string | null
|
||||
* resolveSynopsisPath(commandToken) -> full path | null
|
||||
* registerProvider(name, fn) -> register an `internal` completion source
|
||||
* clearCache() -> drop the in-memory caches
|
||||
*/
|
||||
|
||||
const TSF_VERSION = "1.0"
|
||||
const CACHE_VERSION = 1 // bump when compile()'s output shape changes
|
||||
const SYN_DIR = "\\tvdos\\synopsis" // built-in / coreutil synopses
|
||||
const CACHE_PARENT = "\\tvdos\\cache"
|
||||
const CACHE_DIR = "\\tvdos\\cache\\synopsis" // compiled-model disk cache
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// small local helpers (deliberately mirror command.js internals)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function drive() { return (typeof _G !== "undefined" && _G.shell) ? _G.shell.getCurrentDrive() : "A" }
|
||||
|
||||
function trimStartRevSlash(s) {
|
||||
let cnt = 0
|
||||
while (cnt < s.length && s[cnt] === '\\') cnt += 1
|
||||
return s.substring(cnt)
|
||||
}
|
||||
|
||||
function isValidDriveLetter(l) {
|
||||
if (typeof l === 'string' || l instanceof String) {
|
||||
let lc = l.charCodeAt(0)
|
||||
return (l == '$' || 65 <= lc && lc <= 90 || 97 <= lc && lc <= 122)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function fileExists(p) { try { return files.open(p).exists } catch (e) { return false } }
|
||||
function fileSize(p) { try { return files.open(p).size | 0 } catch (e) { return 0 } }
|
||||
function readText(p) { try { let f = files.open(p); return f.exists ? f.sread() : null } catch (e) { return null } }
|
||||
|
||||
let _cacheDirReady = false
|
||||
function ensureCacheDir() {
|
||||
if (_cacheDirReady) return
|
||||
let d = drive()
|
||||
let segs = [CACHE_PARENT, CACHE_DIR]
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
try { let f = files.open(`${d}:${segs[i]}`); if (!f.exists) f.mkDir() } catch (e) { /* best-effort */ }
|
||||
}
|
||||
_cacheDirReady = true
|
||||
}
|
||||
function writeText(p, s) {
|
||||
try { ensureCacheDir(); files.open(p).swrite(s); return true } catch (e) { return false }
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// executable + synopsis-path resolution
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Find the runnable file a bare command name would resolve to, mirroring the
|
||||
// search order command.js uses (current directory, then PATH, with PATHEXT).
|
||||
function findExecutable(cmd) {
|
||||
let d = drive()
|
||||
if (isValidDriveLetter(cmd[0]) && cmd[1] === ':') {
|
||||
try { let f = files.open(cmd); return f.exists ? f.fullPath : null } catch (e) { return null }
|
||||
}
|
||||
let pwd = (typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwd() : [""]
|
||||
let searchDir = (cmd.charAt(0) === '/') ? [""] : ["/" + pwd.join("/")].concat(_TVDOS.getPath())
|
||||
let pathExt = []
|
||||
if (cmd.split(".")[1] === undefined) {
|
||||
(_TVDOS.variables.PATHEXT || "").split(';').forEach(function (it) {
|
||||
if (it.length) { pathExt.push(it); pathExt.push(it.toUpperCase()) }
|
||||
})
|
||||
} else {
|
||||
pathExt.push("")
|
||||
}
|
||||
for (let i = 0; i < searchDir.length; i++) {
|
||||
for (let j = 0; j < pathExt.length; j++) {
|
||||
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||
let sp = trimStartRevSlash(search + cmd + pathExt[j])
|
||||
try { let f = files.open(`${d}:\\${sp}`); if (f.exists) return f.fullPath } catch (e) { /* keep looking */ }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve a command token to the full path of its .synopsis document, or null.
|
||||
function resolveSynopsisPath(token) {
|
||||
if (!token) return null
|
||||
let d = drive()
|
||||
let lower = token.toLowerCase()
|
||||
|
||||
// built-in coreutil? -> \tvdos\synopsis\<name>.synopsis
|
||||
// try the typed name first, then any alias that shares the same function so
|
||||
// `ls` finds dir.synopsis without a duplicate file.
|
||||
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils &&
|
||||
typeof _G.shell.coreutils[lower] === 'function') {
|
||||
let fn = _G.shell.coreutils[lower]
|
||||
let names = [lower]
|
||||
Object.keys(_G.shell.coreutils).forEach(function (k) {
|
||||
if (_G.shell.coreutils[k] === fn && names.indexOf(k) < 0) names.push(k)
|
||||
})
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
let p = `${d}:${SYN_DIR}\\${names[i]}.synopsis`
|
||||
if (fileExists(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// app -> <executable>.synopsis colocated with the program
|
||||
let exe = findExecutable(token)
|
||||
if (!exe) return null
|
||||
let p = exe + ".synopsis"
|
||||
return fileExists(p) ? p : null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// TSF compilation -- raw document -> completion model
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function compile(doc) {
|
||||
if (!doc || typeof doc !== 'object') return null
|
||||
let symbols = doc.symbols || {}
|
||||
|
||||
// ---- options: every symbol of kind "option" is an offerable flag ----
|
||||
let flags = [] // one entry per option symbol
|
||||
let flagMap = {} // flag string ("-r", "--recursive", "--no-recursive") -> entry
|
||||
Object.keys(symbols).forEach(function (id) {
|
||||
let s = symbols[id]
|
||||
if (!s || s.kind !== 'option') return
|
||||
let value = s.value || null
|
||||
let hasValue = !!value
|
||||
let entry = {
|
||||
id: id,
|
||||
long: s.long || null,
|
||||
short: s.short || null,
|
||||
summary: s.summary || '',
|
||||
negatable: !!s.negatable,
|
||||
hasValue: hasValue,
|
||||
valueRequired: hasValue ? (value.required !== false) : false,
|
||||
value: value
|
||||
}
|
||||
flags.push(entry)
|
||||
if (entry.long) flagMap[entry.long] = entry
|
||||
if (entry.short) flagMap[entry.short] = entry
|
||||
if (entry.negatable && entry.long) flagMap['--no-' + entry.long.replace(/^--/, '')] = entry
|
||||
})
|
||||
|
||||
// ---- positionals + subcommands, in grammar order ----
|
||||
let positionals = []
|
||||
let subcommands = []
|
||||
let seenSub = {}
|
||||
function walk(node, inRepeat) {
|
||||
if (!node || typeof node !== 'object') return
|
||||
switch (node.type) {
|
||||
case 'sequence':
|
||||
case 'choice':
|
||||
(node.children || []).forEach(function (c) { walk(c, inRepeat) }); break
|
||||
case 'optional': walk(node.child, inRepeat); break
|
||||
case 'repeat': walk(node.child, true); break
|
||||
case 'oneOrMore': walk(node.child, true); break
|
||||
case 'reference': {
|
||||
let sym = symbols[node.symbol]
|
||||
if (!sym) return
|
||||
if (sym.kind === 'positional') {
|
||||
positionals.push({
|
||||
id: node.symbol,
|
||||
name: sym.name || node.symbol,
|
||||
type: sym.type || 'string',
|
||||
values: sym.values || null,
|
||||
completion: sym.completion || null,
|
||||
summary: sym.summary || '',
|
||||
repeatable: !!inRepeat
|
||||
})
|
||||
} else if (sym.kind === 'subcommand') {
|
||||
if (!seenSub[node.symbol]) {
|
||||
seenSub[node.symbol] = true
|
||||
subcommands.push({ name: sym.name || node.symbol, summary: sym.summary || '', tsf: sym.tsf || null })
|
||||
}
|
||||
}
|
||||
break // option / group references add no positional ordering
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
walk(doc.synopsis, false)
|
||||
|
||||
return {
|
||||
cacheVersion: CACHE_VERSION,
|
||||
tsfVersion: doc.tsfVersion || null,
|
||||
name: doc.name || null,
|
||||
summary: doc.summary || '',
|
||||
description: doc.description || '',
|
||||
symbols: symbols,
|
||||
synopsisNode: doc.synopsis || null,
|
||||
flags: flags,
|
||||
flagMap: flagMap,
|
||||
positionals: positionals,
|
||||
subcommands: subcommands,
|
||||
constraints: doc.constraints || []
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// loading + caching
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let _mem = {} // synopsisPath -> { srcSize, model }
|
||||
let _resolveMemo = {} // "drive|pwd|token" -> synopsisPath | null
|
||||
|
||||
function cacheKey(p) {
|
||||
// FNV-1a 32-bit hash, prefixed with a sanitised basename for readability.
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < p.length; i++) { h ^= p.charCodeAt(i); h = (h * 16777619) >>> 0 }
|
||||
let base = (p.split(/[\\/]/).pop() || 'syn').replace(/[^A-Za-z0-9._-]/g, '_')
|
||||
return base + '_' + ('00000000' + h.toString(16)).slice(-8)
|
||||
}
|
||||
function cachePath(synPath) { return `${drive()}:${CACHE_DIR}\\${cacheKey(synPath)}.json` }
|
||||
|
||||
function loadModel(synPath) {
|
||||
if (!synPath) return null
|
||||
let srcSize = fileSize(synPath)
|
||||
|
||||
// 1. in-memory
|
||||
let mem = _mem[synPath]
|
||||
if (mem && mem.srcSize === srcSize) return mem.model
|
||||
|
||||
// 2. disk cache (size + version validated)
|
||||
let cachedText = readText(cachePath(synPath))
|
||||
if (cachedText) {
|
||||
try {
|
||||
let c = JSON.parse(cachedText)
|
||||
if (c && c.cacheVersion === CACHE_VERSION && c.srcSize === srcSize && c.model) {
|
||||
_mem[synPath] = { srcSize: srcSize, model: c.model }
|
||||
return c.model
|
||||
}
|
||||
} catch (e) { /* corrupt cache -> re-parse */ }
|
||||
}
|
||||
|
||||
// 3. parse the source
|
||||
let src = readText(synPath)
|
||||
if (src === null) return null
|
||||
let doc
|
||||
try { doc = JSON.parse(src) }
|
||||
catch (e) { try { serial.printerr("synopsis: bad JSON in " + synPath + ": " + e) } catch (_) {} ; return null }
|
||||
let model = compile(doc)
|
||||
if (!model) return null
|
||||
|
||||
_mem[synPath] = { srcSize: srcSize, model: model }
|
||||
writeText(cachePath(synPath), JSON.stringify({ cacheVersion: CACHE_VERSION, srcSize: srcSize, model: model }))
|
||||
return model
|
||||
}
|
||||
|
||||
function getModel(token) {
|
||||
if (!token) return null
|
||||
let key = drive() + '|' + ((typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwdString() : '') + '|' + token
|
||||
let synPath
|
||||
if (Object.prototype.hasOwnProperty.call(_resolveMemo, key)) synPath = _resolveMemo[key]
|
||||
else { synPath = resolveSynopsisPath(token); _resolveMemo[key] = synPath }
|
||||
return synPath ? loadModel(synPath) : null
|
||||
}
|
||||
|
||||
function clearCache() { _mem = {}; _resolveMemo = {}; _cacheDirReady = false }
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// internal completion providers (for `"completion": { "method": "internal" }`)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let _providers = {}
|
||||
function registerProvider(name, fn) { _providers[name] = fn }
|
||||
function safeProvider(name, word, model) {
|
||||
let fn = _providers[name]
|
||||
if (!fn) return []
|
||||
try { return fn(word, model) || [] } catch (e) { return [] }
|
||||
}
|
||||
|
||||
// "commands" -- runnable command names (coreutils + PATH executables).
|
||||
registerProvider('commands', function (word) {
|
||||
word = (word || '').toLowerCase()
|
||||
let out = [], seen = {}
|
||||
function add(n) { let k = n.toLowerCase(); if (seen[k]) return; seen[k] = true; out.push(n) }
|
||||
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils)
|
||||
Object.keys(_G.shell.coreutils).forEach(function (k) { if (k.toLowerCase().indexOf(word) === 0) add(k) })
|
||||
try {
|
||||
let d = drive()
|
||||
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||
.filter(function (e) { return e.length }).map(function (e) { return e.toLowerCase() })
|
||||
_TVDOS.getPath().forEach(function (dir) {
|
||||
let full = (dir === '') ? `${d}:\\` : `${d}:${dir.charAt(0) === '\\' ? dir : '\\' + dir}`
|
||||
try {
|
||||
let f = files.open(full); if (!f.exists || !f.isDirectory) return
|
||||
;(f.list() || []).forEach(function (it) {
|
||||
if (it.isDirectory) return
|
||||
let nl = (it.name || '').toLowerCase()
|
||||
if (!exts.some(function (e) { return nl.endsWith(e) })) return
|
||||
let nm = it.name
|
||||
exts.forEach(function (e) { if (nm.toLowerCase().endsWith(e)) nm = nm.substring(0, nm.length - e.length) })
|
||||
if (nm.toLowerCase().indexOf(word) === 0) add(nm)
|
||||
})
|
||||
} catch (e) { /* skip unreadable dir */ }
|
||||
})
|
||||
} catch (e) { /* ignore */ }
|
||||
return out
|
||||
})
|
||||
|
||||
// "envvars" -- environment variable names.
|
||||
registerProvider('envvars', function (word) {
|
||||
word = word || ''
|
||||
try {
|
||||
return Object.keys(_TVDOS.variables || {}).filter(function (k) {
|
||||
return k.toLowerCase().indexOf(word.toLowerCase()) === 0
|
||||
})
|
||||
} catch (e) { return [] }
|
||||
})
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// completion query
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Turn a `values` array (bare values or { value, summary } objects) into
|
||||
// completion candidates whose value matches `word` as a prefix.
|
||||
function valuesToCandidates(values, word) {
|
||||
if (!values) return []
|
||||
word = word || ''
|
||||
let out = []
|
||||
values.forEach(function (v) {
|
||||
let val, sum
|
||||
if (v && typeof v === 'object' && ('value' in v)) { val = '' + v.value; sum = v.summary || '' }
|
||||
else { val = '' + v; sum = '' }
|
||||
if (val.indexOf(word) === 0) out.push({ label: val, value: val + ' ', summary: sum, isDir: false })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Candidates implied by an argument descriptor (a positional, or an option's
|
||||
// `value`). Returns { candidates, filesystem } where `filesystem` is false or
|
||||
// one of 'path' | 'file' | 'directory' -- a request that the caller ALSO offer
|
||||
// matching filesystem entries.
|
||||
function descriptorCandidates(desc, word, model) {
|
||||
word = word || ''
|
||||
let none = { candidates: [], filesystem: false }
|
||||
if (!desc) return none
|
||||
|
||||
let method = (desc.completion && desc.completion.method) || (desc.type === 'enum' ? 'enum' : null)
|
||||
|
||||
// explicit completion block
|
||||
if (method === 'none') return none
|
||||
if (method === 'enum') return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||
if (method === 'list') {
|
||||
let items = (desc.completion && (desc.completion.items || desc.completion.values)) || desc.values || []
|
||||
return { candidates: valuesToCandidates(items, word), filesystem: false }
|
||||
}
|
||||
if (method === 'internal') {
|
||||
let prov = desc.completion && desc.completion.provider
|
||||
return { candidates: valuesToCandidates(safeProvider(prov, word, model), word), filesystem: false }
|
||||
}
|
||||
// method 'command' (run a program for candidates) is intentionally not
|
||||
// executed here -- side-effect / latency safety -- so it falls through to
|
||||
// the type defaults below.
|
||||
|
||||
// no completion block (or unhandled method): default behaviour by type
|
||||
switch (desc.type) {
|
||||
case 'path': return { candidates: [], filesystem: 'path' }
|
||||
case 'file': return { candidates: [], filesystem: 'file' }
|
||||
case 'directory': return { candidates: [], filesystem: 'directory' }
|
||||
case 'boolean': return { candidates: valuesToCandidates(['true', 'false'], word), filesystem: false }
|
||||
case 'command': return { candidates: valuesToCandidates(safeProvider('commands', word, model), word), filesystem: false }
|
||||
case 'enum': return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||
case 'user': if (_providers['users']) return { candidates: valuesToCandidates(safeProvider('users', word, model), word), filesystem: false }; break
|
||||
case 'group': if (_providers['groups']) return { candidates: valuesToCandidates(safeProvider('groups', word, model), word), filesystem: false }; break
|
||||
default: break
|
||||
}
|
||||
// string / integer / float / url / hostname / unknown: a soft `values`
|
||||
// list may still help; otherwise there is nothing to offer.
|
||||
if (desc.values) return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||
return none
|
||||
}
|
||||
|
||||
// Every textual form a flag may be typed as (long, short, and the --no- form).
|
||||
function flagForms(entry) {
|
||||
let forms = []
|
||||
if (entry.long) forms.push(entry.long)
|
||||
if (entry.short) forms.push(entry.short)
|
||||
if (entry.negatable && entry.long) forms.push('--no-' + entry.long.replace(/^--/, ''))
|
||||
return forms
|
||||
}
|
||||
|
||||
// Count how many positional arguments `tokens` (the args already typed before
|
||||
// the caret) have consumed, skipping option flags and the values they take.
|
||||
function countPositionals(tokens, model) {
|
||||
let n = 0, skip = false
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
let t = tokens[i]
|
||||
if (skip) { skip = false; continue } // this token was an option's value
|
||||
if (t.length > 0 && t.charAt(0) === '-') {
|
||||
if (t.indexOf('=') >= 0) continue // inline value -- no following value token
|
||||
let e = model.flagMap[t]
|
||||
if (e && e.hasValue && e.valueRequired) skip = true
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function finalise(r) { return { ok: true, candidates: r.candidates, filesystem: r.filesystem } }
|
||||
|
||||
/*
|
||||
* Main entry point used by command.js.
|
||||
*
|
||||
* commandToken : the command (first word on the line)
|
||||
* prefixTokens : the argument tokens already typed, in order, EXCLUDING the
|
||||
* word currently under the caret
|
||||
* word : the partial word under the caret (may be "")
|
||||
*
|
||||
* Returns { ok:false } when there is no synopsis for the command (the caller
|
||||
* should fall back to its own default completion). Otherwise returns
|
||||
* { ok:true, candidates:[{label,value,summary,isDir}], filesystem:<flag> }
|
||||
* where `filesystem` (false | 'path' | 'file' | 'directory') asks the caller to
|
||||
* additionally offer matching filesystem entries.
|
||||
*/
|
||||
function getCompletion(commandToken, prefixTokens, word) {
|
||||
let model = getModel(commandToken)
|
||||
if (!model) return { ok: false }
|
||||
word = word || ''
|
||||
prefixTokens = prefixTokens || []
|
||||
|
||||
// (1) the caret is on an option flag
|
||||
if (word.length > 0 && word.charAt(0) === '-') {
|
||||
// inline value form: --flag=partial
|
||||
if (word.indexOf('--') === 0 && word.indexOf('=') >= 0) {
|
||||
let eq = word.indexOf('=')
|
||||
let flagPart = word.substring(0, eq)
|
||||
let valPart = word.substring(eq + 1)
|
||||
let entry = model.flagMap[flagPart]
|
||||
if (entry && entry.hasValue) {
|
||||
let r = descriptorCandidates(entry.value, valPart, model)
|
||||
r.candidates = r.candidates.map(function (c) {
|
||||
return { label: c.label, value: flagPart + '=' + c.value.replace(/ $/, '') + ' ', summary: c.summary, isDir: false }
|
||||
})
|
||||
return { ok: true, candidates: r.candidates, filesystem: false }
|
||||
}
|
||||
return { ok: true, candidates: [], filesystem: false }
|
||||
}
|
||||
// list flags matching the prefix
|
||||
let out = []
|
||||
model.flags.forEach(function (e) {
|
||||
flagForms(e).forEach(function (f) {
|
||||
if (f.indexOf(word) === 0) out.push({ label: f, value: f + ' ', summary: e.summary, isDir: false })
|
||||
})
|
||||
})
|
||||
return { ok: true, candidates: out, filesystem: false }
|
||||
}
|
||||
|
||||
// (2) the caret is on the value of the immediately preceding option
|
||||
let prev = prefixTokens.length > 0 ? prefixTokens[prefixTokens.length - 1] : null
|
||||
if (prev && prev.charAt(0) === '-' && prev.indexOf('=') < 0) {
|
||||
let entry = model.flagMap[prev]
|
||||
if (entry && entry.hasValue && entry.valueRequired)
|
||||
return finalise(descriptorCandidates(entry.value, word, model))
|
||||
}
|
||||
|
||||
// (3) a positional argument (or a subcommand in the first slot)
|
||||
let posIndex = countPositionals(prefixTokens, model)
|
||||
if (posIndex === 0 && model.subcommands.length > 0) {
|
||||
let out = model.subcommands
|
||||
.filter(function (s) { return s.name.indexOf(word) === 0 })
|
||||
.map(function (s) { return { label: s.name, value: s.name + ' ', summary: s.summary, isDir: false } })
|
||||
return { ok: true, candidates: out, filesystem: false }
|
||||
}
|
||||
let desc = null
|
||||
if (model.positionals.length > 0) {
|
||||
if (posIndex < model.positionals.length) desc = model.positionals[posIndex]
|
||||
else {
|
||||
let last = model.positionals[model.positionals.length - 1]
|
||||
if (last && last.repeatable) desc = last
|
||||
}
|
||||
}
|
||||
// No descriptor for this slot -> let the caller use its default completion.
|
||||
if (!desc) return { ok: false }
|
||||
return finalise(descriptorCandidates(desc, word, model))
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// generated help (per the spec, usage text is derived output, not normative)
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
function grammarToText(node, symbols) {
|
||||
if (!node || typeof node !== 'object') return ''
|
||||
switch (node.type) {
|
||||
case 'sequence':
|
||||
return (node.children || []).map(function (c) { return grammarToText(c, symbols) })
|
||||
.filter(function (s) { return s.length }).join(' ')
|
||||
case 'choice':
|
||||
return '(' + (node.children || []).map(function (c) { return grammarToText(c, symbols) }).join(' | ') + ')'
|
||||
case 'optional':
|
||||
return '[' + grammarToText(node.child, symbols) + ']'
|
||||
case 'repeat': {
|
||||
// a repeat over a group is the familiar [OPTION...] slot
|
||||
let child = node.child
|
||||
if (child && child.type === 'reference' && symbols[child.symbol] && symbols[child.symbol].kind === 'group')
|
||||
return '[' + grammarToText(child, symbols) + '...]'
|
||||
return grammarToText(child, symbols) + '...'
|
||||
}
|
||||
case 'oneOrMore': {
|
||||
let t = grammarToText(node.child, symbols)
|
||||
return t + ' [' + t + '...]'
|
||||
}
|
||||
case 'reference': {
|
||||
let s = symbols[node.symbol]
|
||||
if (!s) return node.symbol
|
||||
if (s.kind === 'group') return 'OPTION'
|
||||
if (s.kind === 'option') return s.long || s.short || node.symbol
|
||||
if (s.kind === 'subcommand') return s.name || node.symbol
|
||||
if (s.kind === 'positional') return s.name || node.symbol
|
||||
return node.symbol
|
||||
}
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function getUsage(token) {
|
||||
let m = getModel(token)
|
||||
if (!m) return null
|
||||
let body = grammarToText(m.synopsisNode, m.symbols)
|
||||
return ((m.name || token) + (body ? ' ' + body : '')).trim()
|
||||
}
|
||||
|
||||
function getSummary(token) {
|
||||
let m = getModel(token)
|
||||
return m ? (m.summary || '') : null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Module exports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
exports = {
|
||||
getCompletion,
|
||||
getModel,
|
||||
getSummary,
|
||||
getUsage,
|
||||
resolveSynopsisPath,
|
||||
registerProvider,
|
||||
clearCache,
|
||||
TSF_VERSION,
|
||||
}
|
||||
@@ -169,7 +169,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
if ((sys.peek(filePtr + projOff + i) & 0xFF) !== projMagic[i]) { prjOk = false; break }
|
||||
}
|
||||
if (prjOk) {
|
||||
const PATCH_SIZE = 31
|
||||
// Patches are VARIABLE LENGTH (since 2026-06-13): a version byte (feature
|
||||
// bit-flags 0b x00Pfpvi) + 30 common bytes, then optional x/v/p/f/P blocks.
|
||||
const patchLen = (ver) => 31
|
||||
+ ((ver & 0x80) ? 15 : 0) // x: extra-base-info (u32 flags1 + u32 flags2 + u16 fadeout + u16 cutoff + u16 reson + u8 initialAttenuation octet)
|
||||
+ ((ver & 0x02) ? 54 : 0) // v: volume envelope
|
||||
+ ((ver & 0x04) ? 54 : 0) // p: panning envelope
|
||||
+ ((ver & 0x08) ? 54 : 0) // f: filter envelope
|
||||
+ ((ver & 0x10) ? 54 : 0) // P: pitch envelope
|
||||
let p = projOff + 16 // skip magic(8) + reserved(8)
|
||||
while (p + 8 <= fileSize) {
|
||||
const fc = String.fromCharCode(
|
||||
@@ -179,7 +186,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
const payload = p + 8
|
||||
if (payload + secLen > fileSize) break
|
||||
if (fc === 'Ixmp') {
|
||||
// Each entry: Uint8 instId + Uint24 patchCount + (patchCount × PATCH_SIZE) bytes.
|
||||
// Each entry: Uint8 instId + Uint24 patchCount + variable-length patches.
|
||||
let q = payload
|
||||
const qEnd = payload + secLen
|
||||
while (q + 4 <= qEnd) {
|
||||
@@ -188,8 +195,15 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
const cntMid = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const cntHi = sys.peek(filePtr + q) & 0xFF; q++
|
||||
const patchCnt = cntLo | (cntMid << 8) | (cntHi << 16)
|
||||
const blobLen = patchCnt * PATCH_SIZE
|
||||
if (q + blobLen > qEnd) break
|
||||
// Walk the patches to find the blob length (each depends on its version byte).
|
||||
let blobLen = 0, scan = q, ok = true
|
||||
for (let i = 0; i < patchCnt; i++) {
|
||||
if (scan + 31 > qEnd) { ok = false; break }
|
||||
const len = patchLen(sys.peek(filePtr + scan) & 0xFF)
|
||||
if (scan + len > qEnd) { ok = false; break }
|
||||
scan += len; blobLen += len
|
||||
}
|
||||
if (!ok) break
|
||||
let buf = new Array(blobLen)
|
||||
for (let k = 0; k < blobLen; k++) buf[k] = sys.peek(filePtr + q + k) & 0xFF
|
||||
audio.uploadInstrumentPatches(instId, buf)
|
||||
@@ -291,6 +305,35 @@ function captureTrackerDataToFile(outFile) {
|
||||
// Layout: header(32) + compressed(compressedSize) + songTable(1 × TAUD_SONG_ENTRY)
|
||||
let songOffset = TAUD_HEADER_SIZE + compressedSize + 1 * TAUD_SONG_ENTRY
|
||||
|
||||
// -- 6.5 Build Ixmp project-data block (preserves multi-sample instruments)
|
||||
// Without this, saving a song whose instruments carry Ixmp patches (IT/XM
|
||||
// keyboard tables, SF2 imports) would silently collapse every instrument to
|
||||
// its base sample on the next load. Section format per terranmon.txt
|
||||
// §"Project Data" / §"Ixmp": magic(8) + reserved(8) + FourCC + Uint32 len +
|
||||
// repetition of { Uint8 instId, Uint24 count, count × variable-length patches }.
|
||||
let ixmpPayload = []
|
||||
for (let s = 0; s < 256; s++) {
|
||||
const cnt = audio.getInstrumentPatchCount(s)
|
||||
if (cnt <= 0) continue
|
||||
const blob = audio.getInstrumentPatches(s) // flat variable-length patch bytes
|
||||
ixmpPayload.push(s & 0xFF, cnt & 0xFF, (cnt >>> 8) & 0xFF, (cnt >>> 16) & 0xFF)
|
||||
for (let k = 0; k < blob.length; k++) ixmpPayload.push(blob[k] & 0xFF)
|
||||
}
|
||||
let projData = []
|
||||
let projOff = 0
|
||||
if (ixmpPayload.length > 0) {
|
||||
projData = [
|
||||
0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A, // \x1ETaudPrJ
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved
|
||||
0x49, 0x78, 0x6D, 0x70, // 'Ixmp'
|
||||
(ixmpPayload.length ) & 0xFF,
|
||||
(ixmpPayload.length >>> 8) & 0xFF,
|
||||
(ixmpPayload.length >>> 16) & 0xFF,
|
||||
(ixmpPayload.length >>> 24) & 0xFF,
|
||||
].concat(ixmpPayload)
|
||||
projOff = songOffset + patCompSize + cueCompSize
|
||||
}
|
||||
|
||||
// -- 7. Build header byte array (32 bytes) --------------------------------
|
||||
let sigBytes = new Array(14)
|
||||
for (let i = 0; i < 14; i++)
|
||||
@@ -306,8 +349,11 @@ function captureTrackerDataToFile(outFile) {
|
||||
(compressedSize >>> 8) & 0xFF,
|
||||
(compressedSize >>> 16) & 0xFF,
|
||||
(compressedSize >>> 24) & 0xFF,
|
||||
// project data offset (4) -- not emitted
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
// project data offset (4) -- zero when no Ixmp/etc. to carry
|
||||
(projOff ) & 0xFF,
|
||||
(projOff >>> 8) & 0xFF,
|
||||
(projOff >>> 16) & 0xFF,
|
||||
(projOff >>> 24) & 0xFF,
|
||||
].concat(sigBytes) // 8 + 2 + 4 + 4 + 14 = 32 bytes
|
||||
|
||||
// -- 8. Build song-table row (32 bytes) -----------------------------------
|
||||
@@ -360,6 +406,13 @@ function captureTrackerDataToFile(outFile) {
|
||||
TAUD_HEADER_SIZE + compressedSize + songTable.length + patCompSize)
|
||||
sys.free(cueCompBuf)
|
||||
|
||||
// -- 14. Append project data (Ixmp) at projOff ----------------------------
|
||||
if (projData.length > 0) {
|
||||
let projBuf = sys.malloc(projData.length)
|
||||
for (let k = 0; k < projData.length; k++) sys.poke(projBuf + k, projData[k])
|
||||
fileHandle.pwrite(projBuf, projData.length, projOff)
|
||||
sys.free(projBuf)
|
||||
}
|
||||
|
||||
fileHandle.flush(); fileHandle.close()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Markup
|
||||
* ------
|
||||
* <b>...</b> emphasised foreground colour
|
||||
* <b>...</b> de-emphasised foreground colour
|
||||
* <c>...</c> centre-align this source line
|
||||
* <r>...</r> right-align this source line
|
||||
* <l>...</l> left-align this source line
|
||||
@@ -43,12 +44,14 @@
|
||||
|
||||
const COL_TEXT = 239 // popup body default (== colWHITE)
|
||||
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||
const COL_DEEMPH = 248 // <s>...</s> unhighlight
|
||||
const COL_BRAND = 211 // first half of "Microtone"
|
||||
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||
|
||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||
const ESC_EMPH = fgEsc(COL_EMPH)
|
||||
const ESC_DEEMPH = fgEsc(COL_DEEMPH)
|
||||
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||
|
||||
|
||||
@@ -67,7 +70,7 @@ function expandEntities(s) {
|
||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||
.replaceAll(' ', '\u007F')
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('­', '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
@@ -102,7 +105,7 @@ function expandEntities(s) {
|
||||
// Width accounting:
|
||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
|
||||
// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word)
|
||||
// - non-breaking space (' ') : 1 visible char (consumed as part of a word)
|
||||
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
|
||||
// - everything else : 1 visible char
|
||||
function tokenise(line) {
|
||||
@@ -123,6 +126,8 @@ function tokenise(line) {
|
||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
||||
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||
if (line.slice(i, i + 3) === '<s>') { buf += ESC_DEEMPH; i += 3; continue }
|
||||
if (line.slice(i, i + 4) === '</s>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||
@@ -327,5 +332,6 @@ exports = {
|
||||
COL_BRAND_DIM,
|
||||
ESC_DEFAULT,
|
||||
ESC_EMPH,
|
||||
ESC_DEEMPH,
|
||||
MICROTONE,
|
||||
}
|
||||
|
||||
@@ -49,28 +49,22 @@ class WindowObject {
|
||||
let tt = ''+this.title
|
||||
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`)
|
||||
print(`\x84${charset[6]}u`)
|
||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[7]}u`)
|
||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||
}
|
||||
if (this.titleLeft !== undefined) {
|
||||
let tt = ''+this.titleLeft
|
||||
con.move(this.y, this.x)
|
||||
print(`\x84${charset[0]}u`)
|
||||
con.move(this.y, this.x + 1)
|
||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
||||
print(`\x1B[38;5;${colourText}m`);print(tt)
|
||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||
print(`\x1B[38;5;${colour}m`);print(`\x84${charset[4]}u`)
|
||||
}
|
||||
if (this.titleRight !== undefined) {
|
||||
let tt = ''+this.titleRight
|
||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2)
|
||||
print(`\x84${charset[4]}u`)
|
||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 1)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +211,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 +308,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 +538,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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cat",
|
||||
"summary": "Print a file, or pipe its contents onward",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to read; reads from the pipe when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cd",
|
||||
"summary": "Change the current working directory",
|
||||
"symbols": {
|
||||
"dir": { "kind": "positional", "type": "directory", "name": "DIR", "summary": "Directory to change into; prints the current directory when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "dir" }
|
||||
}
|
||||
}
|
||||
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "chvt",
|
||||
"summary": "Switch to virtual console N (1-6)",
|
||||
"symbols": {
|
||||
"console": {
|
||||
"kind": "positional",
|
||||
"type": "enum",
|
||||
"name": "N",
|
||||
"summary": "Target virtual console",
|
||||
"values": [
|
||||
{ "value": "1", "summary": "Virtual console 1" },
|
||||
{ "value": "2", "summary": "Virtual console 2" },
|
||||
{ "value": "3", "summary": "Virtual console 3" },
|
||||
{ "value": "4", "summary": "Virtual console 4" },
|
||||
{ "value": "5", "summary": "Virtual console 5" },
|
||||
{ "value": "6", "summary": "Virtual console 6" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "console" }
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cls",
|
||||
"summary": "Clear the screen",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cp",
|
||||
"summary": "Copy a file",
|
||||
"symbols": {
|
||||
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to copy from" },
|
||||
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "source" },
|
||||
{ "type": "reference", "symbol": "dest" }
|
||||
]
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "date",
|
||||
"summary": "Print the system date and time",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
9
assets/disk0/tvdos/synopsis/del.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/del.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "del",
|
||||
"summary": "Delete a file",
|
||||
"symbols": {
|
||||
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to delete" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "file" }
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/dir.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/dir.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "dir",
|
||||
"summary": "List the contents of a directory",
|
||||
"symbols": {
|
||||
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Directory to list; the working directory when omitted" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "path" }
|
||||
}
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/echo.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/echo.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "echo",
|
||||
"summary": "Print text, expanding $VARIABLE references",
|
||||
"symbols": {
|
||||
"text": { "kind": "positional", "type": "string", "name": "TEXT", "summary": "Text to print", "completion": { "method": "none" } }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "repeat",
|
||||
"child": { "type": "reference", "symbol": "text" }
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/exit.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/exit.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "exit",
|
||||
"summary": "Exit the command processor",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
9
assets/disk0/tvdos/synopsis/mkdir.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/mkdir.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "mkdir",
|
||||
"summary": "Create a directory",
|
||||
"symbols": {
|
||||
"dir": { "kind": "positional", "type": "path", "name": "DIR", "summary": "Directory to create" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "dir" }
|
||||
}
|
||||
16
assets/disk0/tvdos/synopsis/mv.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/mv.synopsis
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "mv",
|
||||
"summary": "Move or rename a file",
|
||||
"symbols": {
|
||||
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to move" },
|
||||
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "reference", "symbol": "source" },
|
||||
{ "type": "reference", "symbol": "dest" }
|
||||
]
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/panic.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/panic.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "panic",
|
||||
"summary": "Deliberately raise an error (diagnostic aid)",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
12
assets/disk0/tvdos/synopsis/rem.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/rem.synopsis
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "rem",
|
||||
"summary": "A comment; the line is ignored",
|
||||
"symbols": {
|
||||
"text": { "kind": "positional", "type": "string", "name": "TEXT", "summary": "Comment text", "completion": { "method": "none" } }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "repeat",
|
||||
"child": { "type": "reference", "symbol": "text" }
|
||||
}
|
||||
}
|
||||
18
assets/disk0/tvdos/synopsis/set.synopsis
Normal file
18
assets/disk0/tvdos/synopsis/set.synopsis
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "set",
|
||||
"summary": "Set or display an environment variable",
|
||||
"symbols": {
|
||||
"assignment": {
|
||||
"kind": "positional",
|
||||
"type": "string",
|
||||
"name": "NAME=VALUE",
|
||||
"summary": "Variable assignment, or a name to display; lists all variables when omitted",
|
||||
"completion": { "method": "internal", "provider": "envvars" }
|
||||
}
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "optional",
|
||||
"child": { "type": "reference", "symbol": "assignment" }
|
||||
}
|
||||
}
|
||||
7
assets/disk0/tvdos/synopsis/ver.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/ver.synopsis
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "ver",
|
||||
"summary": "Print the operating system version",
|
||||
"symbols": {},
|
||||
"synopsis": { "type": "sequence", "children": [] }
|
||||
}
|
||||
9
assets/disk0/tvdos/synopsis/which.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/which.synopsis
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "which",
|
||||
"summary": "Report how a command name resolves",
|
||||
"symbols": {
|
||||
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command name to resolve" }
|
||||
},
|
||||
"synopsis": { "type": "reference", "symbol": "program" }
|
||||
}
|
||||
BIN
assets/disk0/tvdos/tsvm.chr
Normal file
BIN
assets/disk0/tvdos/tsvm.chr
Normal file
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
\begin{itemlist}
|
||||
\item Song, Minjae. 2021. ``Terran BASIC Reference Manual for Language Version 1.2, Third Edition''.
|
||||
\item Bradner, S. 1997. ``Key words for use in RFCs to Indicate Requirement Levels.'' RFC 2119. \url{https://www.rfc-editor.org/rfc/rfc2119}.
|
||||
\item Leiba, B. 2017. ``Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words.'' RFC 8174. \url{https://www.rfc-editor.org/rfc/rfc8174}.
|
||||
\item Wikipedia. ``List of DOS commands.'' Updated 2022-08-29 15:00. \url{https://en.wikipedia.org/wiki/List_of_DOS_commands}.
|
||||
\item Wikipedia. ``Pipeline (software).'' Updated 2022-07-17 06:21. \url{https://en.wikipedia.org/wiki/Pipeline_(software)}.
|
||||
\end{itemlist}
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
\section{Specs}
|
||||
|
||||
\begin{outline}
|
||||
\1 16 MB memory space with maximum 8 MB of scratchpad memory
|
||||
\1 16 MB memory space with maximum 8 MB of core memory
|
||||
\1 7 peripheral card slots, each can map 1 MB of memory to the memory space
|
||||
\1 Standard graphics adapter on slot 1, with 256 simultaneous colours, 560\times448 pixels framebuffer and 80-column 32-row text buffer
|
||||
\1 Built-in mouse input support
|
||||
\1 4 serial ports to connect disk drives, modems and other computers
|
||||
\end{outline}
|
||||
|
||||
There are three memories on the system: Hardware Memory (8 MB), Scratchpad Memory (up to 8 MB) and Program Memory (infinite!)
|
||||
There are three memories on the system: Hardware Memory (8 MB), Core Memory (up to 8 MB) and Program Memory (infinite!)
|
||||
|
||||
Your Javascript program is stored into the Program Memory, and since its capacity is limitless, you can put a large graphics directly into your Javascript source code, but the Program Memory is the slowest of all three memories. For faster graphics, you need to store them onto the Scratchpad Memory then DMA-Copy them to the graphics adapter.
|
||||
Your Javascript program is stored into the Program Memory, and since its capacity is limitless, you can put a large graphics directly into your Javascript source code, but the Program Memory is the slowest of all three memories. For faster graphics, you need to store them onto the Core Memory then DMA-Copy them to the graphics adapter.
|
||||
|
||||
\section{Javascript Extensions}
|
||||
|
||||
@@ -36,7 +36,29 @@ Your Javascript program is stored into the Program Memory, and since its capacit
|
||||
|
||||
\chapter{Libraries}
|
||||
|
||||
\thismachine\ runs your program on an embedded ECMAScript engine. The language is standard Javascript; what makes a \thismachine\ program a \thismachine\ program is the set of \emph{host namespaces} the engine exposes --- global objects, available without any import, through which the program talks to the hardware.
|
||||
|
||||
\section{Namespaces}
|
||||
|
||||
\index{namespaces}The following namespaces are always present, whether or not an operating system is loaded:
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{sys}{low-level system and memory access (\code{peek}, \code{poke}, \code{malloc}, timing, input).}
|
||||
\1\inlinesynopsis{graphics}{the graphics adapter: pixels, palette, graphics modes, image decoding.}
|
||||
\1\inlinesynopsis{audio}{the sound card: PCM playback and the tracker engine.}
|
||||
\1\inlinesynopsis{com}{block (``serial'') communication with attached devices.}
|
||||
\1\inlinesynopsis{dma}{bulk memory-to-memory and memory-to-device transfers.}
|
||||
\1\inlinesynopsis{gzip}{compression and decompression (see the note on the name below).}
|
||||
\1\inlinesynopsis{base64}{Base64 encoding and decoding.}
|
||||
\1\inlinesynopsis{serial}{a debug text channel to the host; output appears on the host's console, not on the \thismachine\ screen.}
|
||||
\1\inlinesynopsis{con}{screen text manipulation, built on top of the above.}
|
||||
\end{outline}
|
||||
|
||||
The plain \code{print}, \code{println}, \code{printerr}, \code{printerrln} and \code{read} functions are also global.
|
||||
|
||||
A second set of namespaces --- \code{files}, \code{input}, \code{unicode}, \code{GL}, \code{require}, \code{exports} and the \code{\_G.shell} family --- is provided by \thedos\ and is only present once the operating system has booted. Those are documented in the \thedos\ part of this book.
|
||||
|
||||
\textbf{A note on \code{gzip}:} despite the name, the \code{gzip} namespace compresses and decompresses using the \emph{Zstandard} format. The decompressor recognises both Zstandard and the older gzip streams, so older files keep working. The name is historical.
|
||||
|
||||
\section{Standard Input and Output}
|
||||
|
||||
@@ -96,6 +118,8 @@ Functions:
|
||||
\1\formalsynopsis{color\_fore}{code: Int}{Defines the foreground colour of the text. 0 -- black, 1 -- red, 2 -- green, 3 -- yellow, 4 -- blue, 5 -- magenta, 6 -- cyan, 7 -- white, -1 -- transparent}
|
||||
\1\formalsynopsis{color\_back}{code: Int}{Defines the background colour of the text.}
|
||||
\1\formalsynopsis{color\_pair}{fore: Int, back: Int}{Defines the foreground and background colour of the text. Colour code for this function differs from the \code{color\_back} and \code{color\_fore}; please refer to the \ref{colourpalette}.}
|
||||
\1\formalsynopsis{get\_color\_fore}{}[Int]{Returns the current text foreground colour (palette index).}
|
||||
\1\formalsynopsis{get\_color\_back}{}[Int]{Returns the current text background colour (palette index).}
|
||||
\1\formalsynopsis{clear}{}{Clears the text buffer. The framebuffer (if any) will not be affected.}
|
||||
\1\formalsynopsis{reset\_graphics}{}{Resets foreground and background colour to defaults and makes the cursor visible if it was hidden.}
|
||||
\end{outline}
|
||||
@@ -146,7 +170,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{poke}{address: Int, value: Int}{Puts a value into the memory of the specified address.}
|
||||
\1\formalsynopsis{peek}{address: Int}[Int]{Reads a value from the memory of the specified address.}
|
||||
\1\formalsynopsis{malloc}{size: Int}[Int]{Allocates a space of the given size on the Scratchpad memory and returns its pointer (starting address)}
|
||||
\1\formalsynopsis{malloc}{size: Int}[Int]{Allocates a space of the given size on the Core memory and returns its pointer (starting address)}
|
||||
\1\formalsynopsis{free}{pointer: Int}{Frees the memory space previously \code{malloc}'d}
|
||||
\1\formalsynopsis{memcpy}{from: Int, to: Int, length: Int}{Copies the memory block of the given length. From and To are pointers.}
|
||||
\1\formalsynopsis{mapRom}{romSlotNum: Int}{Maps the contents on the given ROM to the memory address {-\nobreak65537.\nobreak.-\nobreak131072}}
|
||||
@@ -163,7 +187,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
||||
\1\formalsynopsis{waitForMemChg}{address: Int, andMask: Int, xorMask: Int}[]{Do nothing until a memory value is changed. More specifically, this function will \code{spin} while:$$ (\mathrm{peek}(addr)\ \mathrm{xor}\ xorMask)\ \mathrm{and}\ andMask = 0 $$}
|
||||
\1\formalsynopsis{getSysRq}{}[Boolean]{Returns true if System Request key is down.}
|
||||
\1\formalsynopsis{unsetSysRq}{}{After using the System Request key, call this function to `release' the key so that other programs can use it.}
|
||||
\1\formalsynopsis{maxmem}{}[Int]{returns the size of the Scratchpad Memory in bytes.}
|
||||
\1\formalsynopsis{maxmem}{}[Int]{returns the size of the Core Memory in bytes.}
|
||||
\1\formalsynopsis{getUsedMem}{}[Int]{Returns how many memories can be \code{malloc}'d.}
|
||||
\1\formalsynopsis{getMallocStatus}{}[IntArray(2)]{Returns the \code{malloc} status in following order:$$ [\mathrm{Malloc\ unit\ size,\ allocated\ block\ counts}] $$}
|
||||
\end{outline}
|
||||
@@ -172,6 +196,37 @@ Sys library allows programmers to manipulate the system in low-level.
|
||||
|
||||
|
||||
|
||||
\section{DMA}
|
||||
|
||||
\index{dma (library)}The \thismachine\ can copy blocks of memory directly, without the program shuffling individual bytes. This is far faster than a \code{peek}/\code{poke} loop and is the usual way of moving graphics into the framebuffer.
|
||||
|
||||
\namespaceis{DMA}{dma}
|
||||
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{ramToRam}{from: Int, to: Int, length: Int}{Copies a block of memory from one address to another.}
|
||||
\1\formalsynopsis{ramToFrame}{from: Int, to: Int, length: Int}{Copies a block of memory into the framebuffer of the first graphics adapter.}
|
||||
\1\formalsynopsis{ramToFrame}{from: Int, devnum: Int, offset: Int, length: Int}{Copies a block of memory into the framebuffer of the graphics adapter in the given slot, starting at the given offset.}
|
||||
\1\formalsynopsis{frameToRam}{from: Int, to: Int, length: Int}{Copies a block of framebuffer memory back into ordinary memory.}
|
||||
\1\formalsynopsis{frameToRam}{from: Int, to: Int, devnum: Int, length: Int}{As above, sourcing the framebuffer of the graphics adapter in the given slot.}
|
||||
\1\formalsynopsis{comToRam}{portNo: Int, srcOff: Int, destOff: Int, length: Int}{Copies bytes from a serial port's receive block into memory.}
|
||||
\1\formalsynopsis{ramToCom}{srcOff: Int, portNo: Int, length: Int}{Copies bytes from memory into a serial port's send block.}
|
||||
\1\formalsynopsis{strToRam}{str: String, to: Int, srcOff: Int, length: Int}{Writes the characters of a Javascript string into memory as bytes.}
|
||||
\end{outline}
|
||||
|
||||
|
||||
\section{Serial Debugger}
|
||||
|
||||
\index{serial (library)}The \code{serial} namespace prints to a debug channel that appears on the \emph{host} machine's console, never on the \thismachine\ screen. It is invaluable for tracing a program without disturbing what the user sees.
|
||||
|
||||
\namespaceis{Serial}{serial}
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{print}[Anything]{prints a value to the host debug console.}
|
||||
\1\inlinesynopsis{println}[Anything]{prints a value followed by a new line to the host debug console.}
|
||||
\end{outline}
|
||||
|
||||
|
||||
|
||||
\chapter{Serial Communication}
|
||||
|
||||
Some peripherals such as disk drives are connected through the ``Serial Communication''.
|
||||
@@ -328,7 +383,13 @@ The com-port will behave differently if you're writing to or reading from the ad
|
||||
|
||||
\section{Keyboard}
|
||||
|
||||
TODO
|
||||
\index{keyboard}The keyboard is part of the main hardware (the slot-zero IO device) and is read through MMIO. It offers two distinct views of the same physical keyboard, and a program chooses whichever suits it.
|
||||
|
||||
\textbf{The input stream (TTY view).} For ordinary line- and character-oriented input, the program opens the text input stream by writing a non-zero value to MMIO \code{-39}. Keystrokes are then translated to characters and pushed onto the \emph{keyboard input buffer}; the head of that buffer is read from MMIO \code{-38}, and reading it shifts the buffer along. This is the mechanism behind \code{con.getch}, \code{read} and the \code{sys.read}/\code{sys.readKey} family. Opening the stream clears the buffer, so it must be opened exactly once.
|
||||
|
||||
\textbf{The raw view.} For interactive programs that need to know which keys are physically held down --- games, editors --- the program latches the current input by writing to MMIO \code{-40} and then reads the \emph{list of pressed keys} from \code{-41..-48}. This buffer holds up to eight simultaneous keys (``8-key rollover''), sorted, with zero filling the unused slots. \code{con.poll\_keys} exposes this directly. Latching freezes the values so that a multi-key read is consistent.
|
||||
|
||||
The codes in the raw view are the engine keycodes listed below; the codes in the stream view are character codes (with a handful of control values, e.g.\ \code{3} for Ctrl-C, \code{8} for Backspace, \code{13} for Return, and \code{19}--\code{22} for the arrow keys). \thedos\ builds its event-driven \code{input} library (see the \thedos\ part) on top of the raw view.
|
||||
|
||||
\subsection{Keycodes}
|
||||
|
||||
@@ -451,7 +512,14 @@ F12 & 142 \\
|
||||
|
||||
\section{Mouse}
|
||||
|
||||
TODO
|
||||
\index{mouse}The mouse is read through the same slot-zero IO device. Its position and button state are memory-mapped:
|
||||
|
||||
\begin{outline}
|
||||
\1the cursor's X position is a 16-bit value at MMIO \code{-33..-34}, and the Y position at \code{-35..-36}. Like the keyboard's raw view, both are latched by a write to \code{-40} so a coordinate pair can be read consistently.
|
||||
\1the button state is a single byte at \code{-37}. It is a bit-field: bit 0 is the left button, bit 1 the right, bit 2 the middle. Bits 6 and 7 report a wheel notch up and down respectively; these wheel bits latch in hardware and clear when read, so a single notch is reported exactly once.
|
||||
\end{outline}
|
||||
|
||||
Most programs do not poke these addresses directly. Under \thedos, the \code{input} library turns mouse motion, clicks and wheel notches into the \textbf{mouse\_down}, \textbf{mouse\_up}, \textbf{mouse\_move} and \textbf{mouse\_wheel} events described in the \thedos\ part of this book.
|
||||
|
||||
|
||||
\chapter{Peripherals and Memory Mapping}
|
||||
@@ -490,7 +558,7 @@ The memory map of \thismachine\ is illustrated as following:
|
||||
\draw(0,12.5) node[anchor=north east] {Start};
|
||||
\draw(6,12.5) node[anchor=north west] {End};
|
||||
|
||||
\draw(3,10) node[anchor=mid] {\memlabel{Scratchpad Memory}};
|
||||
\draw(3,10) node[anchor=mid] {\memlabel{Core Memory}};
|
||||
\draw(3,7.5) node[anchor=mid] {\memlabel{MMIO Area}};
|
||||
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
||||
\draw(3,5.5) node[anchor=mid] {\memlabel{Peripheral Memory \#2}};
|
||||
@@ -559,7 +627,7 @@ Address & RW & Description \\
|
||||
-41..-48 & RO & List of pressed keys (latched by -40) \\
|
||||
-49 & RO & System Flags A (\code{0b r000 000t}, where r: RESET button held, t: STOP button held) \\
|
||||
-50..-52 & RO & Unused System Flags \\
|
||||
-65..-68 & RO & Size of the Scratchpad Memory \\
|
||||
-65..-68 & RO & Size of the Core Memory \\
|
||||
-69 & WO & Counter Latch (\code{0b01}--Uptime, \code{0b10}--RTC) \\
|
||||
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
||||
-81..-88 & RO & RTC in nanoseconds (latched by -69) \\
|
||||
@@ -578,7 +646,14 @@ Address & RW & Description \\
|
||||
|
||||
\chapter{Text and Graphics Display}
|
||||
|
||||
TODO: Textbuf, pixelbuf, graphics mode, chart of the draw order
|
||||
\index{display model}The reference graphics adapter drives the screen from two superimposed surfaces:
|
||||
|
||||
\begin{outline}
|
||||
\1the \textbf{framebuffer} (also called the pixel buffer), a grid of pixels whose size and colour depth depend on the current graphics mode; and
|
||||
\1the \textbf{text buffer}, a grid of character cells, each with its own foreground and background colour.
|
||||
\end{outline}
|
||||
|
||||
These are composited every frame in a fixed order: first the screen background (border) colour, then the framebuffer, then the text on top. The text is therefore always visible over whatever the framebuffer holds, and a text cell whose background uses the transparent palette entry lets the framebuffer show through. A program that wants a purely graphical display simply leaves the text buffer blank; one that wants a classic terminal leaves the framebuffer cleared.
|
||||
|
||||
While the graphics adapters can be plugged into any peripheral slot, it is highly recommended they occupy the 1st slot. The Memory address charts for the graphics adapter on this documentation will assume as such.
|
||||
|
||||
@@ -634,7 +709,35 @@ Sequence & Description \\
|
||||
|
||||
\section{Frame Buffer}
|
||||
|
||||
TODO
|
||||
\index{frame buffer}The framebuffer holds the pixel image. In the default mode it is $560\times448$ pixels, one byte per pixel, where each byte is an index into the 256-entry colour palette. Pixels are written either with the \code{graphics} library (\code{plotPixel}, \code{plotRect}, \dots), or far more quickly by \code{dma}-copying a prepared image into the framebuffer region of the adapter's memory.
|
||||
|
||||
\subsection{Graphics Modes}
|
||||
|
||||
\index{graphics mode}The adapter can be reconfigured into several modes that trade resolution, colour depth and the number of \emph{layers} (independent pixel planes that the adapter composites together). The mode is selected with \code{graphics.setGraphicsMode}, or by poking the Current Graphics Mode register. The higher modes need extra video-memory banks to be fitted; if the required banks are absent, the mode change is ignored.
|
||||
|
||||
\begin{center}
|
||||
\begin{tabulary}{\textwidth}{clcl}
|
||||
Mode & Resolution & Colours & Layers / requirement \\
|
||||
\hline
|
||||
0 & $560\times448$ & 256 & 1 layer (default) \\
|
||||
1 & $280\times224$ & 256 & 4 layers \\
|
||||
2 & $280\times224$ & 4096 & 2 layers \\
|
||||
3 & $560\times448$ & 256 & 2 layers (needs bank 2) \\
|
||||
4 & $560\times448$ & 4096 & 1 layer (needs bank 2) \\
|
||||
5 & $560\times448$ & 15-bit & 1 layer (needs bank 2) \\
|
||||
8 & $560\times448$ & 24-bit & 1 layer (needs banks 3 \& 4) \\
|
||||
\end{tabulary}
|
||||
\end{center}
|
||||
|
||||
A graphics adapter ships with one 256\,kB memory bank and can hold up to four. The number of installed banks is reported by the adapter and limits which modes are available.
|
||||
|
||||
\subsection{Direct Colour}
|
||||
|
||||
\index{direct colour}The 4096-colour (``direct colour'') modes do not use the palette. Instead two layers are paired to form one frame: the low layer carries the red and green channels (\code{0b RRRR GGGG}) and the high layer carries the blue channel and a 4-bit transparency (\code{0b BBBB AAAA}), giving 4096 colours each with 16 levels of transparency.
|
||||
|
||||
\subsection{Layers and Scrolling}
|
||||
|
||||
\index{layers}When a mode provides more than one layer, the \emph{layer arrangement} register chooses the order in which the layers are stacked, so a program can swap front and back planes without moving any pixels. The whole framebuffer can also be panned: the horizontal and vertical framebuffer-scroll registers shift the entire image, and a per-scanline horizontal offset table allows each scanline to be displaced independently --- the basis of split-screen and wavy ``raster'' effects.
|
||||
|
||||
\subsection{Colour Palette}
|
||||
\label{colourpalette}
|
||||
@@ -940,14 +1043,55 @@ TODO
|
||||
|
||||
\section{The Graphics Library}
|
||||
|
||||
\index{graphics (library)}Graphics library provides basic functions to communicate and manipulate the graphics adapter.
|
||||
\index{graphics (library)}Graphics library provides basic functions to communicate and manipulate the graphics adapter. Coordinates are in pixels with the origin at the top-left; colours are palette indices unless the adapter is in a direct-colour mode.
|
||||
|
||||
\namespaceis{Graphics}{graphics}
|
||||
|
||||
Drawing:
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{TODO}{to be added.}
|
||||
\1\formalsynopsis{plotPixel}{x: Int, y: Int, colour: Int}{Plots a single pixel on the first framebuffer.}
|
||||
\1\formalsynopsis{plotPixel2}{x: Int, y: Int, colour: Int}{Plots a single pixel on the second framebuffer.}
|
||||
\1\formalsynopsis{plotRect}{x: Int, y: Int, w: Int, h: Int, colour: Int}{Fills a rectangle on the first framebuffer. An optional sixth argument selects a blending effect.}
|
||||
\1\formalsynopsis{plotRect2}{x: Int, y: Int, w: Int, h: Int, colour: Int}{As \code{plotRect}, on the second framebuffer.}
|
||||
\1\formalsynopsis{clearPixels}{col: Int}{Fills the entire framebuffer with the given colour.}
|
||||
\1\formalsynopsis{clearText}{}{Blanks the text buffer.}
|
||||
\1\formalsynopsis{getGpuMemBase}{}[Int]{Returns the base address of the graphics adapter's memory, for direct access via \code{peek}/\code{poke} or \code{dma}.}
|
||||
\end{outline}
|
||||
|
||||
Colour:
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{setBackground}{r: Int, g: Int, b: Int}{Sets the screen background (border) colour, 8 bits per channel.}
|
||||
\1\formalsynopsis{resetPalette}{}{Restores the default colour palette.}
|
||||
\1\formalsynopsis{setPalette}{index: Int, r: Int, g: Int, b: Int, a: Int}{Sets one palette entry. Channels are 4-bit (0--15); \code{a} (alpha) defaults to 15 (opaque).}
|
||||
\1\formalsynopsis{setTextFore}{b: Int}{Sets the current text foreground colour.}
|
||||
\1\formalsynopsis{setTextBack}{b: Int}{Sets the current text background colour.}
|
||||
\1\formalsynopsis{getTextFore}{}[Int]{Returns the current text foreground colour.}
|
||||
\1\formalsynopsis{getTextBack}{}[Int]{Returns the current text background colour.}
|
||||
\end{outline}
|
||||
|
||||
Mode and geometry:
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{setGraphicsMode}{mode: Int}{Switches the graphics mode (see \emph{Graphics Modes}).}
|
||||
\1\formalsynopsis{getGraphicsMode}{}[Int]{Returns the current graphics mode.}
|
||||
\1\formalsynopsis{getPixelDimension}{}[IntArray(2)]{Returns the framebuffer size as \code{[width, height]}.}
|
||||
\1\formalsynopsis{getTermDimension}{}[IntArray(2)]{Returns the text grid size as \code{[columns, rows]}.}
|
||||
\1\formalsynopsis{setFramebufferScroll}{x: Int, y: Int}{Pans the whole framebuffer to the given offset.}
|
||||
\1\formalsynopsis{getFramebufferScroll}{}[IntArray(2)]{Returns the current framebuffer scroll offset.}
|
||||
\1\formalsynopsis{scrollFrame}{xdelta: Int, ydelta: Int}{Pans the framebuffer by a relative amount.}
|
||||
\1\formalsynopsis{setLineOffset}{line: Int, offset: Int}{Sets the per-scanline horizontal offset (raster effects).}
|
||||
\1\formalsynopsis{getLineOffset}{line: Int}[Int]{Returns the per-scanline horizontal offset.}
|
||||
\end{outline}
|
||||
|
||||
Text cursor and symbols (these talk to the adapter directly; under \thedos\ prefer the \code{con} library):
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{getCursorYX}{}[IntArray(2)]{Returns the text cursor position as \code{[row, column]}.}
|
||||
\1\formalsynopsis{setCursorYX}{cy: Int, cx: Int}{Moves the text cursor.}
|
||||
\1\formalsynopsis{putSymbol}{c: Int}{Writes the character of the given code at the cursor, without advancing it.}
|
||||
\1\formalsynopsis{putSymbolAt}{cy: Int, cx: Int, c: Int}{Writes a character at a specific cell.}
|
||||
\end{outline}
|
||||
|
||||
Image decoding: the adapter can decode compressed still images straight into memory. \code{decodeImage(srcFilePtr, srcFileLen)} decodes an image (JPEG, PNG, TGA, \dots) whose bytes are already in memory and returns its dimensions; \code{decodeImageTo} writes the result to a given buffer, and \code{decodeImageResample} scales it on the way. These, together with the hardware decoders for the iPF, TEV and TAV formats, are what the \thedos\ media players are built on; the formats themselves are described in the \thedos\ part.
|
||||
|
||||
|
||||
\section{MMIO and Memory Mapping}
|
||||
|
||||
@@ -987,3 +1131,44 @@ Address & RW & Description \\
|
||||
-1310209..-1310720 & RW & Palettes in This Pattern: {\ttfamily 0b RRRR GGGG; 0b BBBB AAAA} \\
|
||||
-1310721..-1561600 & RW & Second Framebuffer \\
|
||||
\end{tabulary}
|
||||
|
||||
|
||||
|
||||
\chapter{Audio}
|
||||
|
||||
\index{audio adapter}The \thismachine\ sound card is built from four independent \textbf{playheads}. Each playhead can play either a stream of PCM samples or a tracker track, and the four are mixed together to the card's native output of 32\,kHz stereo.
|
||||
|
||||
The playheads are not locked to one another, so timing between them is not guaranteed. A single piece of music should therefore live on a single playhead; the remaining playheads are best used for sound effects or left idle.
|
||||
|
||||
\section{PCM Playback}
|
||||
|
||||
\index{PCM playback}In PCM mode a playhead is fed a queue of raw samples, which the card plays back at the hardware rate. This is the path used by the \thedos\ players for raw PCM, WAV/ADPCM, MP2 and \thedos's own compressed audio --- a player decodes the file into samples and pushes them into a playhead's queue. The relevant format details are covered, from the listener's point of view, in the \thedos\ part.
|
||||
|
||||
\section{Tracker Playback}
|
||||
|
||||
\index{tracker engine}In tracker mode a playhead is driven by the card's built-in tracker engine. The engine keeps a pool of digitised \emph{samples}, a set of \emph{instruments} built from them, and the \emph{patterns} and cue sheet that sequence the notes; once these are loaded the playhead renders the song autonomously. This is how \thismachine\ plays its native \textbf{Taud} music.
|
||||
|
||||
This guide deliberately stops at that overview. The Taud file format, the instrument and envelope model, the effect commands, and the \emph{Microtone} tracker used to author Taud music are large topics with a manual of their own, and are out of scope here.
|
||||
|
||||
\section{The Audio Library}
|
||||
|
||||
\index{audio (library)}The \code{audio} namespace controls the playheads. The functions below cover everyday playback; the engine also exposes a large number of tracker-control and voice-inspection calls (pattern and cue upload, tempo, per-voice queries, \dots) that belong with the Taud documentation.
|
||||
|
||||
\namespaceis{Audio}{audio}
|
||||
|
||||
\begin{outline}
|
||||
\1\formalsynopsis{setPcmMode}{playhead: Int}{Puts a playhead (0--3) into PCM mode.}
|
||||
\1\formalsynopsis{setTrackerMode}{playhead: Int}{Puts a playhead into tracker mode.}
|
||||
\1\formalsynopsis{play}{playhead: Int}{Starts the playhead.}
|
||||
\1\formalsynopsis{stop}{playhead: Int}{Stops the playhead.}
|
||||
\1\formalsynopsis{isPlaying}{playhead: Int}[Boolean]{Whether the playhead is currently playing.}
|
||||
\1\formalsynopsis{getFreePlayhead}{fallback: Int}[Int]{Returns the lowest-numbered playhead that is not currently playing.}
|
||||
\1\formalsynopsis{getPosition}{playhead: Int}[Int]{Current playback position of the playhead.}
|
||||
\1\formalsynopsis{setMasterVolume}{playhead: Int, volume: Int}{Sets the playhead's output volume.}
|
||||
\1\formalsynopsis{setMasterPan}{playhead: Int, pan: Int}{Sets the playhead's stereo pan.}
|
||||
\1\formalsynopsis{putPcmDataByPtr}{playhead: Int, ptr: Int, length: Int, destOffset: Int}{Copies PCM samples from memory into the playhead's buffer.}
|
||||
\1\formalsynopsis{purgeQueue}{playhead: Int}{Empties the playhead's pending sample queue.}
|
||||
\1\formalsynopsis{resetParams}{playhead: Int}{Resets the playhead to default parameters.}
|
||||
\1\formalsynopsis{getMemAddr}{}[Int]{Returns the base address of the audio adapter's memory space.}
|
||||
\1\formalsynopsis{getBaseAddr}{}[Int]{Returns the base address of the audio adapter's MMIO area.}
|
||||
\end{outline}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
\thismachine\ is a virtual machine programmable using mainly, but not limited to, Javascript, and can have 7 virtual peripherals that can be communicated using MMIOs exclusively. \thismachine\ has default graphics of 80-column 32-rows text mode with one 560\times448 pixels framebuffer with 256 palette colours with 4096 colours to choose from.
|
||||
\thismachine\ is a virtual machine that imitates the architecture of an 8-bit era home computer while being programmed, mainly but not exclusively, in Javascript. A \thismachine\ system is built around a flat memory space into which both the core memory and the hardware peripherals are mapped, so that every device --- the graphics adapter, the sound card, the disk drives --- is reached through the same \code{peek} and \code{poke} operations that touch ordinary memory.
|
||||
|
||||
This is the documentation for \thismachine\ \tsvmver.
|
||||
Out of the box, \thismachine\ presents an 80-column, 32-row text display backed by a $560\times448$-pixel framebuffer with 256 simultaneous colours chosen from a palette of 4096, built-in keyboard and mouse input, and four serial ports for attaching disk drives, modems and other machines. Up to seven expansion cards may be fitted, each mapping a megabyte of its own memory into the address space.
|
||||
|
||||
This guide is one book in two parts. The first part, \emph{\thismachine}, documents the virtual machine itself: its memory map, the Javascript runtime and its built-in libraries, the way peripherals are addressed, and the text, graphics and audio hardware. The second part, \emph{\thedos}, documents the disk operating system that is usually shipped with the machine: how it boots, the commands and applications it provides, how it plays back media, the format in which commands describe themselves, and the libraries it offers to programs of your own.
|
||||
|
||||
This is the documentation for \thismachine\ version \tsvmver\ and the \thedos\ that accompanies it.
|
||||
|
||||
@@ -10,6 +10,6 @@ Copyrighted under the terms of MIT License
|
||||
|
||||
\begin{center}
|
||||
\begin{tabulary}{\textwidth}{ll}
|
||||
Zeroth Edition (for version 1.0): & \thepublishingdate
|
||||
\theedition\ (for version \tsvmver): & \thepublishingdate
|
||||
\end{tabulary}
|
||||
\end{center}
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
\newcommand{\codeline}[1]{%
|
||||
\colorbox{lgrey}{%
|
||||
\begin{tabular*}{\textwidth}{l}%
|
||||
\monofont #1 \\% TODO fill the cell with \hl colour
|
||||
\monofont #1 \\% cell background is provided by the enclosing \colorbox
|
||||
\end{tabular*}%
|
||||
}}
|
||||
|
||||
@@ -203,8 +203,8 @@
|
||||
\newcommand{\thismachine}{TSVM}
|
||||
\newcommand{\thedos}{TVDOS}
|
||||
\newcommand{\tsvmver}{1.2}
|
||||
\newcommand{\theedition}{Zeroth Edition}
|
||||
\newcommand{\thepublishingdate}{0000-00-00}
|
||||
\newcommand{\theedition}{First Edition}
|
||||
\newcommand{\thepublishingdate}{2026-06-06}
|
||||
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
||||
|
||||
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
||||
@@ -247,10 +247,10 @@
|
||||
|
||||
% \input{changesmeta}
|
||||
|
||||
\part{The Virtual Machine}
|
||||
\part{\thismachine: The Virtual Machine}
|
||||
\input{implementation}
|
||||
|
||||
\part{The DOS}
|
||||
\part{\thedos: The Disk Operating System}
|
||||
\input{tvdos}
|
||||
|
||||
\part*{Bibliography}
|
||||
|
||||
738
doc/tvdos.tex
738
doc/tvdos.tex
@@ -7,16 +7,16 @@ All \thedos-related features requires the DOS to be fully loaded.
|
||||
|
||||
\chapter{Bootstrapping}
|
||||
|
||||
\index{boot process}\thedos\ goes through follwing progress to deliver the \code{A:\rs} prompt:
|
||||
\index{boot process}\thedos\ goes through the following progress to deliver the \code{A:\rs} prompt:
|
||||
|
||||
\section{Probing Bootable Devices}
|
||||
The BIOS will probe serial devices to find first bootable drive. If found, port number of the driver is written to the \code{\_BIOS} object, then attempts to load and run the bootloader.
|
||||
The BIOS will probe serial devices to find the first bootable drive. If found, the port number of the drive is written to the \code{\_BIOS} object, then it attempts to load and run the bootloader.
|
||||
|
||||
\section{The Bootloader}
|
||||
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
||||
|
||||
\section{TVDOS.SYS}
|
||||
\thedos.SYS will load system libraries and variables and then will try to run the boot script by executing \code{A:\rs{}AUTOEXEC.BAT}
|
||||
\thedos.SYS loads the system libraries and variables, installs the filesystem and input drivers, and then runs the boot script.
|
||||
|
||||
Boot Procedure:
|
||||
|
||||
@@ -26,21 +26,58 @@ Boot Procedure:
|
||||
\item initialise DOS variables
|
||||
\item install filesystem drivers
|
||||
\item install input device drivers
|
||||
\item install GL using the external file
|
||||
\item execute \code{AUTOEXEC.BAT}
|
||||
\begin{enumerate}
|
||||
\item execute \code{command.js} with proper arguments
|
||||
\item \code{command.js} to initialise \code{shell.*} functions (this includes coreutils and patched version of \code{require})
|
||||
\item \code{command.js} to parse and run \code{AUTOEXEC.BAT}
|
||||
\end{enumerate}
|
||||
\item install \code{GL} using the external file
|
||||
\item run \code{\rs{}commandrc} to set up the environment
|
||||
\item hand the screen to the virtual-console manager, \code{\rs{}tvdos\rs{}VTMGR.SYS}
|
||||
\item when the manager exits, run \code{\rs{}AUTOEXEC.BAT} as a bare fallback shell
|
||||
\end{enumerate}
|
||||
|
||||
\section{AUTOEXEC.BAT}
|
||||
Steps 7--9 are run through \code{command.js}: \thedos.SYS loads \code{command.js}, which initialises the \code{shell.*} functions (the coreutils and a patched \code{require}), and then parses and runs the requested script.
|
||||
|
||||
AUTOEXEC can setup user-specific variables (e.g. keyboard layout) and launch the command shell of your choice, \code{COMMAND} is the most common shell.
|
||||
\section{The Boot Configuration: commandrc and AUTOEXEC.BAT}
|
||||
|
||||
Variables can be set or changed using \textbf{SET} commands.
|
||||
\index{commandrc}\index{AUTOEXEC.BAT}The boot configuration is split across two files with deliberately different jobs. The split exists because \thedos\ runs several independent shell sessions at once (see \emph{Virtual Consoles}): the environment must be established in \emph{every} session, but applications should be launched only \emph{once per session}.
|
||||
|
||||
\code{\rs{}commandrc} is the \textbf{environment} file. It holds \code{set} commands and nothing else, and \thedos.SYS runs it in every context --- the boot console and every virtual-console pane. Because it has no \code{.BAT} extension, the boot block runs it line by line. A typical \code{commandrc} configures the search paths and the keyboard layout:
|
||||
|
||||
\begin{lstlisting}
|
||||
set PATH=\tbas;\hopper\bin;$PATH
|
||||
set INCLPATH=\hopper\include;$INCLPATH
|
||||
set HELPPATH=\hopper\help;$HELPPATH
|
||||
set KEYBOARD=us_qwerty
|
||||
\end{lstlisting}
|
||||
|
||||
\code{\rs{}AUTOEXEC.BAT} is the \textbf{per-console launch} script. It is run once for each console: by each virtual-console pane as it starts, and by the boot console as the fallback once the virtual-console manager has exited. It performs work that must happen per session --- registering the input method, then starting the interactive shell:
|
||||
|
||||
\begin{lstlisting}
|
||||
command -fancy
|
||||
\end{lstlisting}
|
||||
|
||||
Variables are set or changed with the \textbf{set} command. A value may refer to the previous value of a variable with \code{\$NAME}, as the \code{\$PATH} above does, which appends to rather than replaces the existing search path.
|
||||
|
||||
|
||||
|
||||
\chapter{Virtual Consoles}
|
||||
|
||||
\index{virtual consoles}\thedos\ runs up to six independent shell sessions at once, called \textbf{virtual consoles}. Only one is shown on the physical screen at a time; the others keep running in the background. This is managed by \code{VTMGR.SYS}, which the boot process starts automatically (see \emph{Bootstrapping}).
|
||||
|
||||
\section{Switching Consoles}
|
||||
|
||||
\begin{outline}
|
||||
\1\textbf{Alt-1} through \textbf{Alt-6} switch to that console. A console is created the first time it is selected, and re-created if its shell has exited.
|
||||
\1the \code{chvt} \emph{N} command switches to console \emph{N} from within a script or a running shell.
|
||||
\1\textbf{Alt-0} shuts the virtual-console manager down entirely, after which the boot console's \code{AUTOEXEC.BAT} runs as a single bare shell.
|
||||
\end{outline}
|
||||
|
||||
Console 1 is created at boot; consoles 2--6 are created on first use. Each console runs its own \code{command -fancy} shell, with its own working directory and screen, but they all share the same environment set up by \code{commandrc}. The prompt of consoles 2--6 is prefixed with \code{[\emph{N}]} so the active console is always identifiable.
|
||||
|
||||
\section{Concurrency}
|
||||
|
||||
Switching is truly concurrent: a console keeps running even while it is not on screen, and you can switch away from a long-running command and back again without interrupting it. A console that is waiting for keyboard input is parked until it is brought to the foreground.
|
||||
|
||||
\section{Well-behaved Applications}
|
||||
|
||||
Most programs need no special handling to run inside a virtual console, because they draw through the \code{con} library and the \code{print} family, which \thedos\ routes to the correct console automatically. The one exception is a program that writes to the text area \emph{directly} through \code{graphics.getGpuMemBase()}: such a program would paint the physical screen and bleed into whichever console is visible. Applications that need direct text-area access must resolve their writes through a console-aware base address; the bundled applications that do this are already adapted.
|
||||
|
||||
|
||||
\chapter{Coreutils}
|
||||
@@ -50,6 +87,7 @@ Variables can be set or changed using \textbf{SET} commands.
|
||||
\begin{outline}
|
||||
\1\dossynopsis{cat}[file]{Reads a file and pipes its contents to the pipe, or to the console if no pipes are specified.}
|
||||
\1\dossynopsis{cd}[dir]{Change the current working directory. Alias: chdir}
|
||||
\1\dossynopsis{chvt}[N]{Switches to virtual console \emph{N} (1--6). Only meaningful inside a virtual console. See \emph{Virtual Consoles}.}
|
||||
\1\dossynopsis{cls}{Clears the text buffer and the framebuffer if available.}
|
||||
\1\dossynopsis{cp}[from to]{Make copies of the specified file. The source file must not be a directory. Alias: copy}
|
||||
\1\dossynopsis{date}{Prints the system date. Alias: time}
|
||||
@@ -59,42 +97,148 @@ Variables can be set or changed using \textbf{SET} commands.
|
||||
\1\dossynopsis{exit}{Exits the current command processor.}
|
||||
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
||||
\1\dossynopsis{mv}[from to]{Moves or renames the file. Aliase: move}
|
||||
\1\dossynopsis{panic}{Deliberately raises an error in the command processor. A diagnostic aid.}
|
||||
\1\dossynopsis{rem}{Comment-out the line.}
|
||||
\1\dossynopsis{set}[key=value]{Sets the global variable \code{key} to \code{value}, or displays the list of global variables if no arguments were given.}
|
||||
\1\dossynopsis{ver}{Prints the version of \thedos.}
|
||||
\1\dossynopsis{which}[program]{Reports how a name would resolve: as a shell built-in, or as the full path of the executable found on the \code{PATH}. Alias: where}
|
||||
\end{outline}
|
||||
|
||||
|
||||
|
||||
\chapter{Built-in Apps}
|
||||
|
||||
\index{built-in apps (DOS)}Built-in Applications are the programs shipped with the standard distribution of \thedos\ that is written for users' convenience.
|
||||
\index{built-in apps (DOS)}Built-in Applications are the programs shipped with the standard distribution of \thedos\ that are written for users' convenience.
|
||||
|
||||
This chapter will only briefly list and describe the applications.
|
||||
This chapter lists the general-purpose applications. The applications for playing and converting media have a chapter of their own, \emph{Media Playback and Formats}.
|
||||
|
||||
\section{Shells and Languages}
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC rom is present, nothing will be done.}
|
||||
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, this command will invoke the BASIC interpreter stored in the disk.}
|
||||
\1\dossynopsis{color}{Changes the background and the foreground of the active session.}
|
||||
\1\dossynopsis{command}{The default text-based DOS shell. Call with \code{command -fancy} for more \ae sthetically pleasing looks.}
|
||||
\1\dossynopsis{decodeipf}[file]{Decodes the IPF-formatted image to the framebuffer using the graphics processor.}
|
||||
\1\dossynopsis{drives}{Shows the list of the connected and mounted disk drives.}
|
||||
\1\dossynopsis{edit}[file]{The interactive full-screen text editor.}
|
||||
\1\dossynopsis{encodeipf}[1/2 imagefile ipffile]{Encodes the given image file (.jpg, .png, .bmp, .tga) to the IPF format using the graphics hardware.}
|
||||
\1\dossynopsis{false}{Returns errorlevel 1 upon execution.}
|
||||
\1\dossynopsis{geturl}[url]{Reads contents on the web address and store it to the disk. Requires Internet adapter.}
|
||||
\1\dossynopsis{hexdump}[file]{Prints out the contents of a file in hexadecimal view. Supports pipe.}
|
||||
\1\dossynopsis{less}[file]{Allows user to read the long text, even if they are wider and/or taller than the screen. Supports pipe.}
|
||||
\1\dossynopsis{playmov}[file]{Plays tsvmmov-formatted video. Use -i flag for playback control.}
|
||||
\1\dossynopsis{playmp2}[file]{Plays MP2 (MPEG-1 Audio Layer II) formatted audio. Use -i flag for playback control.}
|
||||
\1\dossynopsis{playpcm}[file]{Plays raw PCM audio. Use -i flag for playback control.}
|
||||
\1\dossynopsis{playwav}[file]{Plays linear PCM/ADPCM audio. Use -i flag for playback control.}
|
||||
\1\dossynopsis{printfile}[file]{Prints out the contents of a textfile with line numbers. Useful for making descriptive screenshots.}
|
||||
\1\dossynopsis{touch}[file]{Updates a file's modification date. New file will be created if the specified file does not exist.}
|
||||
\1\dossynopsis{true}{Returns errorlevel 0 upon execution.}
|
||||
\1\dossynopsis{zfm}{Z File Manager. A two-panel graphical user interface to navigate the system using arrow keys. Hit Z to switch panels.}
|
||||
\1\dossynopsis{command}{The default text-based \thedos\ shell. Call with \code{command -fancy} for a more \ae sthetically pleasing look.}
|
||||
\1\dossynopsis{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC ROM is present, nothing is done.}
|
||||
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, invokes the BASIC interpreter stored on the disk.}
|
||||
\end{outline}
|
||||
|
||||
\section{Files and Text}
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{edit}[file]{The interactive full-screen text editor.}
|
||||
\1\dossynopsis{zfm}{Z File Manager. A two-panel graphical interface for navigating the system with the arrow keys. Press Z to switch panels.}
|
||||
\1\dossynopsis{less}[file]{Lets the user read long text, even when it is wider and/or taller than the screen. Supports pipes.}
|
||||
\1\dossynopsis{hexdump}[file]{Prints the contents of a file in a hexadecimal view. Supports pipes.}
|
||||
\1\dossynopsis{printfile}[file]{Prints the contents of a text file with line numbers. Useful for making descriptive screenshots.}
|
||||
\1\dossynopsis{touch}[file]{Updates a file's modification date. The file is created if it does not exist.}
|
||||
\1\dossynopsis{tee}[file]{In a pipe, copies the incoming stream to a file \emph{and} passes it on to the next command.}
|
||||
\1\dossynopsis{writeto}[file]{In a pipe, writes the incoming stream to a file.}
|
||||
\end{outline}
|
||||
|
||||
\section{Storage and Archives}
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{drives}{Lists the connected and mounted disk drives.}
|
||||
\1\dossynopsis{defrag}{Defragments the current drive.}
|
||||
\1\dossynopsis{gzip}[file]{Compresses a file in place, or decompresses it with \code{-d}, or writes to standard output with \code{-c}. As with the \code{gzip} library, the actual format used is Zstandard.}
|
||||
\1\dossynopsis{lfs}{Creates and extracts \thedos\ Linear File Strip (\code{.lfs}) archives --- a simple way to bundle a directory tree into one file.}
|
||||
\1\dossynopsis{autorun}[file]{Runs a program or plays a media file directly from a sequential (tape) device.}
|
||||
\end{outline}
|
||||
|
||||
\section{Networking and Packages}
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{geturl}[url]{Reads the contents at a web address and stores it to the disk. Requires an Internet adapter.}
|
||||
\1\dossynopsis{telcom}[port]{A terminal program for talking to a device or modem on a serial port.}
|
||||
\1\dossynopsis{hopper}{The \thedos\ package manager, for installing and managing optional software. Alias: hop.}
|
||||
\end{outline}
|
||||
|
||||
\section{Miscellaneous}
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{synopsis}[program]{Prints a command's one-line summary together with an auto-generated synopsis --- its usage line, arguments, options and constraints --- read from the command's \code{.synopsis} description. With no \code{program} it describes itself. Alias: help.}
|
||||
\1\dossynopsis{color}{Changes the background and foreground colour of the active session.}
|
||||
\1\dossynopsis{true}{Returns errorlevel 0 upon execution.}
|
||||
\1\dossynopsis{false}{Returns errorlevel 1 upon execution.}
|
||||
\end{outline}
|
||||
|
||||
The music tracker and editor (invoked as \code{microtone}) is also bundled, but it --- together with the music format it authors --- is large enough to warrant its own manual and is not covered here.
|
||||
|
||||
|
||||
|
||||
\chapter{Media Playback and Formats}
|
||||
|
||||
\index{media playback (DOS)}\thedos\ can display images, play video and play audio, in several formats. Decoding is hardware-accelerated by the graphics and audio adapters, so even the more sophisticated formats play back smoothly. This chapter is written for someone who wants to \emph{use} these formats; it describes what each is for and which command plays it, not how the codecs work internally.
|
||||
|
||||
Most players accept the \code{-i} flag, which turns on \textbf{interactive mode}: an on-screen progress bar, a visualiser where appropriate, and playback control. Without it, the player simply plays the file and exits. Across the players, holding \textbf{Backspace} stops playback.
|
||||
|
||||
\section{Still Images}
|
||||
|
||||
\index{iPF}The machine's native still-image format is \textbf{iPF} (Interchangeable Picture Format). The graphics hardware can also decode ordinary JPEG, PNG and TGA images directly.
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{decodeipf}[file]{Decodes an iPF image onto the framebuffer.}
|
||||
\1\dossynopsis{encodeipf}[1/2 imagefile ipffile]{Encodes an image file (\code{.jpg}, \code{.png}, \code{.bmp}, \code{.tga}) into iPF, using the graphics hardware. The first argument selects the iPF type (1 or 2).}
|
||||
\end{outline}
|
||||
|
||||
The TAV video format below also have still-picture variants, used for high-fidelity images.
|
||||
|
||||
\section{Video}
|
||||
|
||||
\thedos\ supports three families of video, in rough order of age and sophistication:
|
||||
|
||||
\begin{outline}
|
||||
\1\textbf{MV1} --- the legacy movie format, carrying iPF (or plain palette) frames with MP2 audio. Created on the machine with \code{encodemov} / \code{encodemov2}.
|
||||
\1\textbf{TEV} (\thismachine\ Enhanced Video) --- a modern format with markedly better compression than iPF movies, taking full advantage of the 4096-colour hardware.
|
||||
\1\textbf{TAV} (\thismachine\ Advanced Video) --- the current format and successor to TEV, offering the best compression and image quality.
|
||||
\end{outline}
|
||||
|
||||
TEV and TAV files are prepared on a host computer and copied to the disk for playback; MOV and iPF content can also be produced on the machine itself. Both TEV and TAV are encoded at a chosen \emph{quality level} when they are made, trading file size against fidelity --- as a viewer you simply play the result.
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{playmov}[file]{Plays any movie --- MV1, TEV or TAV, including TAV still pictures --- detecting the format from the file itself. This is the recommended player; see below.}
|
||||
\1\dossynopsis{playmv1}[file]{Plays a MV1-format movie.}
|
||||
\1\dossynopsis{playtev}[file]{Plays a TEV-format video.}
|
||||
\1\dossynopsis{playtav}[file]{Plays a TAV-format video.}
|
||||
\1\dossynopsis{movprobe}[file]{Prints a movie's properties (dimensions, frame rate, audio, \dots) without playing it.}
|
||||
\1\dossynopsis{playucf}[file]{Plays a chaptered movie (UCF), presenting a chapter selector.}
|
||||
\end{outline}
|
||||
|
||||
\subsection{The All-in-One Player}
|
||||
|
||||
\index{playmov}\code{playmov} plays every video format above --- MV1, TEV and TAV, including TAV still pictures --- from a single command, choosing the right decoder by inspecting the file. It is the recommended way to play video; the format-specific players remain available for compatibility. Subtitles are shown automatically for files that carry them, and chaptered (UCF) streams are navigable.
|
||||
|
||||
Run with \code{-i} for interactive playback. The controls are:
|
||||
|
||||
\begin{outline}
|
||||
\1\textbf{Backspace} --- stop and exit (hold).
|
||||
\1\textbf{Space} --- pause and resume.
|
||||
\1\textbf{Left} / \textbf{Right} --- seek backward / forward.
|
||||
\1\textbf{Up} / \textbf{Down} --- volume up / down.
|
||||
\1\textbf{Page Up} / \textbf{Page Down} --- previous / next chapter, for chaptered files.
|
||||
\1\textbf{A} --- toggle ASCII-render mode.
|
||||
\end{outline}
|
||||
|
||||
\index{ASCII-render mode}In \textbf{ASCII-render mode} the picture is drawn as text-mode character art rather than pixels --- a novelty, low-fidelity view of the same playing video. Begin in this mode with \code{playmov -ascii file}, or press \textbf{A} to switch in and out at any time, including mid-playback.
|
||||
|
||||
\section{Audio}
|
||||
|
||||
\index{MP2}\index{TAD}For sound, \thedos\ plays standard \textbf{MP2} (MPEG-1 Audio Layer II), raw and wave-wrapped PCM, and its own compressed format, \textbf{TAD} (\thismachine\ Advanced Audio). All audio plays back at the hardware's 32\,kHz stereo. As with video, TAD files are prepared on a host computer; MP2 is a widely interchangeable format, and PCM/WAV are uncompressed.
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{playmp2}[file]{Plays MP2 (MPEG-1 Audio Layer II) audio.}
|
||||
\1\dossynopsis{playtad}[file]{Plays TAD audio.}
|
||||
\1\dossynopsis{playpcm}[file]{Plays raw PCM audio.}
|
||||
\1\dossynopsis{playwav}[file]{Plays linear-PCM or ADPCM \code{.wav} audio.}
|
||||
\end{outline}
|
||||
|
||||
\section{Music}
|
||||
|
||||
\index{Taud}\thismachine\ has a native tracker music format, \textbf{Taud}, played by the built-in tracker engine (see the \emph{Audio} chapter in the \thismachine\ part). Unlike the streamed audio formats above, a Taud file is a compact \emph{score} --- samples plus the patterns that sequence them --- so a whole piece of music occupies very little space.
|
||||
|
||||
\begin{outline}
|
||||
\1\dossynopsis{playtaud}[file]{Plays a Taud module, with a text-mode visualiser. An optional second argument selects which song within the file to play. Hold Backspace to exit.}
|
||||
\end{outline}
|
||||
|
||||
Authoring Taud music, and the format itself, are the subject of a separate manual and are not described here.
|
||||
|
||||
|
||||
\chapter{Writing Your Own Apps}
|
||||
@@ -110,6 +254,15 @@ The command line arguments are given via the array of strings named `exec\_args`
|
||||
Index zero holds the name used to invoke the app, and the rest hold the actual arguments.
|
||||
|
||||
|
||||
\section{Describing Your App: the Synopsis File}
|
||||
|
||||
\index{synopsis file}Every command should ship a machine-readable description of its own command-line interface, written in the \thedos\ Synopsis Format (TSF) and described in full in the \emph{Command Synopsis Format} chapter.
|
||||
|
||||
The rule is simple: the synopsis lives \emph{next to} the program, with the program's full filename plus a \code{.synopsis} extension. An app installed as \code{cp.js} in \code{\rs{}tvdos\rs{}bin} therefore ships alongside it as \code{\rs{}tvdos\rs{}bin\rs{}cp.js.synopsis}. \thedos\ uses these files to generate help text and tab-completion, so providing one makes your app a first-class citizen of the system.
|
||||
|
||||
Built-in coreutils have no on-disk executable to sit beside, so their synopses live together in \code{\rs{}tvdos\rs{}synopsis}, named for the command (for example \code{\rs{}tvdos\rs{}synopsis\rs{}cp.synopsis}). Either way, once a synopsis is in place the \code{synopsis} command --- and its \code{help} alias --- will display it, and the shell will tab-complete against it.
|
||||
|
||||
|
||||
\section{Invoking Coreutils on the user Apps}
|
||||
|
||||
DOS coreutils and some of the internal functions can be used on Javascript program.
|
||||
@@ -135,6 +288,254 @@ Due to the non-preemptive nature of the virtual machine, the termination\footnot
|
||||
While- and For-loops are always have such checks injected, but the `read()` is not checked for the termination.
|
||||
|
||||
|
||||
\chapter{Command Synopsis Format}
|
||||
\label{ch:tsf}
|
||||
|
||||
\index{TSF}\index{synopsis format}The \thedos\ Synopsis Format (TSF) is the language in which a command describes its own command-line interface. Every command is expected to ship a TSF document --- a file with the command's full name plus a \code{.synopsis} extension, stored in the same directory as the program. The command \code{cp.js} in \code{\rs{}tvdos\rs{}bin}, for instance, is accompanied by \code{cp.js.synopsis} in that same directory. \thedos\ reads these documents to drive shell completion, help generation, and argument validation.
|
||||
|
||||
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY and OPTIONAL in this chapter are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.
|
||||
|
||||
\section{Scope}
|
||||
|
||||
A TSF document describes a command's grammar: its options and flags, positional arguments, subcommands, argument types, completion sources and validation constraints.
|
||||
|
||||
A TSF document MUST be valid JSON, and MUST be encoded so that its byte stream contains only ASCII characters: any character outside the ASCII range (U+0000--U+007F) MUST be written as a JSON \texttt{\textbackslash u} escape (a backslash, \texttt{u}, and four hexadecimal digits) rather than as a literal multibyte character. Consumers MUST decode such escapes per the JSON specification.
|
||||
|
||||
\section{Design Goals}
|
||||
|
||||
TSF SHALL be machine-readable, human-authorable, and able to support automatic shell completion, automatic help generation, parser generation and GUI generation.
|
||||
|
||||
The structured synopsis grammar SHALL be the sole normative description of command syntax; every other representation, including human-readable usage strings, is treated as output generated from it.
|
||||
|
||||
\section{Document Structure}
|
||||
|
||||
A TSF document SHALL contain one JSON object.
|
||||
|
||||
\begin{lstlisting}
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cp",
|
||||
"summary": "Copy files and directories",
|
||||
"symbols": {},
|
||||
"synopsis": {}
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
Its fields are:
|
||||
|
||||
\begin{tabulary}{\textwidth}{lclL}
|
||||
Field & Req. & Type & Notes \\
|
||||
\hline
|
||||
tsfVersion & yes & string & Version of TSF this document targets. \\
|
||||
name & yes & string & Command name as invoked. \\
|
||||
summary & yes & string & One-line description. \\
|
||||
symbols & yes & object & The symbol table. \\
|
||||
synopsis & yes & object & The synopsis grammar root node. \\
|
||||
description & no & string & Free-form long description for help generation. \\
|
||||
constraints & no & array & Constraint objects. \\
|
||||
metadata & no & object & Free-form, non-normative data for authors and hosts. \\
|
||||
\end{tabulary}
|
||||
|
||||
\section{The Symbol Table}
|
||||
|
||||
All command elements SHALL be declared in the symbol table, and the synopsis grammar SHALL reference them by identifier.
|
||||
|
||||
\begin{lstlisting}
|
||||
"symbols": {
|
||||
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||
"source": { "kind": "positional", "type": "path" }
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
Every symbol has a \code{kind}, one of \code{option}, \code{positional}, \code{subcommand} or \code{group}.
|
||||
|
||||
\section{Argument Descriptors}
|
||||
|
||||
An \emph{argument descriptor} describes a single consumed value. The same shape is used in two places: directly on a \code{positional} symbol, and as the \code{value} of an \code{option} symbol.
|
||||
|
||||
\begin{tabulary}{\textwidth}{lclL}
|
||||
Field & Req. & Type & Notes \\
|
||||
\hline
|
||||
type & no & string & One of the built-in types. Defaults to \code{string}. \\
|
||||
name & no & string & Metavar shown in generated usage (e.g.\ FILE, WHEN). \\
|
||||
values & cond. & array & Permitted values; REQUIRED when \code{type} is \code{enum}. \\
|
||||
default & no & any & Default value, for help and GUI prefill. \\
|
||||
validation & no & object & Value-level validation (below). \\
|
||||
completion & no & object & Completion override. \\
|
||||
summary & no & string & Short description of the value. \\
|
||||
\end{tabulary}
|
||||
|
||||
Each entry in \code{values} SHALL be either a bare JSON value or an object \code{\{ "value": v, "summary": s \}}; the optional per-value summary feeds completion hints and help.
|
||||
|
||||
\subsection{Validation}
|
||||
|
||||
The \code{validation} object expresses checks the grammar cannot:
|
||||
|
||||
\begin{tabulary}{\textwidth}{lL}
|
||||
Field & Notes \\
|
||||
\hline
|
||||
pattern & A regular expression the value MUST match (string-like types). \\
|
||||
minimum & Inclusive lower bound (numeric types). \\
|
||||
maximum & Inclusive upper bound (numeric types). \\
|
||||
minLength & Minimum length in characters (string-like types). \\
|
||||
maxLength & Maximum length in characters (string-like types). \\
|
||||
\end{tabulary}
|
||||
|
||||
The regular-expression flavour for \code{pattern} is implementation-defined; authors are RECOMMENDED to keep to a portable subset. As the document is ASCII-only, any non-ASCII character within a pattern MUST be escaped.
|
||||
|
||||
\section{Option Symbols}
|
||||
|
||||
\begin{lstlisting}
|
||||
"output": {
|
||||
"kind": "option",
|
||||
"long": "--output",
|
||||
"short": "-o",
|
||||
"summary": "Write output to FILE",
|
||||
"value": { "name": "FILE", "type": "file", "required": true }
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
\begin{tabulary}{\textwidth}{lclL}
|
||||
Field & Req. & Type & Notes \\
|
||||
\hline
|
||||
kind & yes & string & \code{option}. \\
|
||||
long & cond. & string & Long form, e.g.\ \code{--recursive}. \\
|
||||
short & cond. & string & Short form, e.g.\ \code{-r}. \\
|
||||
summary & no & string & One-line description. \\
|
||||
value & no & object & Argument descriptor. Omit for a bare flag. \\
|
||||
negatable & no & boolean & If true, a \code{--no-<long>} form is also accepted. \\
|
||||
\end{tabulary}
|
||||
|
||||
At least one of \code{long} or \code{short} SHALL exist. The \code{value} object MAY carry a \code{required} field (default true): when false, the argument is optional, as in \code{--color} with or without \code{=WHEN}. How often an option may repeat is expressed in the grammar (via \code{repeat} or \code{oneOrMore}), not by a field on the symbol.
|
||||
|
||||
\section{Positional Symbols}
|
||||
|
||||
A positional symbol is an argument descriptor plus its \code{kind}.
|
||||
|
||||
\begin{lstlisting}
|
||||
"source": { "kind": "positional", "type": "path", "summary": "Source file" }
|
||||
\end{lstlisting}
|
||||
|
||||
Its fields are \code{kind} (\code{positional}) followed by the argument-descriptor fields (\code{type}, \code{name}, \code{values}, \code{default}, \code{validation}, \code{completion}, \code{summary}). Whether a positional is required or optional is expressed in the grammar by wrapping it in \code{optional} or not, keeping a single source of truth.
|
||||
|
||||
\section{Subcommand Symbols}
|
||||
|
||||
\begin{lstlisting}
|
||||
"clone": { "kind": "subcommand", "summary": "Clone repository", "tsf": "git.clone" }
|
||||
\end{lstlisting}
|
||||
|
||||
A subcommand MAY reference an embedded or an external TSF document through its \code{tsf} field; resolution of that reference is implementation-specific.
|
||||
|
||||
\section{Group Symbols}
|
||||
|
||||
A group collects related symbols --- typically options --- so the grammar can refer to them collectively. A group is what backs the conventional \code{[OPTION...]} slot.
|
||||
|
||||
\begin{lstlisting}
|
||||
"commonOptions": {
|
||||
"kind": "group",
|
||||
"summary": "Common options",
|
||||
"members": ["recursive", "force", "verbose"]
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
A group has \code{kind} (\code{group}), a \code{members} array of symbol identifiers, and an optional \code{summary}. A reference to a group is equivalent to a \code{choice} over its members; wrapping that reference in \code{repeat} yields the familiar ``any number of these options, in any order'' behaviour.
|
||||
|
||||
\section{The Synopsis Grammar}
|
||||
|
||||
The \code{synopsis} object describes valid invocations as a tree of nodes. Every node has a \code{type}:
|
||||
|
||||
\begin{tabulary}{\textwidth}{lL}
|
||||
Node & Meaning \\
|
||||
\hline
|
||||
sequence & All children appear in order. Has a \code{children} array. (\code{A B C}) \\
|
||||
choice & Exactly one child appears. Has a \code{children} array. (\code{(A | B | C)}) \\
|
||||
optional & The single \code{child} appears zero or one time. (\code{[A]}) \\
|
||||
repeat & The single \code{child} appears zero or more times. (\code{A...}) \\
|
||||
oneOrMore & The single \code{child} appears at least once; sugar for \code{sequence[A, repeat[A]]}. (\code{A [A...]}) \\
|
||||
reference & References a \code{symbol} by identifier. A reference to a group expands to a \code{choice} over its members. \\
|
||||
\end{tabulary}
|
||||
|
||||
\section{A Complete Example}
|
||||
|
||||
The human-readable form \code{cp [OPTION...] SOURCE DEST} is expressed as:
|
||||
|
||||
\begin{lstlisting}
|
||||
"symbols": {
|
||||
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||
"force": { "kind": "option", "long": "--force", "short": "-f" },
|
||||
"options": { "kind": "group", "members": ["recursive", "force"] },
|
||||
"source": { "kind": "positional", "type": "path", "name": "SOURCE" },
|
||||
"destination": { "kind": "positional", "type": "path", "name": "DEST" }
|
||||
},
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||
{ "type": "reference", "symbol": "source" },
|
||||
{ "type": "reference", "symbol": "destination" }
|
||||
]
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
Because \code{options} is a declared symbol, the \code{[OPTION...]} slot satisfies the rule that every referenced element exists in the symbol table.
|
||||
|
||||
\section{Argument Types}
|
||||
|
||||
The built-in primitive types are \code{string}, \code{integer}, \code{float}, \code{boolean}, \code{path}, \code{file}, \code{directory}, \code{url}, \code{hostname}, \code{user}, \code{group}, \code{command} and \code{enum}.
|
||||
|
||||
A descriptor whose \code{type} is \code{enum} SHALL provide a \code{values} array. The \code{values} array MAY also be supplied for non-\code{enum} types as a soft suggestion list that informs completion without restricting valid input. Unknown types SHALL be interpreted as \code{string}; implementations are RECOMMENDED to emit a diagnostic when they do, since an unknown type is usually an authoring error. Each type carries a default completion behaviour --- \code{path}, \code{file} and \code{directory} complete against the filesystem, \code{user} and \code{group} against the account databases, \code{enum} against its \code{values} --- which a \code{completion} block overrides.
|
||||
|
||||
\section{Completion}
|
||||
|
||||
If a descriptor has no \code{completion} block, completion is derived automatically from its \code{type}. A block overrides that default and names a \code{method}:
|
||||
|
||||
\begin{tabulary}{\textwidth}{lL}
|
||||
method & Notes \\
|
||||
\hline
|
||||
type & Use the default completion implied by the \code{type}. (Implicit when no block is present.) \\
|
||||
enum & Complete from the descriptor's \code{values}. (Implicit when \code{type} is \code{enum}.) \\
|
||||
internal & Use a named provider resolved by the host (a \code{provider} field names it). \\
|
||||
command & Run a command whose output supplies the candidates. \\
|
||||
list & Offer a static inline list of suggestions, without restricting input. \\
|
||||
none & Suppress completion for this value. \\
|
||||
\end{tabulary}
|
||||
|
||||
\section{Constraints}
|
||||
|
||||
Constraints describe relationships not expressible in the grammar, and are listed in the root \code{constraints} array. Symmetric constraints use a \code{symbols} field; asymmetric constraints use \code{subject} and \code{targets}.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{conflicts}{symmetric --- the listed \code{symbols} are mutually exclusive.}
|
||||
\1\inlinesynopsis{requires}{asymmetric --- if \code{subject} is present, every symbol in \code{targets} MUST also be present.}
|
||||
\1\inlinesynopsis{implies}{asymmetric derivation --- if \code{subject} is present, every symbol in \code{targets} is implicitly set (a side effect, not a rejection).}
|
||||
\1\inlinesynopsis{cardinality}{symmetric --- constrains how many of the listed \code{symbols} may appear, via \code{minimum} and \code{maximum}.}
|
||||
\end{outline}
|
||||
|
||||
Three of these (\code{conflicts}, \code{requires}, \code{cardinality}) are validation predicates that decide whether an invocation is well-formed; \code{implies} instead sets a value.
|
||||
|
||||
\begin{lstlisting}
|
||||
"constraints": [
|
||||
{ "type": "conflicts", "symbols": ["stdout", "output"] },
|
||||
{ "type": "cardinality", "symbols": ["create", "extract", "list"],
|
||||
"minimum": 1, "maximum": 1 }
|
||||
]
|
||||
\end{lstlisting}
|
||||
|
||||
\section{Generated Usage and Compatibility}
|
||||
|
||||
Implementations SHOULD generate human-readable usage text from the synopsis grammar; that text is non-authoritative output, the grammar remaining the sole normative description.
|
||||
|
||||
TSF distinguishes additive content, which may be ignored safely, from structural content, which may not. Unknown \emph{fields} on otherwise-valid objects SHALL be ignored, so future minor versions MAY add fields without breaking existing documents or consumers. Unknown \emph{grammar node types} and unknown \emph{symbol kinds} SHALL cause the document to be rejected or to enter an explicitly defined degraded mode, because ignoring them would silently change the set of accepted invocations.
|
||||
|
||||
Authors and hosts that need to attach implementation-specific data SHOULD do so inside the root \code{metadata} object or under field names prefixed with \code{x-}; names without that prefix are reserved for future versions of this specification. Consumers SHOULD report the highest \code{tsfVersion} they support so producers can downgrade gracefully.
|
||||
|
||||
\section{How \thedos\ Uses Synopsis Documents}
|
||||
|
||||
A synopsis document is put to work in three ways. The shell uses it for \emph{tab-completion}: when you press Tab part-way through an argument, the grammar tells the shell whether to offer option flags, a fixed set of \code{enum} values, a subcommand name, or filesystem entries (for the \code{path}, \code{file} and \code{directory} types). The \code{synopsis} command --- and its \code{help} alias --- uses it for \emph{help}, printing the summary, a generated usage line, and the arguments, options and constraints. Authors may further use the \code{validation} and \code{constraints} sections to check an invocation before acting on it.
|
||||
|
||||
\thedos\ finds a command's document the same way it finds the command itself: an app's synopsis sits next to its executable (\code{geturl.js} beside \code{geturl.js.synopsis}), while the built-in coreutils, having no file of their own, keep theirs in \code{\rs{}tvdos\rs{}synopsis}. Aliases resolve to the canonical command's document, so \code{ls} is described by \code{dir.synopsis}. Parsed documents are cached under \code{\rs{}tvdos\rs{}cache\rs{}synopsis} so that repeated completions and help lookups stay fast; the cache is rebuilt on demand and may be deleted at any time.
|
||||
|
||||
|
||||
\chapter{Pipes}
|
||||
|
||||
\index{pipe (DOS)}Pipe is a way to chain the IO of the one program/command into the different programs/commands in series.
|
||||
@@ -230,6 +631,7 @@ Functions:
|
||||
\2\argsynopsis{sread}{returns an empty string}
|
||||
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
||||
\2\argsynopsis{pread}{returns the specified number of zeros}
|
||||
\1\inlinesynopsis{TMP}{a scratch area for temporary files, addressed as \code{\$:\rs{}TMP\rs{}\dots}. Files written here are not expected to persist.}
|
||||
\1\inlinesynopsis{CON}{manipulates the screen text buffer, disregarding the colours}
|
||||
\2\argsynopsis{pread}{reads the texts as bytes.}
|
||||
\2\argsynopsis{bread}{reads the texts as bytes.}
|
||||
@@ -256,24 +658,34 @@ Functions:
|
||||
|
||||
\subsection{Input Events}
|
||||
|
||||
Input events are Javascript array of: $$ [\mathrm{event\ name,\ arg_1,\ arg_2 \cdots arg_n}] $$, where:
|
||||
An input event is a Javascript array of the form $$ [\mathrm{event\ name,\ arg_1,\ arg_2 \cdots arg_n}] $$ where the event name is one of \textbf{key\_down}, \textbf{key\_change}, \textbf{mouse\_down}, \textbf{mouse\_up}, \textbf{mouse\_move} or \textbf{mouse\_wheel}. Every event ends with the current key-press buffer (\code{keycode0} through \code{keycode7}), so a handler can detect modifier keys held during a mouse action.
|
||||
|
||||
\begin{outline}
|
||||
\1event name --- one of following: \textbf{key\_down}, \textbf{mouse\_down}, \textbf{mouse\_move}
|
||||
\1arguments for \textbf{key\_down}:
|
||||
\2\argsynopsis{\argN{1}}{Key Symbol (string) of the head key}
|
||||
\2\argsynopsis{\argN{1}}{Key symbol (string) of the head key}
|
||||
\2\argsynopsis{\argN{2}}{Repeat count of the key event}
|
||||
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the pressed keys}
|
||||
\1arguments for \textbf{mouse\_down}:
|
||||
\1arguments for \textbf{key\_change} (a key was released):
|
||||
\2\argsynopsis{\argN{1}}{Key symbol (string) of the key that went up}
|
||||
\2\argsynopsis{\argN{2}}{0}
|
||||
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the keys still held down}
|
||||
\1arguments for \textbf{mouse\_down} / \textbf{mouse\_up}:
|
||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||
\2\argsynopsis{\argN{3}}{Always the integer 1.}
|
||||
\2\argsynopsis{\argN{3}}{Button mask: 1 = left, 2 = right, 4 = middle (for \textbf{mouse\_up}, the button that was released)}
|
||||
\2\argsynopsis{\argN{4}..}{The key-press buffer}
|
||||
\1arguments for \textbf{mouse\_move}:
|
||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||
\2\argsynopsis{\argN{3}}{1 if the mouse button is held down (i.e. dragging), 0 otherwise}
|
||||
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame (previous V-blank of the screen)}
|
||||
\2\argsynopsis{\argN{3}}{Currently-held button mask (non-zero while dragging)}
|
||||
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame}
|
||||
\2\argsynopsis{\argN{5}}{Y-position of the mouse cursor on the previous frame}
|
||||
\2\argsynopsis{\argN{6}..}{The key-press buffer}
|
||||
\1arguments for \textbf{mouse\_wheel}:
|
||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||
\2\argsynopsis{\argN{3}}{$-1$ for a wheel notch up, $+1$ for a notch down}
|
||||
\2\argsynopsis{\argN{4}..}{The key-press buffer}
|
||||
\end{outline}
|
||||
|
||||
|
||||
@@ -328,7 +740,8 @@ External libraries are packaged codes with the intention of being re-used by oth
|
||||
External libraries can be stored in following locations:
|
||||
|
||||
\begin{enumerate}
|
||||
\item \code{A:\rs{}tvdos\rs{}include}
|
||||
\item \code{A:\rs{}tvdos\rs{}include}, the home of the libraries bundled with \thedos
|
||||
\item any directory listed in the \code{INCLPATH} variable (configured in \code{commandrc})
|
||||
\item a path relative to the user program
|
||||
\item an absolute path that can be anywhere
|
||||
\end{enumerate}
|
||||
@@ -336,7 +749,7 @@ External libraries can be stored in following locations:
|
||||
and can be loaded by:
|
||||
|
||||
\begin{enumerate}
|
||||
\item \code{let name = require(libraryname)} // no .mjs extension
|
||||
\item \code{let name = require(libraryname)} // no .mjs extension; searches the include directory and INCLPATH
|
||||
\item \code{let name = require(./libraryname)} // the relative path must start with a dot-slash
|
||||
\item \code{let name = require(A:/path/to/library.mjs)} // full path WITH the .mjs extension
|
||||
\end{enumerate}
|
||||
@@ -357,3 +770,240 @@ const BAR = 127
|
||||
// following line exports the function and the variable
|
||||
exports = { foo, BAR }
|
||||
\end{lstlisting}
|
||||
|
||||
|
||||
|
||||
\chapter{Bundled Libraries}
|
||||
|
||||
\index{bundled libraries (DOS)}\thedos\ ships a set of ready-made libraries in \code{A:\rs{}tvdos\rs{}include}. Each is loaded with \code{require} using its base name --- \code{require("fs")}, \code{require("psg")}, and so on --- and returns the object the library exports. This chapter documents each in turn.
|
||||
|
||||
\section{fs --- Filesystem (NodeJS-compatible)}
|
||||
|
||||
\index{fs (library)}\code{fs} wraps the \thedos\ file interface in the familiar NodeJS \code{fs} API. Synchronous (\code{*Sync}) calls, callback-style asynchronous calls, and a \code{promises} namespace are all provided; because the machine has no real concurrency, the ``asynchronous'' calls execute immediately and then invoke the callback or resolve the promise. Binary data is exchanged as \code{Uint8Array}; supplying an encoding (\code{"utf8"}, \code{"binary"}, \dots) exchanges strings instead.
|
||||
|
||||
\begin{lstlisting}
|
||||
let fs = require("fs")
|
||||
let txt = fs.readFileSync("A:/etc/motd", "utf8")
|
||||
fs.writeFileSync("A:/tmp/hello.txt", "hi", "utf8")
|
||||
fs.readdirSync("A:/").forEach(println)
|
||||
\end{lstlisting}
|
||||
|
||||
Frequently used members:
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{readFileSync}[path, options]{reads an entire file.}
|
||||
\1\inlinesynopsis{writeFileSync}[path, data, options]{writes (and truncates) a file.}
|
||||
\1\inlinesynopsis{appendFileSync}[path, data, options]{appends to a file.}
|
||||
\1\inlinesynopsis{existsSync}[path]{whether a path exists.}
|
||||
\1\inlinesynopsis{statSync}[path]{returns a \code{Stats} object.}
|
||||
\1\inlinesynopsis{readdirSync}[path]{lists a directory.}
|
||||
\1\inlinesynopsis{mkdirSync}[path]{creates a directory.}
|
||||
\1\inlinesynopsis{unlinkSync, rmSync, rmdirSync}[path]{remove files / directories.}
|
||||
\1\inlinesynopsis{renameSync, copyFileSync, cpSync}[src, dest]{rename and copy.}
|
||||
\1\inlinesynopsis{openSync, readSync, writeSync, closeSync}{the low-level descriptor calls.}
|
||||
\end{outline}
|
||||
The corresponding callback forms (\code{readFile}, \code{writeFile}, \dots), the \code{promises} namespace, the \code{Stats} and \code{Dirent} classes, and the usual \code{constants} are also exported.
|
||||
|
||||
\section{getopt --- Option Parsing}
|
||||
|
||||
\index{getopt (library)}\code{getopt} is a port of the POSIX \code{getopt()} routine. Construct a \code{BasicParser} from an option string and the argument vector, then call \code{getopt()} repeatedly; each call returns an object \code{\{ option, optarg \}} for the next option, or \code{undefined} at the end. A colon after a letter in the option string means that option takes an argument; long aliases are written in parentheses.
|
||||
|
||||
\begin{lstlisting}
|
||||
let getopt = require("getopt")
|
||||
let parser = new getopt.BasicParser("r(recursive)o:(output)", exec_args)
|
||||
let o
|
||||
while ((o = parser.getopt()) !== undefined) {
|
||||
switch (o.option) {
|
||||
case 'r': /* recursive */ break
|
||||
case 'o': let target = o.optarg; break
|
||||
}
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
\section{gl --- Graphics Library}
|
||||
|
||||
\index{gl (library)}\code{gl} is the same graphics-drawing library installed at boot as the \code{GL} namespace; \code{require("gl")} returns the same set of textures, sprite-sheets and drawing functions. It is documented in full in the \emph{The Graphics Library} chapter.
|
||||
|
||||
\section{net --- Internet Text Fetch}
|
||||
|
||||
\index{net (library)}\code{net} fetches text over the network through an attached HTTP modem, translating ordinary URLs into the form the modem expects.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{isAvailable}[]{true if an HTTP modem is attached.}
|
||||
\1\inlinesynopsis{getHttpDrive}[]{the drive letter bound to the HTTP modem, or null.}
|
||||
\1\inlinesynopsis{fetchText}[url]{fetches a URL and returns the body as a string, or null on failure.}
|
||||
\1\inlinesynopsis{fetchTextOrThrow}[url]{as \code{fetchText}, but throws on failure.}
|
||||
\1\inlinesynopsis{open}[url]{returns a file descriptor backed by the modem, whose \code{sread}/\code{bread} trigger the fetch.}
|
||||
\1\inlinesynopsis{toModemPath}[url]{returns the \code{<drive>:\rs{}<url>} path the fetch would use.}
|
||||
\end{outline}
|
||||
|
||||
\section{pcm --- PCM and ADPCM Decoding}
|
||||
|
||||
\index{pcm (library)}\code{pcm} decodes linear PCM and Microsoft ADPCM into the audio hardware's 8-bit format, resampling to the 32\,kHz hardware rate as needed. It underpins the \code{playpcm} and \code{playwav} players.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{decodeLPCM}[inPtr, outPtr, inputLen, config]{decodes linear PCM. \code{config} gives \code{nChannels}, \code{bitsPerSample}, \code{samplingRate} and \code{blockSize}.}
|
||||
\1\inlinesynopsis{decodeMS\_ADPCM}[inPtr, outPtr, blockSize, config]{decodes one Microsoft-ADPCM block.}
|
||||
\1\inlinesynopsis{HW\_SAMPLING\_RATE}{the hardware sampling rate, 32000.}
|
||||
\end{outline}
|
||||
The sample-conversion helpers \code{s8Tou8}, \code{s16Tou8}, \code{u16Tos16} and \code{randomRound} are exported too.
|
||||
|
||||
\section{psg --- Programmable Sound Generator}
|
||||
|
||||
\index{psg (library)}\code{psg} is a software sound generator: it synthesises classic chiptune waveforms into a buffer and sends the result to a playhead as PCM. It is handy for sound effects and simple music without authoring a full tracker module.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{makeBuffer}[length]{allocates a mixing buffer; \code{makeBufferNative} / \code{freeBufferNative} use native memory.}
|
||||
\1\inlinesynopsis{clearBuffer}[buf, offsetSec, lengthSec]{silences part of a buffer.}
|
||||
\1\inlinesynopsis{makeSquare}{writes a square wave (with duty cycle) into a buffer.}
|
||||
\1\inlinesynopsis{makeTriangle, makeAliasedTriangle, makeAliasedTriangleNES}{triangle-wave variants.}
|
||||
\1\inlinesynopsis{makeNoise}{writes an LFSR noise waveform.}
|
||||
\1\inlinesynopsis{sendBuffer, sendBufferFast}[buf, playhead, offsetSec, lengthSec]{uploads a buffer to a playhead for playback.}
|
||||
\end{outline}
|
||||
The wave generators take an offset, frequency, amplitude, pan and a mixing operation, so several voices can be layered into one buffer.
|
||||
|
||||
\section{taud --- Tracker Module Loading}
|
||||
|
||||
\index{taud (library)}\code{taud} moves Taud music between disk and the tracker hardware. It is intentionally small; authoring Taud music belongs to the separate Taud manual.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{uploadTaudFile}[inFile, songIndex, playhead]{loads one song from a Taud file into the tracker hardware and prepares the given playhead to play it.}
|
||||
\1\inlinesynopsis{captureTrackerDataToFile}[outFile]{writes the current tracker state (samples, instruments, patterns and cue sheet) out to a single-song Taud file.}
|
||||
\end{outline}
|
||||
|
||||
\section{mediadec --- Movie and Still Playback}
|
||||
|
||||
\index{mediadec (library)}\code{mediadec} is the decoding engine behind \code{playmov}. Most apps will not need it --- to simply play a file, run \code{playmov}. It is useful when you want to show a \textbf{short intro movie} or a \textbf{still picture} from inside your own program, in any of the machine's formats (MV1, TEV, TAV, and TAV still pictures), without writing a decoder yourself. It detects the format from the file, sets up the graphics and audio hardware, and decodes one frame at a time under your control.
|
||||
|
||||
\code{open} returns a \emph{decoder}; you then call \code{step} in a loop. Each call advances the stream and returns an event describing what happened; when the event is a \code{frame}, call \code{blit} to put it on screen. Always \code{close} the decoder when done --- it frees its buffers and stops audio. The loop below plays an intro the viewer can skip with Backspace:
|
||||
|
||||
\begin{lstlisting}
|
||||
let mediadec = require("mediadec")
|
||||
let dec = mediadec.open("A:/intro.tav")
|
||||
let ev
|
||||
while ((ev = dec.step()).type != "eof") {
|
||||
if (ev.type == "frame") dec.blit()
|
||||
else sys.sleep(1)
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) break // Backspace: skip the intro
|
||||
}
|
||||
dec.close()
|
||||
\end{lstlisting}
|
||||
|
||||
A \textbf{still picture} (a TAV still, played the same way) produces a single frame and then keeps reporting \code{idle}, so you decode it once, show it, and hold it on screen for as long as you like:
|
||||
|
||||
\begin{lstlisting}
|
||||
let dec = mediadec.open("A:/splash.tap")
|
||||
if (dec.step().type == "frame") dec.blit() // decode and show
|
||||
// ...hold it on screen, then:
|
||||
dec.close()
|
||||
\end{lstlisting}
|
||||
|
||||
The library exports \code{open}; everything else is a method on the decoder it returns (called \code{dec} here):
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{open}[path, options]{opens a movie or still and returns a decoder. \code{path} is a fully-qualified path; \code{options} is an optional settings object, rarely needed.}
|
||||
\1\inlinesynopsis[dec]{step}[]{advances the stream and returns \code{\{ type \}}: \code{"frame"} (a frame is ready --- call \code{blit}), \code{"idle"} (nothing to show yet), \code{"eof"} (finished), or \code{"newfile"} (a concatenated next title started).}
|
||||
\1\inlinesynopsis[dec]{blit}[]{draws the current frame to the screen.}
|
||||
\1\inlinesynopsis[dec]{close}[]{frees buffers, stops audio and restores hardware state.}
|
||||
\1\inlinesynopsis[dec]{pause}[on]{pauses (\code{true}) or resumes (\code{false}) playback.}
|
||||
\1\inlinesynopsis[dec]{setVolume}[v]{sets playback volume, 0--255.}
|
||||
\1\inlinesynopsis[dec]{seekSeconds}[n]{seeks \code{n} seconds forward (positive) or back (negative); best-effort, TAV only.}
|
||||
\1\inlinesynopsis[dec]{cue}[dir]{jumps to the previous (\code{-1}) or next (\code{1}) chapter of a chaptered file.}
|
||||
\end{outline}
|
||||
|
||||
The decoder also exposes \code{dec.info} --- a description of the file, with \code{width}, \code{height}, \code{fps}, \code{totalFrames}, \code{isStill}, \code{hasAudio}, \code{hasSubtitles} and more --- and \code{dec.subtitle}, the currently-active subtitle for files that carry one.
|
||||
|
||||
\section{typesetter --- Rich-text Layout}
|
||||
|
||||
\index{typesetter (library)}\code{typesetter} wraps, aligns and justifies text for the console using a small markup language. It returns an array of strings, each padded to the requested width, ready to print.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{typeset}[text, width, opts]{lays out text to the given width (default: the rest of the current row). \code{opts.defaultAlign} is one of \code{l}, \code{c}, \code{r} or \code{j} (justified).}
|
||||
\1\inlinesynopsis{typesetText}[text, width, defaultAlign]{the same, with the width always given explicitly.}
|
||||
\1\inlinesynopsis{expandEntities}[s]{expands the named entities (glyphs, accidentals, arrows, \code{\ }, \dots).}
|
||||
\end{outline}
|
||||
The markup understands \code{<b>}/\code{<s>} for emphasis, \code{<c>}/\code{<r>}/\code{<l>} for per-line alignment, and \code{<o>} for a hanging-indent box anchored at the cursor column.
|
||||
|
||||
\section{wintex --- TUI Windows}
|
||||
|
||||
\index{wintex (library)}\code{wintex} provides a small text-mode window toolkit: framed, titled windows with their own input and drawing handlers, plus scrolling helpers and a ready-made dialog box.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{WindowObject}[x, y, w, h, inputProcessor, drawContents, title, drawFrame]{constructs a window; the two handler functions receive input events and redraw the contents.}
|
||||
\1\inlinesynopsis{showDialog}[opts]{displays a modal dialog (message, buttons, \dots) and returns the user's choice.}
|
||||
\1\inlinesynopsis{scrollVert, scrollHorz}{compute new cursor and scroll positions for scrollable lists and fields.}
|
||||
\end{outline}
|
||||
|
||||
\section{lfs --- Archive Extraction}
|
||||
|
||||
\index{lfs (library)}\code{lfs} extracts \thedos\ Linear File Strip (\code{.lfs}) archives programmatically, transparently inflating any compressed entries.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{extractOne}[archive, filename]{extracts a single named entry and returns its file descriptor (under \code{\$:\rs{}TMP}).}
|
||||
\1\inlinesynopsis{extractAll}[archive]{unpacks the whole archive and returns the directory descriptor.}
|
||||
\end{outline}
|
||||
Both take an optional \code{autoDecompress} flag (default true) and require a relative-path archive.
|
||||
|
||||
\section{mload --- Bulk File Loading}
|
||||
|
||||
\index{mload (library)}\code{mload} is a single function for packaged apps that need to pre-load resources into memory. Given an array of absolute paths, it loads each into memory and returns an array of the corresponding pointers (or \code{null} where a file could not be loaded).
|
||||
|
||||
\begin{lstlisting}
|
||||
let mload = require("mload")
|
||||
let [fontPtr, sheetPtr] = mload(["A:/app/font.bin", "A:/app/sheet.bin"])
|
||||
\end{lstlisting}
|
||||
|
||||
\section{seqread --- Sequential Disk Reading}
|
||||
|
||||
\index{seqread (library)}\code{seqread} reads a file from a serial-connected disk drive as a stream, which is more efficient than random access for whole-file consumption (media players, archive readers).
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{prepare}[fullPath]{opens a file for sequential reading.}
|
||||
\1\inlinesynopsis{readBytes}[length, ptr]{reads bytes into memory (allocating a buffer if no pointer is given).}
|
||||
\1\inlinesynopsis{readInt, readShort, readOneByte, readFourCC, readString}{read typed values from the stream.}
|
||||
\1\inlinesynopsis{skip}[n]{skips ahead; \code{seek}/\code{rewind} reposition the stream.}
|
||||
\1\inlinesynopsis{getReadCount}[]{the number of bytes read so far.}
|
||||
\end{outline}
|
||||
|
||||
\section{seqreadtape --- Sequential Tape Reading}
|
||||
|
||||
\index{seqreadtape (library)}\code{seqreadtape} is the counterpart to \code{seqread} for high-speed tape devices, addressed as \code{\$:\rs{}TAPE0}\dots\code{\$:\rs{}TAPE3}. It reads in large chunks rather than being limited to the serial block size, and exposes the same reading interface (\code{prepare}, \code{readBytes}, \code{readInt}, \dots, \code{skip}, \code{seek}, \code{rewind}) plus \code{close}, \code{isReady} and \code{getCurrentTapeDevice}.
|
||||
|
||||
\section{font --- Character ROM}
|
||||
|
||||
\index{font (library)}\code{font} replaces the displayed character set by uploading a font file into the graphics adapter's character ROM.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{setLowRom}[fullPath]{loads characters 0--127 from a font file.}
|
||||
\1\inlinesynopsis{setHighRom}[fullPath]{loads characters 128--255 from a font file.}
|
||||
\1\inlinesynopsis{resetLowRom, resetHighRom}[]{restore the default character set.}
|
||||
\end{outline}
|
||||
|
||||
\section{keysym --- Key Symbol Constants}
|
||||
|
||||
\index{keysym (library)}\code{keysym} is a table of named key codes (\code{A}, \code{ENTER}, \code{UP}, \code{SHIFT\_LEFT}, \dots) for use with the \code{input} library's events. Note that these are the keycodes carried by \code{input.withEvent} events, which differ from the character codes returned by \code{con.getch}.
|
||||
|
||||
\begin{lstlisting}
|
||||
let key = require("keysym")
|
||||
input.withEvent((e) => {
|
||||
if (e[0] === "key_down" && e.includes(key.ESCAPE)) quit()
|
||||
})
|
||||
\end{lstlisting}
|
||||
|
||||
\section{tbas --- Terran BASIC Runtime}
|
||||
|
||||
\index{tbas (library)}\code{tbas} is the runtime support library for compiled Terran BASIC programs. It supplies the BASIC built-in functions (\code{PRINT}, \code{SIN}, \code{LEFT}, \dots) and the helpers the BASIC compiler emits. It is loaded automatically by a compiled program and is not normally used by hand; it is documented here only for completeness.
|
||||
|
||||
\section{synopsis --- Command Synopsis Loading}
|
||||
|
||||
\index{synopsis (library)}\code{synopsis} loads and caches TSF \code{.synopsis} documents (see the \emph{Command Synopsis Format} chapter) and answers the questions the shell and the \code{synopsis} command ask of them. It resolves a command name to its document --- an app's sits beside the executable, a coreutil's in \code{A:\rs{}tvdos\rs{}synopsis} --- parses it into a model, and caches the result both in memory and on disk.
|
||||
|
||||
\begin{outline}
|
||||
\1\inlinesynopsis{getCompletion}[command, priorArgs, word]{returns the completion candidates for the word being typed: option flags, enum values, subcommand names, and whether the shell should additionally offer filesystem entries.}
|
||||
\1\inlinesynopsis{getModel}[command]{the parsed model --- summary, options, positional arguments, subcommands and constraints --- or null when the command has no synopsis.}
|
||||
\1\inlinesynopsis{getSummary}[command]{the one-line summary, or null.}
|
||||
\1\inlinesynopsis{getUsage}[command]{a generated usage string, such as \code{cp SOURCE DEST}.}
|
||||
\1\inlinesynopsis{resolveSynopsisPath}[command]{the full path of the command's synopsis document, or null.}
|
||||
\1\inlinesynopsis{registerProvider}[name, fn]{registers an \code{internal} completion provider that \code{fn(word)} supplies candidates for; \code{commands} and \code{envvars} are built in.}
|
||||
\1\inlinesynopsis{clearCache}[]{drops the in-memory caches.}
|
||||
\end{outline}
|
||||
|
||||
37
it2taud.py
37
it2taud.py
@@ -44,7 +44,7 @@ from taud_common import (
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, NOTE_NOTEFADE, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
@@ -55,7 +55,8 @@ from taud_common import (
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
|
||||
encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
CUE_INST_NOP, CUE_INST_HALT, cue_instruction_len,
|
||||
cue_instruction_halt_at,
|
||||
build_project_data, detect_subsongs,
|
||||
IXMP_PAN_NO_OVERRIDE,
|
||||
)
|
||||
@@ -69,7 +70,7 @@ IT_INST_MAGIC = b'IMPI'
|
||||
|
||||
IT_NOTE_OFF = 255
|
||||
IT_NOTE_CUT = 254
|
||||
IT_NOTE_FADE = 246 # treated as key-off
|
||||
IT_NOTE_FADE = 246 # → Taud Note Fade (0x0003): fade by instrument fadeout, sustain kept
|
||||
|
||||
IT_ORD_END = 255
|
||||
IT_ORD_SKIP = 254
|
||||
@@ -709,8 +710,13 @@ def parse_patterns(data: bytes, h: ITHeader) -> list:
|
||||
# ── Note encoding (IT linear 0-119 → Taud pitch units) ───────────────────────
|
||||
|
||||
def encode_note_it(it_note: int) -> int:
|
||||
if it_note == IT_NOTE_OFF or it_note == IT_NOTE_FADE:
|
||||
if it_note == IT_NOTE_OFF:
|
||||
return NOTE_KEYOFF
|
||||
if it_note == IT_NOTE_FADE:
|
||||
# IT "~~~" Note Fade: CHN_NOTEFADE — begins the instrument's volume fadeout
|
||||
# without releasing the sustain loop (Schism effects.c:1505-1509). Distinct from
|
||||
# key-off (0x0001), which lifts sustain. Engine handles it via voice.noteFading.
|
||||
return NOTE_NOTEFADE
|
||||
if it_note == IT_NOTE_CUT:
|
||||
return NOTE_CUT
|
||||
if 0 <= it_note <= 119:
|
||||
@@ -1868,29 +1874,26 @@ def _build_song_payload(h: ITHeader, patterns_rows_template: list,
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
last_active = -1
|
||||
n_emit = min(len(cue_list), NUM_CUES)
|
||||
len_cue_count = 0
|
||||
for cue_idx, ci in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
for cue_idx in range(n_emit):
|
||||
ci = cue_list[cue_idx]
|
||||
base_pat = cue_idx * C
|
||||
pat_idx_list = [pat_remap[base_pat + vi] for vi in range(C)]
|
||||
clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS
|
||||
if clen < PATTERN_ROWS:
|
||||
if cue_idx == n_emit - 1:
|
||||
# Final cue: play its own length then HALT. "Halt at x" preserves the
|
||||
# partial length (a short terminal pattern halts at `clen` instead of
|
||||
# running the full 64-row padding); a full-length cue emits a plain HALT.
|
||||
instr = cue_instruction_halt_at(clen)
|
||||
elif clen < PATTERN_ROWS:
|
||||
instr = cue_instruction_len(clen)
|
||||
len_cue_count += 1
|
||||
else:
|
||||
instr = CUE_INST_NOP
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pat_idx_list, instr)
|
||||
last_active = cue_idx
|
||||
|
||||
if last_active >= 0:
|
||||
b30_existing = sheet[last_active * CUE_SIZE + 30]
|
||||
if b30_existing == CUE_INST_LEN:
|
||||
vprint(f" [{song_label}] warning: last active cue {last_active} had LEN; "
|
||||
f"replaced with HALT (partial tail at song terminus)")
|
||||
sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT
|
||||
sheet[last_active * CUE_SIZE + 31] = 0x00
|
||||
else:
|
||||
if n_emit == 0:
|
||||
sheet[30] = CUE_INST_HALT
|
||||
if len_cue_count:
|
||||
vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) "
|
||||
|
||||
2696
midi2taud.py
Normal file
2696
midi2taud.py
Normal file
File diff suppressed because it is too large
Load Diff
183
taud_common.py
183
taud_common.py
@@ -99,16 +99,19 @@ SAMPLE_LEN_LIMIT = 65535
|
||||
NOTE_NOP = 0x0000
|
||||
NOTE_KEYOFF = 0x0001
|
||||
NOTE_CUT = 0x0002
|
||||
NOTE_NOTEFADE = 0x0003 # IT Note Fade ("~~~"): CHN_NOTEFADE — fade by instrument fadeout, sustain kept
|
||||
NOTE_FASTFADE = 0x0004 # ~0.3 s note-fade (SF2 exclusiveClass choke; fluid_voice_kill_excl)
|
||||
TAUD_C4 = 0x5000 # The audio engine's Middle C
|
||||
|
||||
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
|
||||
# Per terranmon.txt §"Cue Sheet":
|
||||
# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
|
||||
# 00000001 (HALT) end of song
|
||||
# 00000000 (NOP) default 64-row cue
|
||||
# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
|
||||
# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
|
||||
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern
|
||||
# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
|
||||
# 00000001 00000000 (HALT) play the full pattern then stop (end of song)
|
||||
# 00000001 01xxxxxx (HALT x) play x rows then stop (x = 0 ⇒ full length)
|
||||
# 00000000 (NOP) default 64-row cue
|
||||
# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
|
||||
# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
|
||||
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern
|
||||
CUE_INST_NOP = 0x00
|
||||
CUE_INST_HALT = 0x01
|
||||
CUE_INST_LEN = 0x02
|
||||
@@ -357,6 +360,44 @@ def cue_instruction_len(rows: int) -> tuple:
|
||||
return (CUE_INST_LEN, (rows - 1) & 0x3F)
|
||||
|
||||
|
||||
def cue_instruction_halt_at(rows: int) -> tuple:
|
||||
"""Build the 2-byte 'Halt at x' cue instruction (terranmon.txt §"Cue Sheet").
|
||||
|
||||
Plays `rows` rows of the pattern (1..64) then stops playback. Encoding is
|
||||
byte30 = 0x01, byte31 = 0b01xxxxxx where x is the row count itself (NOT
|
||||
rows-1 like LEN); x = 0 means "full length". A full-length halt (rows >= 64)
|
||||
is therefore emitted as a plain HALT (byte31 = 0) so existing full-pattern
|
||||
final cues stay byte-identical.
|
||||
"""
|
||||
if not 1 <= rows <= 64:
|
||||
raise ValueError(f"halt-at row count must be 1..64, got {rows}")
|
||||
if rows >= 64:
|
||||
return (CUE_INST_HALT, 0x00)
|
||||
return (CUE_INST_HALT, 0x40 | (rows & 0x3F))
|
||||
|
||||
|
||||
def last_note_cue_index(pat_bin: bytes, num_cues: int, num_channels: int) -> int:
|
||||
"""Index of the last cue holding an *actual* note, or -1 if none.
|
||||
|
||||
`pat_bin` is the raw (pre-dedup) pattern binary, laid out as
|
||||
`num_cues × num_channels` consecutive PATTERN_BYTES blocks, channel-minor
|
||||
within each cue (block = (cue*num_channels + ch)*PATTERN_BYTES; each row is
|
||||
8 bytes, note = little-endian u16 at offset 0). Special notes — NOP,
|
||||
key-off, cut, note-fade, fast-fade (values 0..NOTE_FASTFADE) — are not
|
||||
notes; only pitches above that count. Used to trim trailing note-free cues
|
||||
(e.g. a MIDI conversion's final all-key-off release cue).
|
||||
"""
|
||||
for cue in range(num_cues - 1, -1, -1):
|
||||
for ch in range(num_channels):
|
||||
block = (cue * num_channels + ch) * PATTERN_BYTES
|
||||
for row in range(PATTERN_ROWS):
|
||||
rb = block + row * 8
|
||||
note = pat_bin[rb] | (pat_bin[rb + 1] << 8)
|
||||
if note > NOTE_FASTFADE:
|
||||
return cue
|
||||
return -1
|
||||
|
||||
|
||||
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
|
||||
"""Consolidate identical 512-byte Taud patterns into a single copy.
|
||||
|
||||
@@ -545,18 +586,94 @@ def _name_table_blob(names) -> bytes:
|
||||
|
||||
# ── Ixmp encoder (terranmon.txt §Project Data → Ixmp) ───────────────────────
|
||||
|
||||
# Per-patch byte layout. Field offsets must match AudioJSR223Delegate.uploadInstrumentPatches
|
||||
# (Kotlin parser) and terranmon.txt "Ixmp. Instrument extra samples".
|
||||
IXMP_PATCH_SIZE = 31
|
||||
# Per-patch byte layout. Field offsets / version flags must match
|
||||
# AudioJSR223Delegate.uploadInstrumentPatches (Kotlin parser) and terranmon.txt
|
||||
# "Ixmp. Instrument extra samples". Patches are VARIABLE LENGTH since 2026-06-13:
|
||||
# a version byte (feature bit-flags) + 30 common bytes + optional blocks. A
|
||||
# version byte with only the 'i' bit set yields the legacy 31-byte record.
|
||||
IXMP_COMMON_SIZE = 31 # version byte + 30 common bytes (legacy record size)
|
||||
IXMP_PAN_NO_OVERRIDE = 0xFF
|
||||
IXMP_DNV_NO_OVERRIDE = 0
|
||||
IXMP_VIBWAVE_NO_OVERRIDE = 0xFF
|
||||
|
||||
# Version byte feature bits (terranmon.txt 0b x00Pfpvi).
|
||||
IXMP_VER_I = 0x01 # always set (version 1)
|
||||
IXMP_VER_V = 0x02 # has volume envelope block
|
||||
IXMP_VER_P = 0x04 # has panning envelope block
|
||||
IXMP_VER_F = 0x08 # has filter envelope block
|
||||
IXMP_VER_PITCH = 0x10 # has pitch envelope block ('P')
|
||||
IXMP_VER_X = 0x80 # has extra-base-info block
|
||||
|
||||
|
||||
# "Perceptually Significant Octet to Decibel Table" → linear gain (octet → amplitude).
|
||||
# The canonical perceptual loudness curve shared by the engine (AudioAdapter.META_MIX_GAIN),
|
||||
# the Metainstrument layer mix volume, and the base/patch initialAttenuation octet.
|
||||
# Octet 0 = silence, 159 = unity (0 dB), 255 = +24 dB.
|
||||
META_GAIN = (
|
||||
0.0, 5e-05, 5.6e-05, 6.3e-05, 7.1e-05, 7.9e-05, 8.9e-05, 0.0001,
|
||||
0.000112, 0.000126, 0.000141, 0.000158, 0.000178, 0.0002, 0.000224, 0.000251,
|
||||
0.000282, 0.000316, 0.000355, 0.000398, 0.000447, 0.000501, 0.000562, 0.000631,
|
||||
0.000708, 0.000794, 0.000891, 0.001, 0.001122, 0.001259, 0.001413, 0.001585,
|
||||
0.001778, 0.001995, 0.002239, 0.002512, 0.002818, 0.003162, 0.003548, 0.003981,
|
||||
0.004467, 0.005012, 0.005623, 0.00631, 0.007079, 0.007943, 0.008913, 0.01,
|
||||
0.01122, 0.012589, 0.014125, 0.015849, 0.017783, 0.019953, 0.022387, 0.025119,
|
||||
0.028184, 0.031623, 0.035481, 0.039811, 0.044668, 0.050119, 0.056234, 0.063096,
|
||||
0.066834, 0.070795, 0.074989, 0.079433, 0.08414, 0.089125, 0.094406, 0.1,
|
||||
0.105925, 0.112202, 0.11885, 0.125893, 0.133352, 0.141254, 0.149624, 0.158489,
|
||||
0.16788, 0.177828, 0.188365, 0.199526, 0.211349, 0.223872, 0.237137, 0.251189,
|
||||
0.258523, 0.266073, 0.273842, 0.281838, 0.290068, 0.298538, 0.307256, 0.316228,
|
||||
0.325462, 0.334965, 0.344747, 0.354813, 0.365174, 0.375837, 0.386812, 0.398107,
|
||||
0.409732, 0.421697, 0.43401, 0.446684, 0.459727, 0.473151, 0.486968, 0.501187,
|
||||
0.508452, 0.515822, 0.523299, 0.530884, 0.53858, 0.546387, 0.554307, 0.562341,
|
||||
0.570493, 0.578762, 0.587151, 0.595662, 0.604296, 0.613056, 0.621942, 0.630957,
|
||||
0.640103, 0.649382, 0.658795, 0.668344, 0.678032, 0.68786, 0.697831, 0.707946,
|
||||
0.718208, 0.728618, 0.73918, 0.749894, 0.760764, 0.771792, 0.782979, 0.794328,
|
||||
0.805842, 0.817523, 0.829373, 0.841395, 0.853591, 0.865964, 0.878517, 0.891251,
|
||||
0.90417, 0.917276, 0.930572, 0.944061, 0.957745, 0.971628, 0.985712, 1.0,
|
||||
1.014495, 1.029201, 1.044119, 1.059254, 1.074608, 1.090184, 1.105987, 1.122018,
|
||||
1.138282, 1.154782, 1.171521, 1.188502, 1.20573, 1.223207, 1.240938, 1.258925,
|
||||
1.277174, 1.295687, 1.314468, 1.333521, 1.352851, 1.372461, 1.392355, 1.412538,
|
||||
1.433013, 1.453784, 1.474857, 1.496236, 1.517924, 1.539927, 1.562248, 1.584893,
|
||||
1.607867, 1.631173, 1.654817, 1.678804, 1.703139, 1.727826, 1.752871, 1.778279,
|
||||
1.804056, 1.830206, 1.856735, 1.883649, 1.910953, 1.938653, 1.966754, 1.995262,
|
||||
2.053525, 2.113489, 2.175204, 2.238721, 2.304093, 2.371374, 2.440619, 2.511886,
|
||||
2.585235, 2.660725, 2.73842, 2.818383, 2.900681, 2.985383, 3.072557, 3.162278,
|
||||
3.254618, 3.349654, 3.447466, 3.548134, 3.651741, 3.758374, 3.868121, 3.981072,
|
||||
4.216965, 4.466836, 4.731513, 5.011872, 5.308844, 5.623413, 5.956621, 6.309573,
|
||||
6.683439, 7.079458, 7.498942, 7.943282, 8.413951, 8.912509, 9.440609, 10.0,
|
||||
10.592537, 11.220185, 11.885022, 12.589254, 13.335214, 14.125375, 14.962357, 15.848932,
|
||||
)
|
||||
|
||||
|
||||
def atten_cb_to_octet(atten_cb: float) -> int:
|
||||
"""SF2 initialAttenuation (centibels, ≥0) → nearest [META_GAIN] octet (159 = 0 dB /
|
||||
unity). Returns 159 for ~0 attenuation and never 0 — octet 0 is the engine's "unset"
|
||||
sentinel (treated as unity), so emitting it for a real value would silence the voice."""
|
||||
if atten_cb <= 0:
|
||||
return 159
|
||||
g = 10.0 ** (-atten_cb / 200.0)
|
||||
return min(range(1, 160), key=lambda o: abs(META_GAIN[o] - g))
|
||||
|
||||
|
||||
def _encode_env_block(env: dict) -> bytes:
|
||||
"""One v/p/f/P envelope block: LOOP word + SUSTAIN word + 25 (value, minifloat)
|
||||
node pairs = 54 bytes. `env` keys: 'loop' (u16), 'sustain' (u16), 'nodes'
|
||||
(list of (value 0..255, minifloat_index 0..255); padded/truncated to 25)."""
|
||||
out = bytearray(struct.pack('<HH', int(env.get('loop', 0)) & 0xFFFF,
|
||||
int(env.get('sustain', 0)) & 0xFFFF))
|
||||
nodes = list(env.get('nodes', []))
|
||||
while len(nodes) < 25:
|
||||
nodes.append((nodes[-1][0] if nodes else 0, 0))
|
||||
for val, mf in nodes[:25]:
|
||||
out.append(int(val) & 0xFF)
|
||||
out.append(int(mf) & 0xFF)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_ixmp_patch(p: dict) -> bytes:
|
||||
"""Encode a single patch dict into 31 bytes.
|
||||
"""Encode one variable-length patch.
|
||||
|
||||
Expected keys (numeric values; defaults are applied for missing optional fields):
|
||||
Common keys (numeric; defaults applied for missing optionals):
|
||||
pitch_start, pitch_end : Taud 4096-TET noteVal (Uint16)
|
||||
volume_start, volume_end : 0..63 (Uint8)
|
||||
sample_ptr : Uint32 (sample bin offset)
|
||||
@@ -569,6 +686,16 @@ def encode_ixmp_patch(p: dict) -> bytes:
|
||||
default_note_volume : Uint8 IT-scaled (0 = no override, default 0)
|
||||
vibrato_speed/sweep/depth/rate: Uint8 (default 0)
|
||||
vibrato_waveform : Uint8 (0..7 or 0xFF for no override, default 0xFF)
|
||||
|
||||
Optional blocks (presence sets the version flag; appended in spec order x,v,p,f,P):
|
||||
extra : dict {fadeout (u16), default_cutoff (u16), default_resonance (u16),
|
||||
initial_attenuation (u8 dB-table octet),
|
||||
filter_sf_mode (bool — flag1 bit 0; SoundFont filter params)}
|
||||
→ 'x' block (15 bytes)
|
||||
vol_env : env-block dict → 'v' block (54 bytes)
|
||||
pan_env : env-block dict → 'p' block
|
||||
filter_env : env-block dict → 'f' block
|
||||
pitch_env : env-block dict → 'P' block
|
||||
"""
|
||||
pitch_start = max(0, min(0xFFFF, int(p['pitch_start'])))
|
||||
pitch_end = max(0, min(0xFFFF, int(p['pitch_end'])))
|
||||
@@ -581,9 +708,23 @@ def encode_ixmp_patch(p: dict) -> bytes:
|
||||
loop_end = max(0, min(0xFFFF, int(p.get('loop_end', 0))))
|
||||
rate = max(0, min(0xFFFF, int(p.get('sampling_rate', 0))))
|
||||
detune = max(-0x8000, min(0x7FFF, int(p.get('sample_detune', 0))))
|
||||
return struct.pack(
|
||||
|
||||
extra = p.get('extra')
|
||||
vol_e = p.get('vol_env')
|
||||
pan_e = p.get('pan_env')
|
||||
filt_e = p.get('filter_env')
|
||||
pit_e = p.get('pitch_env')
|
||||
|
||||
ver = IXMP_VER_I
|
||||
if extra is not None: ver |= IXMP_VER_X
|
||||
if vol_e is not None: ver |= IXMP_VER_V
|
||||
if pan_e is not None: ver |= IXMP_VER_P
|
||||
if filt_e is not None: ver |= IXMP_VER_F
|
||||
if pit_e is not None: ver |= IXMP_VER_PITCH
|
||||
|
||||
common = struct.pack(
|
||||
'<BHHBBIHHHHHhBBBBBBBB',
|
||||
1, # patch version
|
||||
ver, # patch version / feature flags
|
||||
pitch_start, pitch_end,
|
||||
vol_start, vol_end,
|
||||
sample_ptr,
|
||||
@@ -600,6 +741,22 @@ def encode_ixmp_patch(p: dict) -> bytes:
|
||||
int(p.get('vibrato_rate', 0)) & 0xFF,
|
||||
int(p.get('vibrato_waveform', IXMP_VIBWAVE_NO_OVERRIDE)) & 0xFF,
|
||||
)
|
||||
out = bytearray(common)
|
||||
if extra is not None: # 'x' block (15 bytes), spec order
|
||||
# flags1 bit 0 (m): 0 = IT filter params, 1 = SoundFont (Fc cents / Q centibels).
|
||||
flags1 = 0x01 if extra.get('filter_sf_mode') else 0x00
|
||||
out += struct.pack('<I', flags1) # Bit32 extra-feature-flags 1..32
|
||||
out += struct.pack('<I', 0) # Bit32 extra-feature-flags 33..64 (reserved)
|
||||
out += struct.pack('<H', int(extra.get('fadeout', 0)) & 0xFFFF)
|
||||
out += struct.pack('<H', int(extra.get('default_cutoff', 0xFFFF)) & 0xFFFF)
|
||||
out += struct.pack('<H', int(extra.get('default_resonance', 0xFFFF)) & 0xFFFF)
|
||||
# per-patch initialAttenuation as a dB-table octet (159 = unity); 0 = unset sentinel.
|
||||
out.append(int(extra.get('initial_attenuation', 0)) & 0xFF)
|
||||
if vol_e is not None: out += _encode_env_block(vol_e)
|
||||
if pan_e is not None: out += _encode_env_block(pan_e)
|
||||
if filt_e is not None: out += _encode_env_block(filt_e)
|
||||
if pit_e is not None: out += _encode_env_block(pit_e)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_ixmp_payload(patches_by_inst: dict) -> bytes:
|
||||
|
||||
563
terranmon.txt
563
terranmon.txt
@@ -1986,7 +1986,7 @@ Endianness: little
|
||||
|
||||
TSVM Audio Adapter is consisted of 4 playheads, each playhead is capable of playing one PCM or Tracker track.
|
||||
|
||||
Synchronisation between playheads are not guaranteed. Do not play music in multiple tracks.
|
||||
Synchronisation between playheads are not guaranteed. Do not play a music across multiple playheads.
|
||||
|
||||
|
||||
Memory Space
|
||||
@@ -2069,6 +2069,10 @@ their pan / pf envelopes evaluated by the current engine — re-convert
|
||||
from source.
|
||||
|
||||
0 Uint32 Sample Pointer
|
||||
- If the high 16 bits are 0xFFFF — i.e. (samplePointer >> 16) == 0xFFFF, a
|
||||
value no real sample pointer can take (the 8 MB sample bin caps pointers
|
||||
at 0x7FFFFF) — this instrument is a "Metainstrument" and bytes 0..3 are
|
||||
reinterpreted per "Metainstrument definition" below.
|
||||
4 Uint16 Sample length
|
||||
6 Uint16 Sampling rate at C4 (note number 0x5000)
|
||||
8 Uint16 Play Start (usually 0 but not always)
|
||||
@@ -2166,9 +2170,11 @@ from source.
|
||||
beyond it; multiply by (255/64) and round. The XM samplewise
|
||||
volume goes into byte 196.
|
||||
172 Uint8 Volume Fadeout low bits
|
||||
173 Bit8 Volume Fadeout high bits
|
||||
0b 0000 ffff
|
||||
173 Bit8 Volume Fadeout high bits & Filter interpretation mode
|
||||
0b 000m ffff
|
||||
f: Volume Fadeout high bits (low nibble of byte 173; high nibble reserved, must be zero)
|
||||
m-unset: Default Cutoff (offset 182) and Default Resonance (offset 183) works like ImpulseTracker
|
||||
m-set: Default Cutoff (offset 182 << 8 | offset 252) and Default Resonance (offset 183 << 8 | offset 253) works like SoundFont (absolute cents for Fc, height above DC gain in centibels for Q)
|
||||
* Combined 12-bit unsigned value (range 0..4095). The engine maintains
|
||||
a per-voice fadeoutVolume ∈ [0, 1] initialised to 1.0 on note-on, and
|
||||
while the voice is in key-off or NNA Note-Fade state applies once per
|
||||
@@ -2218,7 +2224,7 @@ from source.
|
||||
* FastTracker2 has instrumentwise config (0..255)
|
||||
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
|
||||
176 Uint8 Vibrato sweep
|
||||
* FastTracker2 instrument config
|
||||
* FastTracker2 instrument config (0..255)
|
||||
177 Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
|
||||
* ImpulseTracker has samplewise default pan and instrumentwise default pan, and they must be taken into account because Taud has no samplewise config
|
||||
178 Uint16 Pitch-pan centre (4096-TET note value)
|
||||
@@ -2226,11 +2232,22 @@ from source.
|
||||
181 Uint8 Pan swing (0..255 full range)
|
||||
182 Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
|
||||
183 Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
|
||||
184 Uint16 Sample detune (in 4096-TET unit) (FT2 finetune scale need to be rescaled accordingly)
|
||||
184 Sint16 Sample detune (in 4096-TET unit) (FT2 finetune scale need to be rescaled accordingly)
|
||||
186 Bit8 Instrument Flag
|
||||
0b 000 www nn
|
||||
0b 00N www nn
|
||||
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
|
||||
ww: Vibrato waveform (IT: sample config, FT2: instrument config). 00: sine, 01: ramp-down saw, 10: square, 11: random, 100: ramp-up saw (FT2 only)
|
||||
Nnn: New note action (non-traditional). 100: key lift
|
||||
* Key Lift behaves *exactly* like a MIDI key-up: on key-off the volume-envelope
|
||||
playhead jumps straight to the sustain-end node, so the post-sustain (release)
|
||||
nodes play immediately from there — instead of the IT semantics where key-off
|
||||
merely releases the SUSTAIN wrap and the playhead still walks the remaining
|
||||
pre-sustain (hold/decay) nodes first, ringing held-style instruments like a
|
||||
depressed sustain pedal. Applies wherever key-off is delivered: pattern
|
||||
KEY_OFF (0x0001), the NNA ghost spawned on a new-note, DCA Note Off, and
|
||||
past-note S $71. Instruments without a volume-envelope SUSTAIN region are
|
||||
unaffected (there is no release boundary to jump to). Primary consumer:
|
||||
midi2taud melodic instruments (SF2 ADSR import).
|
||||
187 Uint8 Vibrato Depth (0..255 full range)
|
||||
* ImpulseTracker has range of 0..32 ON THE SAMPLE SETTINGS; multiply by (255/32) then round to int
|
||||
* FastTracker2 has range of 0..16; multiply by (255/16) then round to int
|
||||
@@ -2310,9 +2327,351 @@ from source.
|
||||
files SHOULD treat default_note_volume == 0 as "field not present"
|
||||
and fall back to row_default = 63 — preserving the pre-fix behaviour
|
||||
for legacy files where IGV already carries sample.vol.
|
||||
197..255 Reserved (59 bytes free for future per-instrument fields)
|
||||
197 Bit16 Filter/Pitch envelope LOOP word ; if offset 19 specified 'Pitch', this field is automatically 'filter', and vice-versa. Contradictory 'm' flag is INVALID
|
||||
* Always-active wrap region for the pitch/filter envelope.
|
||||
0b 00P_sssss_mcb_eeeee
|
||||
s (bits 12..8) : loop start index
|
||||
e (bits 4..0) : loop end index
|
||||
b (bit 5) : enable the LOOP
|
||||
c (bit 6) : envelope carry
|
||||
m (bit 7) : mode — 0 = pitch envelope, 1 = filter envelope ; this is NOT inverted from offset 19
|
||||
P (bit 13) : envelope present in source. Same semantics as the
|
||||
pan envelope's P bit: gates whether the mixer applies
|
||||
envelope-driven pitch / cutoff at all. P=0 ⇒ no
|
||||
envelope contribution (sample plays at its own pitch /
|
||||
default cutoff). P=1 ⇒ evaluate every tick regardless
|
||||
of LOOP.b / SUSTAIN.b.
|
||||
(bits 14..15 reserved)
|
||||
199 Bit16 Filter/Pitch envelope SUSTAIN word ; if offset 19 specified 'Pitch', this field is automatically 'filter', and vice-versa
|
||||
* Same encoding as offset 189, applied to the filter/pitch envelope.
|
||||
0b 000_sssss_00b_eeeee
|
||||
201 Bit16x25 Filter/Pitch envelopes ; if offset 19 specified 'Pitch', this field is automatically 'filter', and vice-versa
|
||||
Byte 1: Value (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
251 Uint8 initialAttenuation ; static per-instrument gain as a "Perceptually Significant Octet
|
||||
to Decibel Table" octet (octet 159 = 0 dB / unity; −6 dB = octet 111), same table as the
|
||||
Metainstrument layer mix volume. **0 = unity (the unset sentinel)** so legacy files
|
||||
(these bytes were reserved/zero) are unaffected. Applied by the mixer as a
|
||||
velocity-INDEPENDENT amplitude multiplier; NOT folded into the volume envelope, so the
|
||||
envelope keeps full 0..63 resolution. A per-patch Ixmp 'x' block carries its own override.
|
||||
252 Uint8 Default Cutoff low bits (SoundFont mode; see offset 73)
|
||||
253 Uint8 Default Resonance low bits (SoundFont mode; see offset 73)
|
||||
254..255 Reserved (2 bytes free for future per-instrument fields)
|
||||
|
||||
### Metainstrument definition
|
||||
|
||||
A Metainstrument occupies a normal 256-byte instrument-bin slot and is
|
||||
referenced by a pattern cell exactly like any instrument. It carries no sample
|
||||
of its own; instead it triggers a list of LAYERS, each a normal instrument
|
||||
sounded simultaneously at its own mix level, detune and (note x volume)
|
||||
sub-range. Bytes 0..3 alias the base instrument's Uint32 Sample Pointer; the
|
||||
0xFFFF identifier sits in its high 16 bits so the aliased pointer reads
|
||||
0xFFFF_ll_tt — a value no real sample pointer can take (see byte 0 of the
|
||||
instrument record). Layer records begin at byte 4.
|
||||
|
||||
0 Uint8 Metainstrument type
|
||||
- 0: layered (every layer whose rectangle contains the trigger sounds together)
|
||||
1 Uint8 Metainstrument length (layer count, 1..25 — the record holds at most 25)
|
||||
2 Bit16 Metainstrument identifier
|
||||
- 0xFFFF
|
||||
4 * Repetition of (one 10-byte record per layer):
|
||||
Uint8 Instrument index (MUST be a normal instrument, never a Metainstrument)
|
||||
Uint8 Mix volume according to "Perceptually Significant Octet to Decibel Table" (octet 159 = 0 dB / unity)
|
||||
Sint16 Sample detune (in 4096-TET unit)
|
||||
Uint16 Pitch start ; note-range low, 4096-TET noteVal (same scale as pattern-cell note); full range = 0x0000
|
||||
Uint16 Pitch end (inclusive) ; note-range high; full range = 0xFFFF
|
||||
Uint8 Volume start ; velocity/volume-range low, 0..0x3F; full range = 0x00
|
||||
Uint8 Volume end (inclusive) ; velocity/volume-range high, 0..0x3F; full range = 0x3F
|
||||
|
||||
Notes:
|
||||
a. The four range fields define a rectangle over the same Pitch-Volume space
|
||||
as an Ixmp patch (Ixmp Notes 4/5). A layer fires ONLY when the trigger's
|
||||
(noteVal, rowVolume) falls inside its rectangle; a layer whose rectangle
|
||||
excludes the trigger stays silent for that note. A whole-range layer uses
|
||||
0x0000..0xFFFF / 0x00..0x3F. This is how key/velocity-conditional layering
|
||||
is expressed (e.g. a bell layer only in the top octaves, or an accent
|
||||
layer only above some velocity); the SF2 velocity axis is the usual
|
||||
round(velocity * 63/127) (Ixmp Note 4a).
|
||||
b. Layer rectangles MAY overlap one another — that is the entire purpose of a
|
||||
Metainstrument. Every layer whose rectangle contains the trigger sounds
|
||||
simultaneously, each on its own voice. (Overlap remains INVALID WITHIN a
|
||||
single normal instrument's Ixmp patch list, Ixmp Note 1 — layering lives
|
||||
here instead. The same normal instrument may be listed as several layers
|
||||
at different detune/mix to reproduce SF2 detune-stacks and duplicate zones
|
||||
without spending extra instrument slots.)
|
||||
c. Mix volume SCALES that layer's output; it multiplies with the trigger's
|
||||
row volume / velocity (it does not replace it). Sample detune is ADDED to
|
||||
the trigger noteVal and to the layer instrument's own sampleDetune.
|
||||
d. The voices spawned by one trigger form a voice group sharing the note's
|
||||
lifecycle: KEY_OFF, tone portamento (Gxx), channel volume (Mxx) and
|
||||
panning apply to every layer. Each layer still resolves its OWN NNA / DCT
|
||||
/ DCA on key-off, so a key-lift melodic layer and a ring-to-tail layer can
|
||||
coexist in one Metainstrument.
|
||||
e. Recursion is forbidden: a layer's Instrument index MUST resolve to a normal
|
||||
instrument. The engine ignores (skips) any layer pointing at another
|
||||
Metainstrument or at instrument 0.
|
||||
f. Voice budget: a single Metainstrument trigger costs up to `length` voices
|
||||
against the mixer pool.
|
||||
|
||||
#### Perceptually Significant Octet to Decibel Table
|
||||
|
||||
Octet,Decibel-fullscale,Delta
|
||||
255,24,0.5
|
||||
254,23.5,0.5
|
||||
253,23,0.5
|
||||
252,22.5,0.5
|
||||
251,22,0.5
|
||||
250,21.5,0.5
|
||||
249,21,0.5
|
||||
248,20.5,0.5
|
||||
247,20,0.5
|
||||
246,19.5,0.5
|
||||
245,19,0.5
|
||||
244,18.5,0.5
|
||||
243,18,0.5
|
||||
242,17.5,0.5
|
||||
241,17,0.5
|
||||
240,16.5,0.5
|
||||
239,16,0.5
|
||||
238,15.5,0.5
|
||||
237,15,0.5
|
||||
236,14.5,0.5
|
||||
235,14,0.5
|
||||
234,13.5,0.5
|
||||
233,13,0.5
|
||||
232,12.5,0.5
|
||||
231,12,0.25
|
||||
230,11.75,0.25
|
||||
229,11.5,0.25
|
||||
228,11.25,0.25
|
||||
227,11,0.25
|
||||
226,10.75,0.25
|
||||
225,10.5,0.25
|
||||
224,10.25,0.25
|
||||
223,10,0.25
|
||||
222,9.75,0.25
|
||||
221,9.5,0.25
|
||||
220,9.25,0.25
|
||||
219,9,0.25
|
||||
218,8.75,0.25
|
||||
217,8.5,0.25
|
||||
216,8.25,0.25
|
||||
215,8,0.25
|
||||
214,7.75,0.25
|
||||
213,7.5,0.25
|
||||
212,7.25,0.25
|
||||
211,7,0.25
|
||||
210,6.75,0.25
|
||||
209,6.5,0.25
|
||||
208,6.25,0.25
|
||||
207,6,0.125
|
||||
206,5.875,0.125
|
||||
205,5.75,0.125
|
||||
204,5.625,0.125
|
||||
203,5.5,0.125
|
||||
202,5.375,0.125
|
||||
201,5.25,0.125
|
||||
200,5.125,0.125
|
||||
199,5,0.125
|
||||
198,4.875,0.125
|
||||
197,4.75,0.125
|
||||
196,4.625,0.125
|
||||
195,4.5,0.125
|
||||
194,4.375,0.125
|
||||
193,4.25,0.125
|
||||
192,4.125,0.125
|
||||
191,4,0.125
|
||||
190,3.875,0.125
|
||||
189,3.75,0.125
|
||||
188,3.625,0.125
|
||||
187,3.5,0.125
|
||||
186,3.375,0.125
|
||||
185,3.25,0.125
|
||||
184,3.125,0.125
|
||||
183,3,0.125
|
||||
182,2.875,0.125
|
||||
181,2.75,0.125
|
||||
180,2.625,0.125
|
||||
179,2.5,0.125
|
||||
178,2.375,0.125
|
||||
177,2.25,0.125
|
||||
176,2.125,0.125
|
||||
175,2,0.125
|
||||
174,1.875,0.125
|
||||
173,1.75,0.125
|
||||
172,1.625,0.125
|
||||
171,1.5,0.125
|
||||
170,1.375,0.125
|
||||
169,1.25,0.125
|
||||
168,1.125,0.125
|
||||
167,1,0.125
|
||||
166,0.875,0.125
|
||||
165,0.75,0.125
|
||||
164,0.625,0.125
|
||||
163,0.5,0.125
|
||||
162,0.375,0.125
|
||||
161,0.25,0.125
|
||||
160,0.125,0.125
|
||||
159,0,0.125
|
||||
158,-0.125,0.125
|
||||
157,-0.25,0.125
|
||||
156,-0.375,0.125
|
||||
155,-0.5,0.125
|
||||
154,-0.625,0.125
|
||||
153,-0.75,0.125
|
||||
152,-0.875,0.125
|
||||
151,-1,0.125
|
||||
150,-1.125,0.125
|
||||
149,-1.25,0.125
|
||||
148,-1.375,0.125
|
||||
147,-1.5,0.125
|
||||
146,-1.625,0.125
|
||||
145,-1.75,0.125
|
||||
144,-1.875,0.125
|
||||
143,-2,0.125
|
||||
142,-2.125,0.125
|
||||
141,-2.25,0.125
|
||||
140,-2.375,0.125
|
||||
139,-2.5,0.125
|
||||
138,-2.625,0.125
|
||||
137,-2.75,0.125
|
||||
136,-2.875,0.125
|
||||
135,-3,0.125
|
||||
134,-3.125,0.125
|
||||
133,-3.25,0.125
|
||||
132,-3.375,0.125
|
||||
131,-3.5,0.125
|
||||
130,-3.625,0.125
|
||||
129,-3.75,0.125
|
||||
128,-3.875,0.125
|
||||
127,-4,0.125
|
||||
126,-4.125,0.125
|
||||
125,-4.25,0.125
|
||||
124,-4.375,0.125
|
||||
123,-4.5,0.125
|
||||
122,-4.625,0.125
|
||||
121,-4.75,0.125
|
||||
120,-4.875,0.125
|
||||
119,-5,0.125
|
||||
118,-5.125,0.125
|
||||
117,-5.25,0.125
|
||||
116,-5.375,0.125
|
||||
115,-5.5,0.125
|
||||
114,-5.625,0.125
|
||||
113,-5.75,0.125
|
||||
112,-5.875,0.125
|
||||
111,-6,0.25
|
||||
110,-6.25,0.25
|
||||
109,-6.5,0.25
|
||||
108,-6.75,0.25
|
||||
107,-7,0.25
|
||||
106,-7.25,0.25
|
||||
105,-7.5,0.25
|
||||
104,-7.75,0.25
|
||||
103,-8,0.25
|
||||
102,-8.25,0.25
|
||||
101,-8.5,0.25
|
||||
100,-8.75,0.25
|
||||
99,-9,0.25
|
||||
98,-9.25,0.25
|
||||
97,-9.5,0.25
|
||||
96,-9.75,0.25
|
||||
95,-10,0.25
|
||||
94,-10.25,0.25
|
||||
93,-10.5,0.25
|
||||
92,-10.75,0.25
|
||||
91,-11,0.25
|
||||
90,-11.25,0.25
|
||||
89,-11.5,0.25
|
||||
88,-11.75,0.25
|
||||
87,-12,0.5
|
||||
86,-12.5,0.5
|
||||
85,-13,0.5
|
||||
84,-13.5,0.5
|
||||
83,-14,0.5
|
||||
82,-14.5,0.5
|
||||
81,-15,0.5
|
||||
80,-15.5,0.5
|
||||
79,-16,0.5
|
||||
78,-16.5,0.5
|
||||
77,-17,0.5
|
||||
76,-17.5,0.5
|
||||
75,-18,0.5
|
||||
74,-18.5,0.5
|
||||
73,-19,0.5
|
||||
72,-19.5,0.5
|
||||
71,-20,0.5
|
||||
70,-20.5,0.5
|
||||
69,-21,0.5
|
||||
68,-21.5,0.5
|
||||
67,-22,0.5
|
||||
66,-22.5,0.5
|
||||
65,-23,0.5
|
||||
64,-23.5,0.5
|
||||
63,-24,1
|
||||
62,-25,1
|
||||
61,-26,1
|
||||
60,-27,1
|
||||
59,-28,1
|
||||
58,-29,1
|
||||
57,-30,1
|
||||
56,-31,1
|
||||
55,-32,1
|
||||
54,-33,1
|
||||
53,-34,1
|
||||
52,-35,1
|
||||
51,-36,1
|
||||
50,-37,1
|
||||
49,-38,1
|
||||
48,-39,1
|
||||
47,-40,1
|
||||
46,-41,1
|
||||
45,-42,1
|
||||
44,-43,1
|
||||
43,-44,1
|
||||
42,-45,1
|
||||
41,-46,1
|
||||
40,-47,1
|
||||
39,-48,1
|
||||
38,-49,1
|
||||
37,-50,1
|
||||
36,-51,1
|
||||
35,-52,1
|
||||
34,-53,1
|
||||
33,-54,1
|
||||
32,-55,1
|
||||
31,-56,1
|
||||
30,-57,1
|
||||
29,-58,1
|
||||
28,-59,1
|
||||
27,-60,1
|
||||
26,-61,1
|
||||
25,-62,1
|
||||
24,-63,1
|
||||
23,-64,1
|
||||
22,-65,1
|
||||
21,-66,1
|
||||
20,-67,1
|
||||
19,-68,1
|
||||
18,-69,1
|
||||
17,-70,1
|
||||
16,-71,1
|
||||
15,-72,1
|
||||
14,-73,1
|
||||
13,-74,1
|
||||
12,-75,1
|
||||
11,-76,1
|
||||
10,-77,1
|
||||
9,-78,1
|
||||
8,-79,1
|
||||
7,-80,1
|
||||
6,-81,1
|
||||
5,-82,1
|
||||
4,-83,1
|
||||
3,-84,1
|
||||
2,-85,1
|
||||
1,-86,1
|
||||
0,-Infinity,N/A
|
||||
|
||||
TODO:
|
||||
[x] implement Instrument Flag, Vibrato Depth, Vibrato Rate, other samplewise/instrumentwise changes to it2taud and audio engine
|
||||
@@ -2386,7 +2745,7 @@ TODO:
|
||||
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
|
||||
[x] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
|
||||
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
|
||||
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effrect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
|
||||
Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects
|
||||
[x] 8 MB sample RAM via 512k banks
|
||||
[x] remove panning mode selection and replace global panning rule to equal energy, also move the 'ff' flags to bit 0..1
|
||||
@@ -2411,8 +2770,106 @@ TODO:
|
||||
[ ] establish hooks for the interrupts
|
||||
[x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
||||
follow the ImpulseTracker design first, then improve from there
|
||||
[?] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
|
||||
[ ] Needs .it and .xm test file to complete it2taud and xm2taud
|
||||
[x] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
|
||||
[x] taut.js not reading extra samples added by Ixmp process for some reason, which means the samples are on the sample bin but taut.js is not reading them?
|
||||
[x] Ixmp version 2, supporting per-patch ADSR
|
||||
For UI concerns, taut_instredit.js will take care of it (aka problem for later)
|
||||
[x] .sf2 import module (for generic use, including "Import instrument from soundfont" and midi2taud conversion)
|
||||
[x] Midi2Taud using .mid and .sf2 as input, trim unused samples and Ixmp patches
|
||||
[x] .sf2 specific resample handling
|
||||
1. If length exceeds 65535 samples, calculate resampling.
|
||||
2. If calculated resampling >= 32000, use that.
|
||||
3. If not, resample at 32000. If there is no loop defined, then loop the last 8192 samples (converter SHOULD NOT take that number at face value; perform waveform analysis to derive a smoother loop; converter MAY use that number as a starting number) and modify the fade value such that it decays to zero after 10 or so seconds of firing.
|
||||
[x] Faithful .sf2 "release segment": Set NNA to 'Note Fade' (incl. drumkits), and make sure Volume Fadeout to have a correct number derived from the SF2 timecent unit (it seems SF2 defines envelope floor as 100 dB; needs check)
|
||||
* DONE 2026-06-14. Floor CONFIRMED 100 dB (sfspec24.txt:1934-1941: releaseVolEnv ramps a constant 100 dB per its timecent value, "until 100dB attenuation were reached"). midi2taud.py now: (a) byte 186 NNA = Note Fade (0b11) for every instrument incl. drum kits (was melodic Key-Lift 0b100000 / drum Continue 0b10); (b) the vol-env no longer carries a release leg — it ENDS at the sustain node and the engine holds there on key-off (AudioAdapter.kt:1697-1701 holds a non-zero terminator, doesn't cut); (c) Volume Fadeout (base bytes 172-173 AND per-patch Ixmp 'x' block) = the release segment, fade_sec = releaseVolEnv·(1000−sus_cb)/1000 (the sustain-level → 100 dB-floor time), fadeStep = 2560/(fade_sec·bpm0) so the linear fade completes in that wall-clock time. Per-patch 'x' now also emits when only the release differs (faithful per-layer release). The engine's Key-Lift feature is unchanged (still used by KeyLiftTest); midi2taud simply stopped emitting it. See _zone_fadeout / _adsr_to_env in midi2taud.py.
|
||||
[x] SF2 filter still sounds way too muffled?
|
||||
[x] Drum notes get eaten (E2M1.mid)
|
||||
* DONE 2026-06-14. Root cause: midi2taud ignored SF2 exclusiveClass (gen 57). SGM's
|
||||
STANDARD 1 puts the closed/pedal/open hi-hats (keys 42/44/46) in class 1; without the
|
||||
choke the open hi-hat's multi-second tail (≈4.9 s) washes over the whole beat and
|
||||
buries the other hits. FluidSynth kills same-class voices on note-on
|
||||
(fluid_synth_kill_by_exclusive_class → fluid_voice_kill_excl, a fast −2000-timecent
|
||||
release ≈ 0.3 s). Fix in two parts: (a) midi2taud.py parses gen 57, and per drum
|
||||
channel chokes each note at the next same-class onset — emitting the new fast-fade
|
||||
note 0x0004 (apply_exclusive_class + emit_cells pass 2b; allocate_voices holds the
|
||||
choked voice foreground until the choke); (b) the engine implements note 0x0004
|
||||
(startFastFade): a ~0.3 s note-fade while the sample keeps playing, distinct from
|
||||
^^CUT's hard stop. Headless coverage: devtests/ixmp/FastFadeTest. Verified on
|
||||
E2M1 (open hat no longer rings >0.3 s; crashes, not in a class, still ring out).
|
||||
[ ] midi2taud: toggleable option for disabling filter for percussions [default: on]
|
||||
- Anything on bank 127 and 128 (usually asso siated with ch 10)
|
||||
- GeneralMIDI instruments 113..128
|
||||
[x] midi2taud: instrument fadeout (release) is significantly longer than Fluidsynth
|
||||
* DONE 2026-06-14. _zone_fadeout (midi2taud.py) now scales fade_sec by
|
||||
_RELEASE_PERCEPTUAL_SCALE = 0.25, bringing the fadeout in line with FluidSynth's perceived
|
||||
release. fadeStep comes out ~4× larger (faster fade). I kept the IT/FT2 engine path untouched
|
||||
— it's shared, byte-faithful tracker behaviour and must stay linear-amplitude; the
|
||||
compensation belongs on the encoder side. 0.25 targets the ~−22 dB "release ended" point.
|
||||
If you find sustained pads now cut a touch short (their long tails are more noticeable),
|
||||
nudging it toward 0.30–0.35 lengthens the tail without returning to the old over-long
|
||||
behaviour.
|
||||
[x] auto-set optimal-ish Tickspeed and RPB using MIDI Time Signature events and note analysis. Break pattern when Time Signature changes.
|
||||
* DONE 2026-06-14. midi2taud.py now parses the time-signature meta event (FF 58) and
|
||||
auto-sets the grid by DEFAULT (--rpb/--speed default to auto; passing one pins that
|
||||
axis and auto-fits the other, pass both to override). auto_timing() picks F = rpb·speed
|
||||
fine-ticks/beat to (a) represent the finest onset subdivision actually used
|
||||
(_detect_subdivision: smallest 1/D-of-quarter grid covering >=95% of note onsets,
|
||||
D in {1,2,3,4,6,8,12,16}), (b) keep every tempo inside the Taud BPM register [25,280],
|
||||
and (c) anchor at the proven 24-fts/beat grid (smallest multiple of D that is >=24), so
|
||||
plain material reproduces the old speed 6 / rpb 4. Lexicographic key: init-tempo-fits >
|
||||
fewest-clamped-tempos > prefer-rpb-4 (rows = beats×rpb, so rpb caps pattern count;
|
||||
speed is "free" sub-row + tempo precision) > closeness-to-F_want > exact-grid > near
|
||||
speed 6. Verified rpb4/speed6 on Onestop / E1M1 / E2M1 / flourish / keep_on_rolling /
|
||||
pokemon-theme. RPB bump (final step, both axes auto only): a bend-heavy (>=24 non-centre
|
||||
bend events AND >=0.25/note) OR many-polyphony (peak simultaneous notes >= 10) song with
|
||||
rpb < 8 gets rpb doubled / speed halved (F=rpb·speed, hence tempo, UNCHANGED) up to rpb 8,
|
||||
so more rows host key-offs / chokes / bend-G / channel-M and fewer are eaten by same-row &
|
||||
per-cell-slot collisions. Guarded by a cue/pattern-budget estimate so it can't flip a long
|
||||
dense song into a hard error (pin --rpb 4 to opt out). Measured on Onestop: key-offs
|
||||
absorbed 1856→1574, bend segments 266→552. All six demo MIDIs are dense → rpb8/speed3.
|
||||
Pattern breaking: plan_cues() breaks a cue at every time-sig change and
|
||||
packs each section into whole-bar cues (largest multiple of the bar length that fits in
|
||||
64 rows) via the LEN cue instruction (constant 4/4 still = 64-row cues; 7/8 flourish =
|
||||
56-row cues; mid-song change starts the new section on a fresh cue). The project-data
|
||||
sMet block gets Primary beat division = rows per NOTATED beat (the time-sig denominator,
|
||||
= round(rpb·4/2^dpow): 4 for x/4, 2 for x/8 — always a divisor of the bar so the highlight
|
||||
stays bar-aligned; rpb=rows-per-quarter would drift on 7/8 since 14%4!=0) and Secondary =
|
||||
rows per bar, so the tracker's bar/beat highlight lines up. sMet notation = 120 (12-TET;
|
||||
MIDI is 12-TET) — REQUIRED now that taut.js honours notation on load (the taud_common
|
||||
default 0 = "Raw format" would show hex note numbers). build_pattern_bin now takes
|
||||
(cue_starts, cue_lens) instead of a flat 64-row chunking.
|
||||
taut.js side: loadTaudSongList now reads beat_pri/beat_sec from sMet (were skipped) into
|
||||
songsMeta.songs[i]; applySongBeatDiv() + applySongPitchPreset() apply per-song beat
|
||||
divisions AND notation/pitch-preset on BOTH initial open and switchSong (initial open
|
||||
previously left both at the 4/16 + 12-TET defaults — the latent pitch-preset gap is now
|
||||
closed too). Only midi2taud emits sMet; the other 2taud converters omit it → 12-TET default.
|
||||
[x] Taut UI commit
|
||||
- Inst > Gen.1 > sample binding: ~~~....[two doubledots] et al. (n extra samples)
|
||||
- Inst > Gen.2 > filter: IT/SF mode toggle (which also need to redefine slider range and their writebacks as IT takes 8-bit and SF takes 16-bit values)
|
||||
- Samples playblobs: only active for actually playing samples
|
||||
- Samples playcursor: true cursors for actually playing samples
|
||||
[x] implement note-fade (0x0003) and wire it to it2taud
|
||||
* DONE 2026-06-14. Engine: note word 0x0003 sets voice.noteFading (IT CHN_NOTEFADE,
|
||||
Schism effects.c:1505-1509) — the instrument's own activeFadeoutStep drives
|
||||
fadeoutVolume to 0 in the line ~3676 fade path while sustain loop + vol envelope
|
||||
keep running; no applyKeyLift (sustain kept), no rate override (unlike fast fade
|
||||
0x0004); a zero fadeout rings on, matching IT. Honours sub-row S$Dx delay (delayed
|
||||
dispatch L3453). it2taud: IT_NOTE_FADE (246) now emits NOTE_NOTEFADE (0x0003)
|
||||
instead of collapsing to key-off; NOTE_NOTEFADE added to taud_common.py.
|
||||
[x] taut.js quit with TypeError: Cannot read property 'terminatorIdx' of undefined:
|
||||
TypeError: Cannot read property 'terminatorIdx' of undefined
|
||||
at drawEnvelopeCursor (<eval>:5312:22)
|
||||
at drawInstrumentsContents (<eval>:4919:41)
|
||||
at Object.onClick (<eval>:4984:17)
|
||||
at dispatchMouseEvent (<eval>:6275:53)
|
||||
at <eval>:6633:13
|
||||
at Object.withEvent (<eval>:1168:27)
|
||||
at _tvdosExec$microtone$tvdos$bin$taut$js (<eval>:6632:11)
|
||||
at execApp (<eval>:1457:16)
|
||||
at Object.execute (<eval>:893:38)
|
||||
at <eval>:867:31
|
||||
[ ] Timbres of Heaven's Overdriven Gt decays EXTREMELY SLOWLY even after the fix
|
||||
[ ] Some ways to decouple Sample+Inst and patterns into separate files (tsvm-doom needs separate file access; samplepack can be uploaded once on init)
|
||||
|
||||
TODO - list of demo songs that MUST ship with Microtone:
|
||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||
@@ -2438,8 +2895,10 @@ notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using t
|
||||
Special values:
|
||||
|
||||
note 0x0000: no-op
|
||||
note 0x0001: key-off
|
||||
note 0x0002: note cut
|
||||
note 0x0001: key-off (====)
|
||||
note 0x0002: note cut (^^^^)
|
||||
note 0x0003: note fade (~~~~)
|
||||
note 0x0004: fast fade (vvvv)
|
||||
note 0x0010..0x001F: Interrupt 0..F (notation: Int0..IntF) — reserved interrupt slots; engine has no default handler.
|
||||
|
||||
inst 0: no instrument change
|
||||
@@ -2562,7 +3021,8 @@ Play Head Flags
|
||||
1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy
|
||||
00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns)
|
||||
00000001 00000000 - Halt (HALT ) - Play the full length of the pattern then stop the playback
|
||||
00000001 00xxxxxx - Fadeout (FADOUT) - Gradually decrease global volume such that at row 0bxxxxxx it reaches zero, then stop the playback
|
||||
00000001 01xxxxxx - Halt at x (HALT00) - Play the specified length of the pattern then stop the playback. x = 0 is identical to regular HALT. (decoded by AudioAdapter as of 2026-06-14; emitted as the final cue by midi2taud / it2taud / xm2taud so a partial last bar halts at its own length)
|
||||
00000001 00xxxxxx - Fadeout (FADE00) - Gradually decrease global volume such that at row 0bxxxxxx it reaches zero, then stop the playback
|
||||
00000000 - No operation
|
||||
|
||||
65536..131071 RW: PCM Sample buffer
|
||||
@@ -2780,7 +3240,15 @@ prefixes:
|
||||
Uint8 Instrument ID
|
||||
Uint24 Count of patches
|
||||
** Repetition of:
|
||||
Uint8 Patch definition version (always 1)
|
||||
Bit8 Patch definition version
|
||||
Version numbers are essentially feature bit-flags, where:
|
||||
0b x00 Pfpvi
|
||||
i: always set (version 1)
|
||||
v: has volume envelopes
|
||||
p: has pan envelopes
|
||||
f: has filter envelopes
|
||||
P: has pitch envelopes
|
||||
x: extra base info
|
||||
Uint16 Pitch start ; Taud 4096-TET noteVal (same scale as pattern-cell note)
|
||||
Uint16 Pitch end (inclusive)
|
||||
Uint8 Volume start ; 0..63
|
||||
@@ -2791,9 +3259,9 @@ prefixes:
|
||||
Uint16 Play Start (usually 0 but not always)
|
||||
Uint16 Loop Start (can be smaller than Play Start)
|
||||
Uint16 Loop End
|
||||
Uint16 samplingRate ; per-sample C-5 speed; same encoding as base instrument byte 6-7
|
||||
Uint16 samplingRate ; per-sample C4 (note 0x5000) speed; same encoding as base instrument byte 6-7
|
||||
Int16 sampleDetune ; per-sample fine detune in signed 4096-TET units (XM finetune; IT samples leave 0)
|
||||
Uint8 loopMode ; same encoding as base instrument byte 14 (bits 0-1 = mode, bit 2 = sustain loop)
|
||||
Uint8 loopMode and loop-is-sustain ; identical base instrument byte 14 (bits 0-1 = mode, bit 2 = sustain loop)
|
||||
Uint8 defaultPan ; per-sample default pan (0..255; 0x80 = centre); 0xFF = "no override"
|
||||
Uint8 defaultNoteVolume ; per-sample default note volume (0..255 scaled from IT 0..64); 0 = "no override"
|
||||
Uint8 vibratoSpeed ; per-sample auto-vibrato (mirrors base inst byte 175)
|
||||
@@ -2801,18 +3269,63 @@ prefixes:
|
||||
Uint8 vibratoDepth ; per-sample auto-vibrato (mirrors base inst byte 187)
|
||||
Uint8 vibratoRate ; per-sample auto-vibrato (mirrors base inst byte 188)
|
||||
Uint8 vibratoWaveform ; bits 0-2 only (mirrors instrumentFlag bits 2-4); 0xFF = "no override"
|
||||
* Patch definition flag 'x'
|
||||
Bit32 Extra feature flags 1..32
|
||||
0b 0000 000m ; 0b 0000 0000 ; 0b 0000 0000 ; 0b 0000 0000
|
||||
m-unset: filter params (Fc and Q) works like ImpulseTracker
|
||||
m-set: filter params (Fc and Q) works like SoundFont
|
||||
Bit32 Extra feature flags 33..64 (reserved; keep it as all-unset)
|
||||
Uint16 Volume Fadeout ; same encoding as identical base instrument byte 172-173
|
||||
Uint16 Default cutoff ; identical to base instrument byte 182 and 252
|
||||
Uint16 Default resonance ; identical to base instrument byte 183 and 253
|
||||
Uint8 SF2 Initial Attenuation according to "Perceptually Significant Octet to Decibel Table" (octet 159 = 0 dB, attenuation by 6 dB = octet 111). 0 = unity (unset sentinel). Overrides the base record's byte-251 attenuation for this patch; applied as a velocity-independent gain, NOT folded into the envelope.
|
||||
* Patch definition flag 'v'
|
||||
Bit16 Volume envelope LOOP word ; identical to base instrument byte 15..16
|
||||
Bit16 Volume envelope SUSTAIN word ; identical to base instrument byte 189..190
|
||||
Bit16x25 Volume envelopes ; identical to base instrument byte 21..70
|
||||
* Patch definition flag 'p'
|
||||
Bit16 Panning envelope LOOP word ; identical to base instrument byte 17..18
|
||||
Bit16 Panning envelope SUSTAIN word ; identical to base instrument byte 191..192
|
||||
Bit16x25 Panning envelopes ; identical to base instrument byte 71..120
|
||||
* Patch definition flag 'f'
|
||||
Bit16 Filter envelope LOOP word ; identical to base instrument byte 19..20
|
||||
Bit16 Filter envelope SUSTAIN word ; identical to base instrument byte 193..194
|
||||
Bit16x25 Filter envelopes ; identical to base instrument byte 121..170
|
||||
* Patch definition flag 'P'
|
||||
Bit16 Pitch envelope LOOP word ; identical to base instrument byte 197..198 (the 2nd pf-env slot)
|
||||
Bit16 Pitch envelope SUSTAIN word ; identical to base instrument byte 199..200
|
||||
Bit16x25 Pitch envelopes ; identical to base instrument byte 201..250
|
||||
|
||||
Notes:
|
||||
0. this extension is made to support IT/XM instrument spec as well as partial compatibility to SF2 (Soundfont format two)
|
||||
1. Envelopes (vol/pan/pf), fadeout, NNA / DCT / DCA, pitch-pan, filter, IGV and any other "instrument-scope" parameters all follow the base instrument definition. Only sample-scope parameters (the patch fields listed above) override.
|
||||
2. overlapping regions are considered INVALID
|
||||
3. multiple Ixmp blocks pointing the same instrument are considered INVALID
|
||||
4. IT and XM does not define volumes. Keep the Volume rectangle at 0..63 — the engine clamps to that range when matching.
|
||||
5. SF2 does define volumes (because MIDI). Convert it using `round(velocity * (63/127))`
|
||||
On import, `initialAttenuation`, filters and ADSR shall be ignored
|
||||
6. Patch selection at trigger time walks the patch list in order; the first patch whose rectangle contains the trigger's (noteVal, rowVolume) wins. When no patch matches, the base instrument's sample fields are used unchanged.
|
||||
7. Sentinel values listed above ("no override") let a patch defer to the base instrument for a given field — used by converters that don't carry per-sample data for one of the dimensions (e.g. SF2 ignoring per-sample pan).
|
||||
8. Total per-patch payload is 31 bytes.
|
||||
1. overlapping regions are considered INVALID (layered samples must use Metainstrument)
|
||||
2. multiple Ixmp blocks pointing the same instrument are considered INVALID
|
||||
3. IT and XM does not define volumes. Keep the Volume rectangle at 0..63 — the engine clamps to that range when matching.
|
||||
3a. SF2 does define volumes (because MIDI). The velocity axis of the rectangle is
|
||||
`round(velocity * 63/127)`. SF2 ADSR and filters are now carried per-patch (the
|
||||
v/f/P/x blocks) instead of being ignored. SF2 `initialAttenuation` (a per-zone
|
||||
static gain) has no dedicated field — converters fold it into the per-patch
|
||||
VOLUME envelope's node peak: scale every 0..63 node by `10^(-attenuation_cB/200)`.
|
||||
This multiplies with the velocity-driven note volume (it can NOT live in
|
||||
defaultNoteVolume, which an explicit V column overrides at trigger time), so
|
||||
velocity layers differ in level as well as ADSR shape.
|
||||
4. Patch selection at trigger time walks the patch list in order; the first patch whose rectangle contains the trigger's (noteVal, rowVolume) wins. When no patch matches, the base instrument's sample fields are used unchanged.
|
||||
5. Sentinel values listed above ("no override") let a patch defer to the base instrument for a given field — used by converters that don't carry per-sample data for one of the dimensions (e.g. SF2 ignoring per-sample pan).
|
||||
6. ON-WIRE BLOCK ORDER. The version byte's flag bits gate which optional blocks
|
||||
follow the fixed common fields. When present they ALWAYS appear in this order
|
||||
regardless of bit numbering: x, v, p, f, P. A decoder walks them in that order,
|
||||
skipping any whose flag bit is 0. (Block sizes: x = 12 bytes; each of v/p/f/P =
|
||||
2 (LOOP) + 2 (SUSTAIN) + 50 (25 nodes) = 54 bytes.) A version byte with only the
|
||||
'i' bit set yields the legacy 31-byte record (version byte + 30 common bytes,
|
||||
no blocks) — byte-identical to pre-2026-06-13 Ixmp patches.
|
||||
7. PITCH vs FILTER envelopes. The 'f' (filter) and 'P' (pitch) blocks map onto the
|
||||
base instrument's TWO pf-envelope slots: 'f' → byte 19..20/121..170/193..194
|
||||
(m=filter), 'P' → byte 197..198/201..250/199..200 (m=pitch). A patch may carry
|
||||
either, both, or neither; each independently overrides the base inst's
|
||||
corresponding role and the other role falls through to the base inst. This is
|
||||
how SF2's single modulation envelope (which drives modEnvToPitch AND
|
||||
modEnvToFilterFc simultaneously) is represented; IT/XM instruments use only one
|
||||
pf-env (pitch XOR filter) and leave the other slot absent (P bit = 0).
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ import net.torvald.tsvm.peripheral.MP2Env
|
||||
*
|
||||
* Note values: 0x4000 = C3 (sample's native pitch), 4096 steps per octave.
|
||||
* Empty row: note = 0x0000 (no trigger). Note sentinels (0x0000..0x001F): 0x0000 = no-op,
|
||||
* 0x0001 = key-off, 0x0002 = note cut, 0x0010..0x001F = Int0..IntF (reserved interrupts).
|
||||
* 0x0001 = key-off, 0x0002 = note cut, 0x0003 = note fade (IT-style, by instrument fadeout),
|
||||
* 0x0004 = fast fade, 0x0010..0x001F = Int0..IntF (reserved interrupts).
|
||||
* Valid playable notes are 0x0020..0xFFFF. All 256 instrument slots (0-255) are valid.
|
||||
*
|
||||
* ## How to upload PCM audio into a playhead
|
||||
@@ -67,6 +68,17 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
fun stop(playhead: Int) { getPlayhead(playhead)?.isPlaying = false }
|
||||
fun isPlaying(playhead: Int) = getPlayhead(playhead)?.isPlaying
|
||||
|
||||
/** Lowest-numbered playhead that is not currently playing, so a player app can
|
||||
* "occupy" an idle playhead instead of always clobbering playhead 0. Returns
|
||||
* [fallback] when every playhead is busy (or no audio device is present). */
|
||||
fun getFreePlayhead(fallback: Int): Int {
|
||||
val playheads = getFirstSnd()?.playheads ?: return fallback
|
||||
for (i in playheads.indices) {
|
||||
if (!playheads[i].isPlaying) return i
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// fun setPosition(playhead: Int, pos: Int) { getPlayhead(playhead)?.position = pos and 65535 }
|
||||
fun getPosition(playhead: Int) = getPlayhead(playhead)?.position
|
||||
|
||||
@@ -205,6 +217,25 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
return v.samplePos
|
||||
}
|
||||
|
||||
/** Sample pointer (byte offset into the 8 MB pool) of the sample the voice is ACTUALLY
|
||||
* sounding right now — the resolved Ixmp patch sample, not just the base record. Returns
|
||||
* -1 when the voice is inactive. Together with [getVoiceSampleLength] this is the (ptr,len)
|
||||
* identity of the deduped sample, so visualisers can light only the truly-playing sample of
|
||||
* a multisample instrument instead of every sample the instrument references. */
|
||||
fun getVoiceSamplePtr(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.activeSamplePtr
|
||||
}
|
||||
|
||||
/** Sample length (bytes) of the sample the voice is actually sounding — see [getVoiceSamplePtr].
|
||||
* Returns 0 when inactive (a real sample is always ≥ 1 byte, so 0 is an unambiguous "none"). */
|
||||
fun getVoiceSampleLength(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.activeSampleLength
|
||||
}
|
||||
|
||||
/** Volume-envelope segment index — i.e. the node the voice is currently moving *away* from
|
||||
* (the next node it will hit is index + 1). Returns -1 when inactive. */
|
||||
fun getVoiceEnvVolIndex(playhead: Int, voice: Int): Int {
|
||||
@@ -232,17 +263,17 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
return v.envPanTimeSec
|
||||
}
|
||||
|
||||
/** Pitch/filter-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
/** Pitch-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPitchIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPfIndex
|
||||
return v.envPitchIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pitch/filter-envelope segment. */
|
||||
/** Seconds elapsed into the current pitch-envelope segment. */
|
||||
fun getVoiceEnvPitchTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPfTimeSec
|
||||
return v.envPitchTimeSec
|
||||
}
|
||||
|
||||
/** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */
|
||||
@@ -258,26 +289,26 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload up to 192 bytes defining instrument `slot` (0-255). */
|
||||
/** Upload up to 256 bytes defining instrument `slot` (0-255). (The record was
|
||||
* widened from 192 to 256 bytes on 2026-05-06; the old cap silently dropped
|
||||
* the pan/pf SUSTAIN-word tails, DCT/DCA and the Default Note Volume byte.) */
|
||||
fun uploadInstrument(slot: Int, bytes: IntArray) {
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.let { inst ->
|
||||
for (i in 0 until minOf(192, bytes.size)) inst.setByte(i, bytes[i] and 0xFF)
|
||||
val rec = IntArray(256)
|
||||
for (i in 0 until minOf(256, bytes.size)) rec[i] = bytes[i] and 0xFF
|
||||
inst.loadRecord(rec) // detects the Metainstrument sentinel; else per-byte fields
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload an Ixmp "extra samples" block for instrument [slot] (0-255). The payload is
|
||||
* a flat byte array of `count × 31` patch records — see terranmon.txt "Ixmp. Instrument
|
||||
* extra samples" for the on-wire field layout. Passing an empty array clears any
|
||||
* previously-installed patches on this instrument. */
|
||||
/** Upload an Ixmp "extra samples" block for instrument [slot] (0-255). Patches are
|
||||
* VARIABLE LENGTH (since 2026-06-13): each begins with a version byte (feature
|
||||
* bit-flags 0b x00Pfpvi) + 30 common bytes, optionally followed by the x/v/p/f/P
|
||||
* blocks in that order — see terranmon.txt "Ixmp. Instrument extra samples". A
|
||||
* version byte with only the 'i' bit set is the legacy 31-byte record. Passing an
|
||||
* empty array clears any previously-installed patches on this instrument. */
|
||||
fun uploadInstrumentPatches(slot: Int, bytes: IntArray) {
|
||||
val inst = getFirstSnd()?.instruments?.get(slot and 0xFF) ?: return
|
||||
val recordSize = 31
|
||||
if (bytes.isEmpty() || bytes.size < recordSize) {
|
||||
inst.extraPatches = null
|
||||
return
|
||||
}
|
||||
val count = bytes.size / recordSize
|
||||
if (count == 0) { inst.extraPatches = null; return }
|
||||
if (bytes.size < 31) { inst.extraPatches = null; return }
|
||||
fun u8 (o: Int) = bytes[o] and 0xFF
|
||||
fun u16(o: Int) = (bytes[o] and 0xFF) or ((bytes[o + 1] and 0xFF) shl 8)
|
||||
fun s16(o: Int): Int { val v = u16(o); return if (v >= 0x8000) v - 0x10000 else v }
|
||||
@@ -285,11 +316,39 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
((bytes[o + 1] and 0xFF) shl 8) or
|
||||
((bytes[o + 2] and 0xFF) shl 16) or
|
||||
((bytes[o + 3] and 0xFF) shl 24)
|
||||
val patches = Array(count) { i ->
|
||||
val o = i * recordSize
|
||||
// Patch version byte at offset 0 is parsed but only version 1 is recognised;
|
||||
// a future version bump would gate alternate field layouts here.
|
||||
AudioAdapter.TaudInstPatch(
|
||||
val patches = ArrayList<AudioAdapter.TaudInstPatch>()
|
||||
var o = 0
|
||||
while (o + 31 <= bytes.size) {
|
||||
val ver = u8(o)
|
||||
var p = o + 31 // version byte + 30 common bytes
|
||||
// Optional blocks, walked in the canonical on-wire order x, v, p, f, P.
|
||||
var hasExtra = false; var fadeoutStep = 0; var extraCutoff = 0xFF; var extraResonance = 0xFF
|
||||
var extraAttenOctet = 0; var filterSfMode = false
|
||||
if (ver and 0x80 != 0) { // 'x' block (15 bytes): u32 flags1 + u32 flags2 + u16 fadeout + u16 cutoff + u16 reson + u8 initialAttenuation octet
|
||||
if (p + 15 > bytes.size) break
|
||||
filterSfMode = (u8(p) and 0x01) != 0 // flags1 bit 0: 0 = IT filter, 1 = SoundFont
|
||||
fadeoutStep = u16(p + 8); extraCutoff = u16(p + 10); extraResonance = u16(p + 12)
|
||||
extraAttenOctet = u8(p + 14)
|
||||
hasExtra = true; p += 15
|
||||
}
|
||||
fun readEnv(): Triple<Array<AudioAdapter.TaudInstEnvPoint>, Int, Int>? {
|
||||
if (p + 54 > bytes.size) return null
|
||||
val loop = u16(p); val sus = u16(p + 2)
|
||||
val arr = Array(25) { k ->
|
||||
AudioAdapter.TaudInstEnvPoint(u8(p + 4 + 2 * k), ThreeFiveMiniUfloat(u8(p + 5 + 2 * k)))
|
||||
}
|
||||
p += 54
|
||||
return Triple(arr, loop, sus)
|
||||
}
|
||||
var volEnv: Array<AudioAdapter.TaudInstEnvPoint>? = null; var volLoop = 0; var volSus = 0
|
||||
var panEnv: Array<AudioAdapter.TaudInstEnvPoint>? = null; var panLoop = 0; var panSus = 0
|
||||
var filEnv: Array<AudioAdapter.TaudInstEnvPoint>? = null; var filLoop = 0; var filSus = 0
|
||||
var pitEnv: Array<AudioAdapter.TaudInstEnvPoint>? = null; var pitLoop = 0; var pitSus = 0
|
||||
if (ver and 0x02 != 0) { val e = readEnv() ?: break; volEnv = e.first; volLoop = e.second; volSus = e.third }
|
||||
if (ver and 0x04 != 0) { val e = readEnv() ?: break; panEnv = e.first; panLoop = e.second; panSus = e.third }
|
||||
if (ver and 0x08 != 0) { val e = readEnv() ?: break; filEnv = e.first; filLoop = e.second; filSus = e.third }
|
||||
if (ver and 0x10 != 0) { val e = readEnv() ?: break; pitEnv = e.first; pitLoop = e.second; pitSus = e.third }
|
||||
patches.add(AudioAdapter.TaudInstPatch(
|
||||
pitchStart = u16(o + 1),
|
||||
pitchEnd = u16(o + 3),
|
||||
volumeStart = u8 (o + 5),
|
||||
@@ -308,16 +367,65 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
vibratoSweep = u8 (o + 27),
|
||||
vibratoDepth = u8 (o + 28),
|
||||
vibratoRate = u8 (o + 29),
|
||||
vibratoWaveform = u8 (o + 30)
|
||||
)
|
||||
vibratoWaveform = u8 (o + 30),
|
||||
volEnv = volEnv, volEnvLoop = volLoop, volEnvSustain = volSus,
|
||||
panEnv = panEnv, panEnvLoop = panLoop, panEnvSustain = panSus,
|
||||
filterEnv = filEnv, filterEnvLoop = filLoop, filterEnvSustain = filSus,
|
||||
pitchEnv = pitEnv, pitchEnvLoop = pitLoop, pitchEnvSustain = pitSus,
|
||||
hasExtra = hasExtra, fadeoutStep = fadeoutStep, filterSfMode = filterSfMode,
|
||||
extraCutoff = extraCutoff, extraResonance = extraResonance,
|
||||
extraInitialAttenOctet = extraAttenOctet
|
||||
))
|
||||
o = p
|
||||
}
|
||||
inst.extraPatches = patches
|
||||
inst.extraPatches = if (patches.isEmpty()) null else patches.toTypedArray()
|
||||
}
|
||||
|
||||
/** Number of Ixmp patches currently installed on instrument [slot], or 0 if none. */
|
||||
fun getInstrumentPatchCount(slot: Int): Int =
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches?.size ?: 0
|
||||
|
||||
/** Read back instrument [slot]'s Ixmp patches as a flat variable-length byte array in
|
||||
* the upload wire format (exact inverse of [uploadInstrumentPatches]) so capture
|
||||
* code can re-emit the Ixmp project-data section. Empty array when none. */
|
||||
fun getInstrumentPatches(slot: Int): IntArray {
|
||||
val patches = getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches
|
||||
?: return IntArray(0)
|
||||
val out = ArrayList<Int>(patches.size * 31)
|
||||
fun w8(v: Int) { out.add(v and 0xFF) }
|
||||
fun w16(v: Int) { out.add(v and 0xFF); out.add((v ushr 8) and 0xFF) }
|
||||
fun w32(v: Int) { w16(v); w16(v ushr 16) }
|
||||
fun wEnv(env: Array<AudioAdapter.TaudInstEnvPoint>, loop: Int, sus: Int) {
|
||||
w16(loop); w16(sus)
|
||||
for (k in 0 until 25) { w8(env[k].value); w8(env[k].offset.index) }
|
||||
}
|
||||
patches.forEach { p ->
|
||||
// Reconstruct the version byte from which optional blocks are present.
|
||||
var ver = 0x01
|
||||
if (p.hasExtra) ver = ver or 0x80
|
||||
if (p.volEnv != null) ver = ver or 0x02
|
||||
if (p.panEnv != null) ver = ver or 0x04
|
||||
if (p.filterEnv != null) ver = ver or 0x08
|
||||
if (p.pitchEnv != null) ver = ver or 0x10
|
||||
w8(ver)
|
||||
w16(p.pitchStart); w16(p.pitchEnd)
|
||||
w8(p.volumeStart); w8(p.volumeEnd)
|
||||
w32(p.samplePtr)
|
||||
w16(p.sampleLength); w16(p.playStart); w16(p.loopStart); w16(p.loopEnd)
|
||||
w16(p.samplingRate); w16(p.sampleDetune) // two's complement round-trips
|
||||
w8(p.loopMode); w8(p.defaultPan); w8(p.defaultNoteVolume)
|
||||
w8(p.vibratoSpeed); w8(p.vibratoSweep); w8(p.vibratoDepth)
|
||||
w8(p.vibratoRate); w8(p.vibratoWaveform)
|
||||
// Blocks in the canonical on-wire order x, v, p, f, P.
|
||||
if (p.hasExtra) { w32(if (p.filterSfMode) 1 else 0); w32(0); w16(p.fadeoutStep); w16(p.extraCutoff); w16(p.extraResonance); w8(p.extraInitialAttenOctet) }
|
||||
p.volEnv?.let { wEnv(it, p.volEnvLoop, p.volEnvSustain) }
|
||||
p.panEnv?.let { wEnv(it, p.panEnvLoop, p.panEnvSustain) }
|
||||
p.filterEnv?.let { wEnv(it, p.filterEnvLoop, p.filterEnvSustain) }
|
||||
p.pitchEnv?.let { wEnv(it, p.pitchEnvLoop, p.pitchEnvSustain) }
|
||||
}
|
||||
return out.toIntArray()
|
||||
}
|
||||
|
||||
/** Clear any Ixmp patches previously uploaded to instrument [slot]. */
|
||||
fun clearInstrumentPatches(slot: Int) {
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches = null
|
||||
@@ -419,9 +527,17 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
null, snd.sampleBin.ptr,
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
snd.instruments[i / 256].setByte(i % 256, bytes[sampleSize + i].toInt() and 0xFF)
|
||||
val rec = IntArray(256)
|
||||
for (instIdx in 0 until (instSize / 256)) {
|
||||
val base = sampleSize + instIdx * 256
|
||||
for (k in 0 until 256) rec[k] = bytes[base + k].toInt() and 0xFF
|
||||
snd.instruments[instIdx].loadRecord(rec) // meta-aware
|
||||
}
|
||||
// The blob replaces the entire sample+instrument image, so any Ixmp patches
|
||||
// installed for the previous song are now stale (they point into the old
|
||||
// sample pool). Drop them all; the loader re-uploads the new song's Ixmp
|
||||
// section (if any) after this call.
|
||||
snd.instruments.forEach { it.extraPatches = null }
|
||||
return bytes.size
|
||||
}
|
||||
|
||||
|
||||
@@ -426,6 +426,20 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPixelsAll(col1: Int, col2: Int, col3: Int, col4: Int) {
|
||||
getFirstGPU()?.let {
|
||||
it.poke(250884L, col1.toByte())
|
||||
it.poke(250883L, 2)
|
||||
it.poke(250884L, col2.toByte())
|
||||
it.poke(250883L, 4)
|
||||
it.poke(250884L, col3.toByte())
|
||||
it.poke(250883L, 6)
|
||||
it.poke(250884L, col4.toByte())
|
||||
it.poke(250883L, 8)
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* prints a char as-is; won't interpret them as an escape sequence
|
||||
*/
|
||||
@@ -7088,22 +7102,52 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload interlaced GOP frame from videoBuffer with deinterlacing.
|
||||
* Handles field extraction and temporal deinterlacing for GOP frames.
|
||||
* Copy a single progressive GOP frame out of videoBuffer (Java heap) into a
|
||||
* JS-addressable RGB888 RAM buffer, without dithering or uploading. Lets the
|
||||
* mediadec library expose every decoded frame as a generic RAM frame: the
|
||||
* caller then uploads it with uploadRGBToFramebuffer (graphics path) or
|
||||
* samples it for the ASCII renderer — never going through the display planes
|
||||
* just to read the pixels back.
|
||||
*
|
||||
* @param frameIndex Which frame in the GOP to copy (0-based)
|
||||
* @param width Frame width
|
||||
* @param height Frame height
|
||||
* @param bufferOffset Byte offset of the GOP slot in videoBuffer
|
||||
* @param dstRGBAddr Destination RGB888 buffer in VM user memory (width*height*3 bytes)
|
||||
*/
|
||||
fun tavCopyGopFrameToRGB(frameIndex: Int, width: Int, height: Int, bufferOffset: Long, dstRGBAddr: Long) {
|
||||
val gpu = (vm.peripheralTable[1].peripheral as GraphicsAdapter)
|
||||
val frameSize = width * height * 3L
|
||||
val videoBufferOffset = bufferOffset + (frameIndex * frameSize)
|
||||
UnsafeHelper.memcpyRaw(
|
||||
null,
|
||||
gpu.videoBuffer.ptr + videoBufferOffset,
|
||||
null,
|
||||
vm.usermem.ptr + dstRGBAddr,
|
||||
frameSize
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract three consecutive fields of an interlaced GOP frame from videoBuffer
|
||||
* and deinterlace them into an RGB888 RAM buffer — the field-copy + deinterlace
|
||||
* half of uploadInterlacedGopFrameToFramebuffer, WITHOUT the final upload. Used
|
||||
* by the mediadec library to land the decoded frame in RAM (where it can then be
|
||||
* uploaded or sampled), mirroring tavCopyGopFrameToRGB for the progressive case.
|
||||
*
|
||||
* @param frameIndex Current frame index in GOP (0-based)
|
||||
* @param gopSize Total number of frames in GOP
|
||||
* @param width Frame width
|
||||
* @param fieldHeight Height of each field (half of display height)
|
||||
* @param fullHeight Full display height (2 * fieldHeight)
|
||||
* @param frameCount Global frame counter for dithering
|
||||
* @param fullHeight Full display height (2 * fieldHeight) — unused here, kept for call symmetry
|
||||
* @param frameCount Global frame counter (deinterlacer cadence)
|
||||
* @param bufferOffset Start offset of GOP in videoBuffer
|
||||
* @param prevFieldAddr Memory address for previous field buffer
|
||||
* @param currentFieldAddr Memory address for current field buffer
|
||||
* @param nextFieldAddr Memory address for next field buffer
|
||||
* @param deinterlaceOutputAddr Memory address for deinterlaced output
|
||||
* @param prevFieldAddr Memory address for previous field buffer (scratch)
|
||||
* @param currentFieldAddr Memory address for current field buffer (scratch)
|
||||
* @param nextFieldAddr Memory address for next field buffer (scratch)
|
||||
* @param deinterlaceOutputAddr Destination RGB888 buffer (width*fullHeight*3 bytes)
|
||||
*/
|
||||
fun uploadInterlacedGopFrameToFramebuffer(
|
||||
fun tavDeinterlaceGopFrameToRGB(
|
||||
frameIndex: Int,
|
||||
gopSize: Int,
|
||||
width: Int,
|
||||
@@ -7158,8 +7202,43 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
prevFieldAddr, currentFieldAddr, nextFieldAddr,
|
||||
deinterlaceOutputAddr, "yadif"
|
||||
)
|
||||
}
|
||||
|
||||
// Upload deinterlaced full-height frame
|
||||
/**
|
||||
* Upload interlaced GOP frame from videoBuffer with deinterlacing.
|
||||
* Handles field extraction and temporal deinterlacing for GOP frames.
|
||||
*
|
||||
* @param frameIndex Current frame index in GOP (0-based)
|
||||
* @param gopSize Total number of frames in GOP
|
||||
* @param width Frame width
|
||||
* @param fieldHeight Height of each field (half of display height)
|
||||
* @param fullHeight Full display height (2 * fieldHeight)
|
||||
* @param frameCount Global frame counter for dithering
|
||||
* @param bufferOffset Start offset of GOP in videoBuffer
|
||||
* @param prevFieldAddr Memory address for previous field buffer
|
||||
* @param currentFieldAddr Memory address for current field buffer
|
||||
* @param nextFieldAddr Memory address for next field buffer
|
||||
* @param deinterlaceOutputAddr Memory address for deinterlaced output
|
||||
*/
|
||||
fun uploadInterlacedGopFrameToFramebuffer(
|
||||
frameIndex: Int,
|
||||
gopSize: Int,
|
||||
width: Int,
|
||||
fieldHeight: Int,
|
||||
fullHeight: Int,
|
||||
frameCount: Int,
|
||||
bufferOffset: Long,
|
||||
prevFieldAddr: Long,
|
||||
currentFieldAddr: Long,
|
||||
nextFieldAddr: Long,
|
||||
deinterlaceOutputAddr: Long
|
||||
) {
|
||||
// Field copy + deinterlace into the RGB output buffer ...
|
||||
tavDeinterlaceGopFrameToRGB(
|
||||
frameIndex, gopSize, width, fieldHeight, fullHeight, frameCount, bufferOffset,
|
||||
prevFieldAddr, currentFieldAddr, nextFieldAddr, deinterlaceOutputAddr
|
||||
)
|
||||
// ... then upload the deinterlaced full-height frame.
|
||||
uploadRGBToFramebuffer(deinterlaceOutputAddr, width, fullHeight, frameCount, false)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
653
tvdos_synopsis_format_draft.md
Normal file
653
tvdos_synopsis_format_draft.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# TVDOS Synopsis Format (TSF) Version 1.0 Draft
|
||||
|
||||
## 1. Scope
|
||||
|
||||
The TVDOS Synopsis Format (TSF) is a machine-readable command interface description language.
|
||||
|
||||
A TSF document describes:
|
||||
|
||||
* Command grammar
|
||||
* Options and flags
|
||||
* Positional arguments
|
||||
* Subcommands
|
||||
* Argument types
|
||||
* Completion sources
|
||||
* Validation constraints
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and **OPTIONAL** in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals. Lowercase uses of these words carry their ordinary English meaning and impose no normative requirement.
|
||||
|
||||
TSF lives next to the program, with the program's full filename plus a .synopsis extension. An app installed as `cp.js` in `\tvdos\bin` therefore ships alongside it as `\tvdos\bin\cp.js.synopsis`.
|
||||
|
||||
TSF MUST be valid JSON. A TSF document MUST be encoded such that its byte stream contains only ASCII characters: any character outside the ASCII range (U+0000–U+007F) MUST be represented using a JSON `\uXXXX` escape sequence rather than emitted as a literal multibyte character. Consumers MUST decode such escapes per the JSON specification.
|
||||
|
||||
---
|
||||
|
||||
# 2. Design Goals
|
||||
|
||||
TSF SHALL:
|
||||
|
||||
* Be machine-readable.
|
||||
* Be human-authorable.
|
||||
* Support automatic shell completion.
|
||||
* Support automatic help generation.
|
||||
* Support parser generation.
|
||||
* Support GUI generation.
|
||||
|
||||
The structured synopsis grammar SHALL be the sole normative description of command syntax; every other representation, including human-readable usage strings, is treated as generated output derived from it. This principle is referenced rather than restated elsewhere in this document.
|
||||
|
||||
---
|
||||
|
||||
# 3. Root Object
|
||||
|
||||
A TSF document SHALL contain one JSON object.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"tsfVersion": "1.0",
|
||||
"name": "cp",
|
||||
"summary": "Copy files and directories",
|
||||
"symbols": {},
|
||||
"synopsis": {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 4. Root Fields
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ----------- | -------- | ------ | ------------------------------------------------------------ |
|
||||
| tsfVersion | yes | string | Version of TSF this document targets. |
|
||||
| name | yes | string | Command name as invoked. |
|
||||
| summary | yes | string | One-line description. |
|
||||
| symbols | yes | object | The symbol table (§5). |
|
||||
| synopsis | yes | object | The synopsis grammar root node (§12). |
|
||||
| description | no | string | Free-form long description for help generation. |
|
||||
| constraints | no | array | Constraint objects (§18). |
|
||||
| metadata | no | object | Free-form, non-normative data reserved for authors and hosts (§20). |
|
||||
|
||||
---
|
||||
|
||||
# 5. Symbol Table
|
||||
|
||||
All command elements SHALL be declared in the symbol table. The synopsis grammar SHALL reference symbols by identifier.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"symbols": {
|
||||
"recursive": {
|
||||
"kind": "option",
|
||||
"long": "--recursive",
|
||||
"short": "-r"
|
||||
},
|
||||
|
||||
"source": {
|
||||
"kind": "positional",
|
||||
"type": "path"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. Symbol Kinds
|
||||
|
||||
Valid symbol kinds:
|
||||
|
||||
```text
|
||||
option
|
||||
positional
|
||||
subcommand
|
||||
group
|
||||
```
|
||||
|
||||
Each kind is defined in §8, §9, §10, and §11 respectively.
|
||||
|
||||
---
|
||||
|
||||
# 7. Argument Descriptors
|
||||
|
||||
An *argument descriptor* describes a single consumed value. The same descriptor shape is used in two places: directly on a `positional` symbol (§9), and as the `value` of an `option` symbol (§8). Defining it once keeps the two consistent.
|
||||
|
||||
## 7.1 Argument Descriptor Fields
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------- | -------- | -------------- | ---------------------------------------------------------------- |
|
||||
| type | no | string | One of the built-in types (§15). Defaults to `string`. |
|
||||
| name | no | string | Metavar shown in generated usage (e.g. `FILE`, `WHEN`). |
|
||||
| values | cond. | array | Permitted values; **REQUIRED** when `type` is `enum`, otherwise **OPTIONAL** (§15). |
|
||||
| default | no | any | Default value used for help generation and GUI prefill. |
|
||||
| validation | no | object | Value-level validation (§7.2). |
|
||||
| completion | no | object | Completion override (§16). |
|
||||
| summary | no | string | Short description of the value. |
|
||||
|
||||
Each entry in `values` SHALL be either a bare JSON value, or an object of the form `{ "value": <value>, "summary": <string> }`. The optional per-value `summary` is used for completion hints and help generation.
|
||||
|
||||
## 7.2 Validation Object
|
||||
|
||||
`validation` expresses value-level checks that the grammar cannot:
|
||||
|
||||
| Field | Applies to | Notes |
|
||||
| --------- | ----------------- | -------------------------------------------------- |
|
||||
| pattern | string-like types | A regular expression the value MUST match. |
|
||||
| minimum | numeric types | Inclusive lower bound. |
|
||||
| maximum | numeric types | Inclusive upper bound. |
|
||||
| minLength | string-like types | Minimum length in characters. |
|
||||
| maxLength | string-like types | Maximum length in characters. |
|
||||
|
||||
`pattern` is a regular expression; the supported flavour is implementation-defined, and authors are **RECOMMENDED** to restrict patterns to a portable subset. Because the document is ASCII-only (§1), any non-ASCII character within a pattern MUST be `\u`-escaped.
|
||||
|
||||
---
|
||||
|
||||
# 8. Option Symbols
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"recursive": {
|
||||
"kind": "option",
|
||||
"long": "--recursive",
|
||||
"short": "-r",
|
||||
"summary": "Copy directories recursively"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An option carrying an argument declares it via `value`, which is an argument descriptor (§7):
|
||||
|
||||
```json
|
||||
{
|
||||
"output": {
|
||||
"kind": "option",
|
||||
"long": "--output",
|
||||
"short": "-o",
|
||||
"summary": "Write output to FILE",
|
||||
"value": {
|
||||
"name": "FILE",
|
||||
"type": "file",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An enumerated option value (the common `--color=WHEN` idiom) makes completion trivial:
|
||||
|
||||
```json
|
||||
{
|
||||
"color": {
|
||||
"kind": "option",
|
||||
"long": "--color",
|
||||
"summary": "Colourise output",
|
||||
"value": {
|
||||
"name": "WHEN",
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{ "value": "always", "summary": "Always colourise" },
|
||||
{ "value": "never", "summary": "Never colourise" },
|
||||
{ "value": "auto", "summary": "Colourise when stdout is a terminal" }
|
||||
],
|
||||
"default": "auto",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8.1 Option Fields
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| --------- | -------- | ------- | --------------------------------------------------------------------- |
|
||||
| kind | yes | string | `option`. |
|
||||
| long | cond. | string | Long form, e.g. `--recursive`. |
|
||||
| short | cond. | string | Short form, e.g. `-r`. |
|
||||
| summary | no | string | One-line description. |
|
||||
| value | no | object | Argument descriptor (§7). Omit for a bare flag. |
|
||||
| negatable | no | boolean | If `true`, a `--no-<long>` form is also accepted. Defaults to `false`. |
|
||||
|
||||
At least one of `long` or `short` SHALL exist.
|
||||
|
||||
The `value` object MAY additionally carry a `required` field (default `true`). When `required` is `true`, the option consumes a mandatory argument; when `false`, the argument is optional (as in `--color` with or without `=WHEN`).
|
||||
|
||||
How many times an option may appear (for example a repeated `-v`) is expressed in the grammar via `repeat` or `oneOrMore` (§13), not by a field on the symbol; this keeps multiplicity in a single place.
|
||||
|
||||
---
|
||||
|
||||
# 9. Positional Symbols
|
||||
|
||||
A positional symbol is an argument descriptor (§7) plus its `kind`.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "positional",
|
||||
"type": "path",
|
||||
"summary": "Source file"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 9.1 Positional Fields
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------- | -------- | ------ | ------------------------------------------- |
|
||||
| kind | yes | string | `positional`. |
|
||||
| type | no | string | Built-in type (§15). Defaults to `string`. |
|
||||
| name | no | string | Metavar shown in generated usage. |
|
||||
| values | cond. | array | **REQUIRED** when `type` is `enum`. |
|
||||
| default | no | any | Default value. |
|
||||
| validation | no | object | Value-level validation (§7.2). |
|
||||
| completion | no | object | Completion override (§16). |
|
||||
| summary | no | string | One-line description. |
|
||||
|
||||
Whether a positional is required or optional is expressed in the grammar (by wrapping it in `optional` or not), not by a field here; this keeps a single source of truth.
|
||||
|
||||
---
|
||||
|
||||
# 10. Subcommand Symbols
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"clone": {
|
||||
"kind": "subcommand",
|
||||
"summary": "Clone repository",
|
||||
"tsf": "git.clone"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Subcommands MAY reference:
|
||||
|
||||
* embedded TSF documents
|
||||
* external TSF documents
|
||||
|
||||
Implementation-specific resolution of the `tsf` reference is permitted.
|
||||
|
||||
---
|
||||
|
||||
# 11. Group Symbols
|
||||
|
||||
A group collects related symbols, typically options, so that the grammar can refer to them collectively. A group is what backs the conventional `[OPTION...]` slot.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"commonOptions": {
|
||||
"kind": "group",
|
||||
"summary": "Common options",
|
||||
"members": [
|
||||
"recursive",
|
||||
"force",
|
||||
"verbose"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 11.1 Group Fields
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ------- | -------- | ------ | ------------------------------------------------ |
|
||||
| kind | yes | string | `group`. |
|
||||
| members | yes | array | Identifiers of the symbols this group contains. |
|
||||
| summary | no | string | One-line description. |
|
||||
|
||||
A `reference` to a group (§13) is equivalent to a `choice` over its members. Wrapping that reference in `repeat` yields the familiar "any number of these options, in any order" behaviour of `[OPTION...]`.
|
||||
|
||||
---
|
||||
|
||||
# 12. Synopsis Grammar
|
||||
|
||||
The synopsis object SHALL describe valid command invocations.
|
||||
|
||||
Every node SHALL contain:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "<node-type>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 13. Grammar Node Types
|
||||
|
||||
## sequence
|
||||
|
||||
All children must appear in order.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "sequence",
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent:
|
||||
|
||||
```text
|
||||
A B C
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## choice
|
||||
|
||||
Exactly one child must appear.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "choice",
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent:
|
||||
|
||||
```text
|
||||
(A | B | C)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## optional
|
||||
|
||||
Child may appear zero or one time.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "optional",
|
||||
"child": {}
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent:
|
||||
|
||||
```text
|
||||
[A]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## repeat
|
||||
|
||||
Child may appear zero or more times.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "repeat",
|
||||
"child": {}
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent:
|
||||
|
||||
```text
|
||||
A...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## oneOrMore
|
||||
|
||||
Child must appear at least once. This node is sugar for `sequence[A, repeat[A]]` and carries no semantics beyond that combination; it exists for authoring convenience.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "oneOrMore",
|
||||
"child": {}
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent:
|
||||
|
||||
```text
|
||||
A [A...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## reference
|
||||
|
||||
References a symbol. When the referenced symbol is a group, the reference expands to a `choice` over the group's members (§11).
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "reference",
|
||||
"symbol": "recursive"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 14. Example Synopsis
|
||||
|
||||
Human form:
|
||||
|
||||
```text
|
||||
cp [OPTION...] SOURCE DEST
|
||||
```
|
||||
|
||||
Symbol table (abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"symbols": {
|
||||
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||
"force": { "kind": "option", "long": "--force", "short": "-f" },
|
||||
"options": { "kind": "group", "members": ["recursive", "force"] },
|
||||
"source": { "kind": "positional", "type": "path", "name": "SOURCE" },
|
||||
"destination": { "kind": "positional", "type": "path", "name": "DEST" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Synopsis:
|
||||
|
||||
```json
|
||||
{
|
||||
"synopsis": {
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"type": "repeat",
|
||||
"child": {
|
||||
"type": "reference",
|
||||
"symbol": "options"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "reference",
|
||||
"symbol": "source"
|
||||
},
|
||||
{
|
||||
"type": "reference",
|
||||
"symbol": "destination"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `options` group is a declared symbol, so the `[OPTION...]` slot now satisfies the rule in §5 that every referenced element exists in the symbol table.
|
||||
|
||||
---
|
||||
|
||||
# 15. Argument Types
|
||||
|
||||
Built-in primitive types:
|
||||
|
||||
```text
|
||||
string
|
||||
integer
|
||||
float
|
||||
boolean
|
||||
path
|
||||
file
|
||||
directory
|
||||
url
|
||||
hostname
|
||||
user
|
||||
group
|
||||
command
|
||||
enum
|
||||
```
|
||||
|
||||
`enum` restricts a value to one of an explicit set; a descriptor whose `type` is `enum` SHALL provide a `values` array (§7.1). The `values` array MAY also be supplied for non-`enum` types as a soft suggestion list, in which case it informs completion but does not restrict valid input.
|
||||
|
||||
Unknown types SHALL be interpreted as `string`. Implementations are **RECOMMENDED** to emit a diagnostic when they do so, since an unknown type is usually an authoring error rather than an intentional fallback.
|
||||
|
||||
Each type carries a default completion behaviour (§16): for example `path`, `file`, and `directory` complete against the filesystem, `user` and `group` against the host's account databases, and `enum` against its `values`. A `completion` block overrides this default.
|
||||
|
||||
---
|
||||
|
||||
# 16. Completion
|
||||
|
||||
If a descriptor has no `completion` block, completion is derived automatically from its `type` (and from `values` when the type is `enum`). A `completion` block overrides that default.
|
||||
|
||||
| method | Notes |
|
||||
| -------- | --------------------------------------------------------------------------- |
|
||||
| type | Use the default completion implied by the descriptor's `type`. (Implicit when no block is present.) |
|
||||
| enum | Complete from the descriptor's `values`. (Implicit when `type` is `enum`.) |
|
||||
| internal | Use a named provider resolved by the host. |
|
||||
| command | Run a command whose output supplies the candidates. |
|
||||
| list | Offer a static inline list of suggestions, without restricting input. |
|
||||
| none | Suppress completion for this value. |
|
||||
|
||||
Example using a named internal provider:
|
||||
|
||||
```json
|
||||
{
|
||||
"branch": {
|
||||
"kind": "positional",
|
||||
"type": "string",
|
||||
|
||||
"completion": {
|
||||
"method": "internal",
|
||||
"provider": "branches"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 17. Constraints
|
||||
|
||||
Constraints describe relationships not expressible in the grammar. They are listed in the root `constraints` array (§4).
|
||||
|
||||
Three of the four constraint types below (`conflicts`, `requires`, `cardinality`) are validation predicates: they describe whether an invocation is well-formed. The fourth, `implies`, is a derivation: it sets a value as a side effect rather than rejecting input. Consumers should treat it accordingly.
|
||||
|
||||
Field naming is uniform: symmetric constraints use `symbols`; asymmetric constraints use `subject` and `targets`.
|
||||
|
||||
---
|
||||
|
||||
## conflicts
|
||||
|
||||
Symmetric. The listed symbols are mutually exclusive.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "conflicts",
|
||||
"symbols": [
|
||||
"stdout",
|
||||
"output"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Meaning:
|
||||
|
||||
```text
|
||||
--stdout conflicts with --output
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## requires
|
||||
|
||||
Asymmetric. If `subject` is present, every symbol in `targets` MUST also be present.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "requires",
|
||||
"subject": "output",
|
||||
"targets": [
|
||||
"format"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## implies
|
||||
|
||||
Asymmetric derivation. If `subject` is present, every symbol in `targets` is implicitly set.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "implies",
|
||||
"subject": "verbose",
|
||||
"targets": [
|
||||
"log"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## cardinality
|
||||
|
||||
Symmetric. Constrains how many of the listed symbols may appear.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "cardinality",
|
||||
"symbols": [
|
||||
"create",
|
||||
"extract",
|
||||
"list"
|
||||
],
|
||||
"minimum": 1,
|
||||
"maximum": 1
|
||||
}
|
||||
```
|
||||
|
||||
Equivalent:
|
||||
|
||||
```text
|
||||
exactly one of:
|
||||
create
|
||||
extract
|
||||
list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 18. Generated Usage
|
||||
|
||||
Implementations SHOULD generate usage text from the synopsis grammar. Per §2, that text is non-authoritative output; the grammar remains the sole normative description.
|
||||
|
||||
---
|
||||
|
||||
# 19. Extensibility and Compatibility
|
||||
|
||||
TSF distinguishes between additive content, which may be ignored safely, and structural content, which may not.
|
||||
|
||||
* **Unknown fields** on otherwise-valid objects SHALL be ignored. Future minor versions MAY add fields without invalidating existing documents or consumers.
|
||||
* **Unknown grammar node types (§13) and unknown symbol kinds (§6)** SHALL cause the document to be rejected, or to enter an explicitly defined degraded mode. They cannot be ignored, because doing so would silently change the set of accepted invocations.
|
||||
|
||||
Authors and hosts that need to attach implementation-specific data SHOULD do so either inside the root `metadata` object or under field names prefixed with `x-`. Names without that prefix are reserved for future versions of this specification.
|
||||
|
||||
Future TSF versions MAY introduce additional grammar node types, symbol kinds, types, and constraint types. Consumers SHOULD report the highest `tsfVersion` they support so that producers can downgrade gracefully.
|
||||
25
xm2taud.py
25
xm2taud.py
@@ -64,7 +64,8 @@ from taud_common import (
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects_per_slot,
|
||||
encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
CUE_INST_NOP, CUE_INST_HALT, cue_instruction_len,
|
||||
cue_instruction_halt_at,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
@@ -1376,28 +1377,26 @@ def _build_song_payload_xm(h: XMHeader, patterns_template: list,
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c * CUE_SIZE:c * CUE_SIZE + CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
last_active = -1
|
||||
n_emit = min(len(cue_list), NUM_CUES)
|
||||
len_cue_count = 0
|
||||
for cue_idx, ci in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
for cue_idx in range(n_emit):
|
||||
ci = cue_list[cue_idx]
|
||||
base_pat = cue_idx * C
|
||||
pats = [pat_remap[base_pat + vi] for vi in range(C)]
|
||||
clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS
|
||||
if clen < PATTERN_ROWS:
|
||||
if cue_idx == n_emit - 1:
|
||||
# Final cue: play its own length then HALT. "Halt at x" preserves the
|
||||
# partial length (a short terminal pattern halts at `clen` instead of
|
||||
# running the full 64-row padding); a full-length cue emits a plain HALT.
|
||||
instr = cue_instruction_halt_at(clen)
|
||||
elif clen < PATTERN_ROWS:
|
||||
instr = cue_instruction_len(clen)
|
||||
len_cue_count += 1
|
||||
else:
|
||||
instr = CUE_INST_NOP
|
||||
sheet[cue_idx * CUE_SIZE:(cue_idx + 1) * CUE_SIZE] = encode_cue(pats, instr)
|
||||
last_active = cue_idx
|
||||
|
||||
if last_active >= 0:
|
||||
if sheet[last_active * CUE_SIZE + 30] == CUE_INST_LEN:
|
||||
vprint(f" [{song_label}] warning: last active cue {last_active} "
|
||||
f"had LEN; replaced with HALT (partial tail at song terminus)")
|
||||
sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT
|
||||
sheet[last_active * CUE_SIZE + 31] = 0x00
|
||||
else:
|
||||
if n_emit == 0:
|
||||
sheet[30] = CUE_INST_HALT
|
||||
if len_cue_count:
|
||||
vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) "
|
||||
|
||||
Reference in New Issue
Block a user