mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 06:14:04 +09:00
Compare commits
28 Commits
f863f6230d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc1d420cd | ||
|
|
e32f7565ba | ||
|
|
c17f4828b0 | ||
|
|
95ac8c53dd | ||
|
|
c8fc363445 | ||
|
|
ce45929c4e | ||
|
|
0f5ede5276 | ||
|
|
aa45c2194f | ||
|
|
3444bdf63b | ||
|
|
df16b99ba5 | ||
|
|
6a0241a249 | ||
|
|
5c7ff9e906 | ||
|
|
c6e087e74c | ||
|
|
7dea413454 | ||
|
|
ee202efe09 | ||
|
|
6be98b5207 | ||
|
|
729e5246c9 | ||
|
|
e27a01dca6 | ||
|
|
35263eeaa4 | ||
|
|
d223adda25 | ||
|
|
a9d095e3cb | ||
|
|
dad345c027 | ||
|
|
2045da0286 | ||
|
|
3362a6b732 | ||
|
|
038db60b59 | ||
|
|
1d3b5ce8aa | ||
|
|
9e8af96c32 | ||
|
|
43e5baadf4 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -74,3 +74,6 @@ assets/disk0/movtestimg/*.jpg
|
|||||||
assets/disk0/*.mov
|
assets/disk0/*.mov
|
||||||
assets/diskMediabin/*
|
assets/diskMediabin/*
|
||||||
assets/disk0/hopper/*
|
assets/disk0/hopper/*
|
||||||
|
|
||||||
|
# TVDOS runtime caches (regenerated on the VM; never commit)
|
||||||
|
assets/disk0/tvdos/cache/
|
||||||
|
|||||||
117
CLAUDE.md
117
CLAUDE.md
@@ -165,6 +165,14 @@ Peripheral memories can be accessed using `vm.peek()` and `vm.poke()` functions,
|
|||||||
|
|
||||||
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
|
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
|
||||||
|
|
||||||
|
## Taud Tracker Engine
|
||||||
|
|
||||||
|
The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
## TVDOS
|
## TVDOS
|
||||||
|
|
||||||
### TVDOS Movie Formats
|
### TVDOS Movie Formats
|
||||||
@@ -428,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
|
|||||||
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
||||||
- Incorrect stereo imaging and extreme side channel distortion
|
- Incorrect stereo imaging and extreme side channel distortion
|
||||||
- Severe frequency response errors that manifest as "clipping-like" 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,10 +1,22 @@
|
|||||||
# Taud Tracker Effect Command Reference
|
# Taud Tracker Effect Command Reference
|
||||||
|
|
||||||
Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine must implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers.
|
Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine **MUST** implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers.
|
||||||
|
|
||||||
|
## Conformance language
|
||||||
|
|
||||||
|
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 and bold. Lowercase uses of these words carry their ordinary English meaning and impose no normative requirement.
|
||||||
|
|
||||||
|
In short:
|
||||||
|
|
||||||
|
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
|
||||||
|
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
|
||||||
|
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
|
||||||
|
|
||||||
|
The "Plain" paragraph of each effect description is non-normative tutorial text; the **Compatibility** and **Implementation** paragraphs carry the normative requirements, expressed through the keywords above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. Tracker Terminologies
|
## 0. Tracker terminologies
|
||||||
|
|
||||||
This manual extensively uses "tracker lingo" that may not sound intuitive to the modern DAW users. This section covers some of the tracker lingo to get the concepts better understood for those who have never used trackers.
|
This manual extensively uses "tracker lingo" that may not sound intuitive to the modern DAW users. This section covers some of the tracker lingo to get the concepts better understood for those who have never used trackers.
|
||||||
|
|
||||||
@@ -16,7 +28,7 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
|
|||||||
|
|
||||||
* **Row.** One horizontal slot within a pattern, at most one note event per channel. A row's duration is `speed × tick_duration` — see Speed and Tempo below.
|
* **Row.** One horizontal slot within a pattern, at most one note event per channel. A row's duration is `speed × tick_duration` — see Speed and Tempo below.
|
||||||
|
|
||||||
* **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one ticks for operation, and **they will not get applied when tick rate is set to 1.**
|
* **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one tick for operation, and **MUST NOT** be applied when the tick rate is set to 1.
|
||||||
|
|
||||||
* **Speed vs. Tempo.** Two independent timing knobs. **Speed** (effect A) is the number of ticks per row; **tempo** (effect T) sets the duration of one tick, conventionally expressed as BPM. To slow the song globally without changing how often per-tick effects update, lower the tempo. To give per-tick effects more iterations per row (denser vibrato, longer slides per row), raise the speed. The default is speed 6, tempo $64 → 125 BPM → 50 Hz tick rate → 120 ms per row.
|
* **Speed vs. Tempo.** Two independent timing knobs. **Speed** (effect A) is the number of ticks per row; **tempo** (effect T) sets the duration of one tick, conventionally expressed as BPM. To slow the song globally without changing how often per-tick effects update, lower the tempo. To give per-tick effects more iterations per row (denser vibrato, longer slides per row), raise the speed. The default is speed 6, tempo $64 → 125 BPM → 50 Hz tick rate → 120 ms per row.
|
||||||
|
|
||||||
@@ -34,7 +46,7 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
|
|||||||
|
|
||||||
* **Note off, note cut, note fade.** Three distinct ways a note ends. **Note cut** (`^^^` or S$Cx) silences instantly. **Note off** (`===` or an NNA = NoteOff) releases the sustain loop and lets the volume envelope's release segment play out, then fades. **Note fade** keeps the sustain loop running but begins the fadeout decay — for soft tail-offs that still sound sustained.
|
* **Note off, note cut, note fade.** Three distinct ways a note ends. **Note cut** (`^^^` or S$Cx) silences instantly. **Note off** (`===` or an NNA = NoteOff) releases the sustain loop and lets the volume envelope's release segment play out, then fades. **Note fade** keeps the sustain loop running but begins the fadeout decay — for soft tail-offs that still sound sustained.
|
||||||
|
|
||||||
* **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background **ghost** voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips.
|
* **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background *ghost* voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips.
|
||||||
|
|
||||||
* **Portamento.** Automatic pitch glide toward a target note (effect G). A row carrying both a note *and* a G does **not** re-trigger the sample; instead the note becomes the target and the already-sounding sample slides into it. Distinct from generic pitch slides (E/F), which move pitch by a fixed amount per tick with no target.
|
* **Portamento.** Automatic pitch glide toward a target note (effect G). A row carrying both a note *and* a G does **not** re-trigger the sample; instead the note becomes the target and the already-sounding sample slides into it. Distinct from generic pitch slides (E/F), which move pitch by a fixed amount per tick with no target.
|
||||||
|
|
||||||
@@ -52,15 +64,15 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
|
|||||||
|
|
||||||
## 1. Sound device
|
## 1. Sound device
|
||||||
|
|
||||||
- **Bit depth:** 8-bit unsigned throughout, including the final mixdown.
|
- **Bit depth:** 8-bit unsigned throughout, including the final mixdown. Conforming implementations **MUST** deliver 8-bit unsigned samples at the output stage.
|
||||||
- **Sample rate:** fixed at 32000 Hz.
|
- **Sample rate:** fixed at 32000 Hz. Conforming implementations **MUST** produce output at exactly this rate; resampling to another playback rate is the responsibility of the host environment, not of the Taud engine.
|
||||||
- **Output channels:** strictly stereo; the mix bus always produces a two-channel frame even for mono-source samples.
|
- **Output channels:** strictly stereo; the mix bus **MUST** always produce a two-channel frame, even for mono-source samples.
|
||||||
|
|
||||||
Internal accumulators may widen to 16 or 32 bits during mixing and effect computation, but stored samples and final output are 8-bit.
|
Internal accumulators **MAY** widen to 16 or 32 bits during mixing and effect computation, but stored samples and final output **MUST** be 8-bit.
|
||||||
|
|
||||||
## 2. Pitch system — 4096-TET
|
## 2. Pitch system — 4096-TET
|
||||||
|
|
||||||
One octave spans **4096 pitch units** ($1000 exactly). A 12-TET semitone therefore equals **4096 ÷ 12 ≈ 341.333 units** (≈ $0155.55), which is not an integer; this irrationality is a deliberate consequence of choosing a microtonal native grid. Implementations store channel pitch as a signed integer in Taud units, and convert to playback rate using
|
One octave spans **4096 pitch units** ($1000 exactly). A 12-TET semitone therefore equals **4096 ÷ 12 ≈ 341.333 units** (≈ $0155.55), which is not an integer; this irrationality is a deliberate consequence of choosing a microtonal native grid. Implementations **MUST** store channel pitch as a signed integer in Taud units, and **MUST** convert to playback rate using
|
||||||
|
|
||||||
```
|
```
|
||||||
playback_rate = reference_rate × 2 ^ (pitch_units / 4096)
|
playback_rate = reference_rate × 2 ^ (pitch_units / 4096)
|
||||||
@@ -83,13 +95,13 @@ Commonly used intervals in Taud units are listed below; all are rounded to the n
|
|||||||
|
|
||||||
## 3. Volume system
|
## 3. Volume system
|
||||||
|
|
||||||
Per-note and per-channel volume runs from **$00 (silent) to $3F (full)**, a 6-bit range narrower than ST3's 0..$40. Global volume (effect V) runs 0..$FF; this wider range lets the mix bus scale the summed channel output without disturbing individual note volumes. The per-frame mix chain per channel is
|
Per-note and per-channel volume runs from **$00 (silent) to $3F (full)**, a 6-bit range narrower than ST3's 0..$40. Global volume (effect V) runs 0..$FF; this wider range lets the mix bus scale the summed channel output without disturbing individual note volumes. Conforming engines **MUST** implement the per-frame mix chain per channel as
|
||||||
|
|
||||||
```
|
```
|
||||||
mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift
|
mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift
|
||||||
```
|
```
|
||||||
|
|
||||||
with saturation applied before the 8-bit stereo output.
|
with saturation applied before the 8-bit stereo output. Internal accumulators **MAY** widen during this computation (see §1), but the saturating clip to the 8-bit range **MUST** be performed at the boundary.
|
||||||
|
|
||||||
`note_vol` and `channel_vol` are **two independent multiplicative axes** mirroring IT's `chan->volume` and `chan->global_volume`:
|
`note_vol` and `channel_vol` are **two independent multiplicative axes** mirroring IT's `chan->volume` and `chan->global_volume`:
|
||||||
|
|
||||||
@@ -127,7 +139,7 @@ Most effects recall their last non-zero argument when re-issued with $0000. Unli
|
|||||||
|
|
||||||
Every other memory-carrying effect (D, I, J, K, L, N, O, P, Q, and others) has a private slot.
|
Every other memory-carrying effect (D, I, J, K, L, N, O, P, Q, and others) has a private slot.
|
||||||
|
|
||||||
**Effects without recall (literal zero).** A few effects do *not* recall on $0000 — the argument is taken at face value. **M** (set channel volume), **V** (set global volume), and the volume- / panning-column SET selectors all behave this way: writing `M $0000` or `V $0000` is a literal "set to silence", not a memory recall. Converters lifting from source trackers that *do* share memory (notably ST3, where the `$00` argument may cohabit with D/E/F/etc.'s shared slot) MUST eagerly resolve the recall to an explicit value before emitting, since the Taud engine takes M / V arguments verbatim.
|
**Effects without recall (literal zero).** A few effects do *not* recall on $0000 — the argument **MUST** be taken at face value. **M** (set channel volume), **V** (set global volume), and the volume- / panning-column SET selectors all behave this way: writing `M $0000` or `V $0000` is a literal "set to silence", not a memory recall. Converters lifting from source trackers that *do* share memory (notably ST3, where the `$00` argument may cohabit with D/E/F/etc.'s shared slot) **MUST** eagerly resolve the recall to an explicit value before emitting, since the Taud engine takes M / V arguments verbatim.
|
||||||
|
|
||||||
## 7. Opcode and argument format
|
## 7. Opcode and argument format
|
||||||
|
|
||||||
@@ -143,7 +155,7 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
|||||||
|
|
||||||
**Compatibility.** ST3 `Axx` maps one-to-one: Taud `A $xx00`. ST3 `A00` is a no-op; Taud `A $0000` is likewise ignored. ProTracker `Fxx` with `xx < $20` maps to Taud `A $xx00`; `Fxx` with `xx ≥ $20` maps to T instead (see T).
|
**Compatibility.** ST3 `Axx` maps one-to-one: Taud `A $xx00`. ST3 `A00` is a no-op; Taud `A $0000` is likewise ignored. ProTracker `Fxx` with `xx < $20` maps to Taud `A $xx00`; `Fxx` with `xx ≥ $20` maps to T instead (see T).
|
||||||
|
|
||||||
**Implementation.** If the high byte is non-zero, write it to `ticks_per_row`; the low byte is reserved and must be zero. The change takes effect from the row on which the A command appears. There is no memory for A.
|
**Implementation.** If the high byte is non-zero, the engine **MUST** write it to `ticks_per_row`; the low byte is reserved and **MUST** be zero. The change takes effect from the row on which the A command appears. There is no memory for A.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,11 +163,11 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
|||||||
|
|
||||||
**Plain.** Finishes the current row, then continues playback at row 0 of the pattern at cue position $xxyy. Use this to create song-level jumps, loops, or branching structures.
|
**Plain.** Finishes the current row, then continues playback at row 0 of the pattern at cue position $xxyy. Use this to create song-level jumps, loops, or branching structures.
|
||||||
|
|
||||||
**Compatibility.** ST3 `Bxx` jumps to an 8-bit cue and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs may have up to $10000 cue entries.
|
**Compatibility.** ST3 `Bxx` jumps to an 8-bit cue and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs **MAY** have up to $10000 cue entries.
|
||||||
|
|
||||||
**Implementation.** On the last tick of the current row, set the next cue index to the argument and the next row to 0. If the argument exceeds the song length, wrap to the song's defined restart position (cue $0000 by default). Jumps are detected by a visited `(cue, row)` set so that pathological loops do not prevent song-length computation, though they do not interrupt actual playback. There is no memory for B.
|
**Implementation.** On the last tick of the current row, the engine **MUST** set the next cue index to the argument and the next row to 0. If the argument exceeds the song length, the engine **MUST** wrap to the song's defined restart position (cue $0000 by default). Jumps **SHOULD** be detected by a visited `(cue, row)` set so that pathological loops do not prevent song-length computation, though they **MUST NOT** interrupt actual playback. There is no memory for B.
|
||||||
|
|
||||||
**Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both fire: B chooses the cue, C chooses the row within that cue. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column takes precedence.
|
**Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both **MUST** fire: B chooses the cue, C chooses the row within that cue. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column **MUST** take precedence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,9 +175,9 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
|||||||
|
|
||||||
**Plain.** Finishes the current row, then skips ahead to row $xxyy of the **next** pattern in the cue sequence.
|
**Plain.** Finishes the current row, then skips ahead to row $xxyy of the **next** pattern in the cue sequence.
|
||||||
|
|
||||||
**Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes should clamp to row 0 on import. When exporting back to ST3, encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63.
|
**Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, converters **MUST** decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes **SHOULD** clamp to row 0 on import. When exporting back to ST3, converters **MUST** encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63.
|
||||||
|
|
||||||
**Implementation.** On the last tick of the current row, advance the cue index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, start the destination pattern at row 0. There is no memory for C.
|
**Implementation.** On the last tick of the current row, the engine **MUST** advance the cue index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, the engine **MUST** start the destination pattern at row 0. There is no memory for C.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -237,9 +249,9 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
|
|||||||
- **MONOTONE source** (Taud `ff = 2`):
|
- **MONOTONE source** (Taud `ff = 2`):
|
||||||
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
|
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
|
||||||
|
|
||||||
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 0-1 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song.
|
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter ought to have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters **MUST** set bits 0-1 (`ff`) of the song-table flags byte to match the units they emit, and **MUST NOT** mix scales within one Taud song.
|
||||||
|
|
||||||
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter must eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
|
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter **MUST** eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
|
||||||
|
|
||||||
**Implementation.** Per-tick processing:
|
**Implementation.** Per-tick processing:
|
||||||
|
|
||||||
@@ -299,10 +311,10 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
|
|||||||
|
|
||||||
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
|
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
|
||||||
|
|
||||||
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
|
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources **SHOULD** be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
|
||||||
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
|
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
|
||||||
|
|
||||||
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). Amiga-mode G sources should be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
|
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; converters **MUST** convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). Amiga-mode G sources **SHOULD** be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
|
||||||
|
|
||||||
**Implementation.**
|
**Implementation.**
|
||||||
|
|
||||||
@@ -464,9 +476,9 @@ The `tick_within_row mod 3` counter resets every row start (so every row begins
|
|||||||
|
|
||||||
## K $xy00 — Dual: vibrato continuation and volume slide $xy
|
## K $xy00 — Dual: vibrato continuation and volume slide $xy
|
||||||
|
|
||||||
**Plain.** Continues the previously started vibrato (H or U) without retriggering it, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available in this form. The K command is implemented sorely for tracker compatibility — new compositions should prefer an explicit `H $0000` (vibrato recall) plus a volume-column slide (`1.$xy` / `2.$xy`), which carries the same semantics with one less hidden dependency.
|
**Plain.** Continues the previously started vibrato (H or U) without retriggering it, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available in this form. The K command is implemented solely for tracker compatibility — new compositions **SHOULD** prefer an explicit `H $0000` (vibrato recall) plus a volume-column slide (`1.$xy` / `2.$xy`), which carries the same semantics with one less hidden dependency.
|
||||||
|
|
||||||
**Compatibility.** ST3 / IT `Kxy` map directly to Taud `K $xy00`: the source's `xy` argument byte goes verbatim into the high byte of the Taud argument. ProTracker / FT2 / XM `6xy` map identically. Source-tracker memory cohorts that share K's argument with D (notably the ST3 single-slot shared memory and IT's D/K/L vol-slide cohort) MUST be resolved eagerly by the converter — emit explicit arguments rather than relying on cohort sharing, since Taud's K has its own private slot.
|
**Compatibility.** ST3 / IT `Kxy` map directly to Taud `K $xy00`: the source's `xy` argument byte goes verbatim into the high byte of the Taud argument. ProTracker / FT2 / XM `6xy` map identically. Source-tracker memory cohorts that share K's argument with D (notably the ST3 single-slot shared memory and IT's D/K/L vol-slide cohort) **MUST** be resolved eagerly by the converter — converters **MUST** emit explicit arguments rather than relying on cohort sharing, since Taud's K has its own private slot.
|
||||||
|
|
||||||
**Implementation.** On row parse:
|
**Implementation.** On row parse:
|
||||||
|
|
||||||
@@ -500,9 +512,9 @@ The slide writes the per-note axis (same as D); `channel_vol` is untouched. K ha
|
|||||||
|
|
||||||
## L $xy00 — Dual: tone portamento continuation and volume slide $xy
|
## L $xy00 — Dual: tone portamento continuation and volume slide $xy
|
||||||
|
|
||||||
**Plain.** Continues the previously started tone portamento (G) without retriggering, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available here. Like K, L is implemented sorely for tracker compatibility — new compositions should prefer an explicit `G $0000` plus a volume-column slide.
|
**Plain.** Continues the previously started tone portamento (G) without retriggering, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available here. Like K, L is implemented solely for tracker compatibility — new compositions **SHOULD** prefer an explicit `G $0000` plus a volume-column slide.
|
||||||
|
|
||||||
**Compatibility.** ST3 / IT `Lxy` map directly to Taud `L $xy00`. ProTracker / FT2 / XM `5xy` map identically. As with K, source cohort recalls (ST3 shared memory; IT D/K/L vol-slide cohort) MUST be resolved eagerly by the converter; Taud's L has its own private slot.
|
**Compatibility.** ST3 / IT `Lxy` map directly to Taud `L $xy00`. ProTracker / FT2 / XM `5xy` map identically. As with K, source cohort recalls (ST3 shared memory; IT D/K/L vol-slide cohort) **MUST** be resolved eagerly by the converter; Taud's L has its own private slot.
|
||||||
|
|
||||||
**Implementation.** Identical machinery to K with `G` swapped for the LFO update:
|
**Implementation.** Identical machinery to K with `G` swapped for the LFO update:
|
||||||
|
|
||||||
@@ -535,7 +547,7 @@ The slide writes the per-note axis (same as D); `channel_vol` is untouched. L ha
|
|||||||
|
|
||||||
**Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would.
|
**Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would.
|
||||||
|
|
||||||
**Compatibility.** IT `Mxx` maps directly: the source byte is taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory** — `M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) MUST be eagerly resolved by the converter before emit.
|
**Compatibility.** IT `Mxx` maps directly: the source byte **MUST** be taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory** — `M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) **MUST** be eagerly resolved by the converter before emit.
|
||||||
|
|
||||||
**Implementation.**
|
**Implementation.**
|
||||||
|
|
||||||
@@ -651,9 +663,9 @@ The volume modifier table, **computed with arithmetic (no LUT)**, is:
|
|||||||
| 6 | vol × 2 / 3 | E | vol × 3 / 2 |
|
| 6 | vol × 2 / 3 | E | vol × 3 / 2 |
|
||||||
| 7 | vol × 1 / 2 | F | vol × 2 |
|
| 7 | vol × 1 / 2 | F | vol × 2 |
|
||||||
|
|
||||||
Multiplicative cases use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results clip to $00..$3F after.
|
Multiplicative cases **MUST** use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results **MUST** clip to $00..$3F after.
|
||||||
|
|
||||||
A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule.
|
A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) **MUST NOT** be retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -661,7 +673,7 @@ A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not r
|
|||||||
|
|
||||||
**Plain.** Modulates volume with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $4x.
|
**Plain.** Modulates volume with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $4x.
|
||||||
|
|
||||||
**Compatibility.** ST3 `Rxy` uses nibbles; convert by nibble-repeat. ST3's volume cap is $40; Taud's is $3F — very deep tremolo that would have briefly clipped at $40 in ST3 may clip slightly earlier in Taud. R has its own memory slot (not shared with H/U).
|
**Compatibility.** ST3 `Rxy` uses nibbles; converters **MUST** convert by nibble-repeat. ST3's volume cap is $40; Taud's is $3F — very deep tremolo that would have briefly clipped at $40 in ST3 **MAY** clip slightly earlier in Taud. R has its own memory slot (not shared with H/U).
|
||||||
|
|
||||||
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
|
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
|
||||||
|
|
||||||
@@ -691,7 +703,7 @@ Taud splits T by which byte carries the value:
|
|||||||
|
|
||||||
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $19`: Taud byte $00 → 25 BPM, $64 → 125 BPM (default), $FF → 280 BPM.
|
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $19`: Taud byte $00 → 25 BPM, $64 → 125 BPM (default), $FF → 280 BPM.
|
||||||
|
|
||||||
**Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; convert with `taud_byte = xx − $18`. Taud byte $07 corresponds to ST3's minimum BPM of 32; Taud bytes below $07 are inexpressible in ST3 and should round up to $07 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below.
|
**Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; converters **MUST** convert with `taud_byte = xx − $18`. Taud byte $07 corresponds to ST3's minimum BPM of 32; Taud bytes below $07 are inexpressible in ST3 and **SHOULD** round up to $07 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below.
|
||||||
|
|
||||||
ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $19)00`; `Fxx` with `xx < $20` maps to A (speed) instead.
|
ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $19)00`; `Fxx` with `xx < $20` maps to A (speed) instead.
|
||||||
|
|
||||||
@@ -701,7 +713,7 @@ ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $19)00`; `Fxx` with
|
|||||||
|
|
||||||
**Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops.
|
**Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops.
|
||||||
|
|
||||||
**Compatibility.** ST3 itself has only the set form; the slide forms originate in the OpenMPT/Schism extension of S3M. On export to strict ST3, slide forms are unrepresentable and should be approximated as an equivalent set-tempo on a later row.
|
**Compatibility.** ST3 itself has only the set form; the slide forms originate in the OpenMPT/Schism extension of S3M. On export to strict ST3, slide forms are unrepresentable and **SHOULD** be approximated as an equivalent set-tempo on a later row.
|
||||||
|
|
||||||
**Implementation.**
|
**Implementation.**
|
||||||
|
|
||||||
@@ -731,9 +743,9 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
|
|||||||
|
|
||||||
**Plain.** Sets the global mix bus volume (0..$FF). $00 is silence; $FF is full. The default is $80.
|
**Plain.** Sets the global mix bus volume (0..$FF). $00 is silence; $FF is full. The default is $80.
|
||||||
|
|
||||||
**Compatibility.** ST3's global volume is 0..$40; convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40. IT's global volume is 0..$80; convert with `taud_v = it_v × 2`, clamped at $FF. On IT, the very first `V 00` command must be resolved as the song's initial global volume.
|
**Compatibility.** ST3's global volume is 0..$40; converters **MUST** convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40. IT's global volume is 0..$80; converters **MUST** convert with `taud_v = it_v × 2`, clamped at $FF. On IT, the very first `V 00` command **MUST** be resolved as the song's initial global volume.
|
||||||
|
|
||||||
**Implementation.** Write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud follows suit, but new Taud compositions should avoid muting channels that carry global effects.
|
**Implementation.** The engine **MUST** write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud **MUST** follow suit, but new Taud compositions **SHOULD NOT** mute channels that carry global effects.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -761,7 +773,7 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
|
|||||||
|
|
||||||
**Plain.** Modulates panning with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $5x.
|
**Plain.** Modulates panning with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $5x.
|
||||||
|
|
||||||
**Compatibility.** IT `Yxy` uses nibbles; convert by nibble-repeat. IT's panning cap is $40; Taud's is $3F — very deep vibrato that would have briefly clipped at $40 in IT may clip slightly earlier in Taud. Y has its own memory slot.
|
**Compatibility.** IT `Yxy` uses nibbles; converters **MUST** convert by nibble-repeat. IT's panning cap is $40; Taud's is $3F — very deep vibrato that would have briefly clipped at $40 in IT **MAY** clip slightly earlier in Taud. Y has its own memory slot.
|
||||||
|
|
||||||
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
|
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
|
||||||
|
|
||||||
@@ -792,7 +804,7 @@ Boundary rules:
|
|||||||
- The block stops at the end of the pattern: a ditto whose nominal span would overflow the pattern's row count clips silently at the final row.
|
- The block stops at the end of the pattern: a ditto whose nominal span would overflow the pattern's row count clips silently at the final row.
|
||||||
- `$xx = $00`, `$yy = $00`, and any `$xx` greater than the row index on which the ditto sits are all treated as no-ops — there is nothing valid to copy from.
|
- `$xx = $00`, `$yy = $00`, and any `$xx` greater than the row index on which the ditto sits are all treated as no-ops — there is nothing valid to copy from.
|
||||||
- A `7` cell appearing inside a source block is **not** recursively expanded: when that source row is pasted into a destination, its effect column is treated as empty. This keeps expansion single-pass and prevents unbounded nesting.
|
- A `7` cell appearing inside a source block is **not** recursively expanded: when that source row is pasted into a destination, its effect column is treated as empty. This keeps expansion single-pass and prevents unbounded nesting.
|
||||||
- Flow-control effects (B, C, S$Bx, S$Ex) that fall inside a source block still fire when their copy lands on a destination row, since the engine sees them as ordinary effect cells after expansion. Composers and converters should avoid placing S$Bx loop bounds wholly inside a ditto'd range — the loop counter is per-voice and the same destination row would be revisited twice with the same state.
|
- Flow-control effects (B, C, S$Bx, S$Ex) that fall inside a source block still fire when their copy lands on a destination row, since the engine sees them as ordinary effect cells after expansion. Composers and converters **SHOULD NOT** place S$Bx loop bounds wholly inside a ditto'd range — the loop counter is per-voice and the same destination row would be revisited twice with the same state.
|
||||||
|
|
||||||
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
|
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
|
||||||
|
|
||||||
@@ -872,7 +884,7 @@ Effect dispatch sees the synthesised effect, never the literal `7` opcode of the
|
|||||||
- `8 $0000` disables both stages and resets the shared clipping mode to clamp.
|
- `8 $0000` disables both stages and resets the shared clipping mode to clamp.
|
||||||
- `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics.
|
- `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics.
|
||||||
|
|
||||||
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 must spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern.
|
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 **MUST** spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern.
|
||||||
|
|
||||||
**Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse:
|
**Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse:
|
||||||
|
|
||||||
@@ -931,7 +943,7 @@ The voice-FX state is preserved verbatim by the NNA-ghost copier, so the post-NN
|
|||||||
|
|
||||||
## 9 $x0zz — Overdrive
|
## 9 $x0zz — Overdrive
|
||||||
|
|
||||||
**Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume is *not* re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and must be zero.
|
**Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume **MUST NOT** be re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and **MUST** be zero.
|
||||||
|
|
||||||
- **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp.
|
- **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp.
|
||||||
- **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
|
- **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
|
||||||
@@ -975,7 +987,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
|||||||
|
|
||||||
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects that modifies global behaviour)
|
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects that modifies global behaviour)
|
||||||
|
|
||||||
**Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` maps directly. To actually hear the effect, the interpolation mode must be set to one of the two Amiga modes.
|
**Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` map directly. To actually hear the effect, the interpolation mode **MUST** be set to one of the two Amiga modes.
|
||||||
|
|
||||||
**Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping.
|
**Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping.
|
||||||
|
|
||||||
@@ -983,7 +995,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
|||||||
|
|
||||||
## S $1x00 — PT/ST3/IT Glissando control
|
## S $1x00 — PT/ST3/IT Glissando control
|
||||||
|
|
||||||
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility** and therefore only works in 12-TET context.
|
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output **MUST** be quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter **MUST** still advance smoothly; only the audible pitch steps. **This command is implemented solely for ST3/IT compatibility** and therefore only works in 12-TET context.
|
||||||
|
|
||||||
**Compatibility.** ST3/IT `S10`/`S11` and PT `E30`/`E31` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid.
|
**Compatibility.** ST3/IT `S10`/`S11` and PT `E30`/`E31` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid.
|
||||||
|
|
||||||
@@ -995,7 +1007,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
|||||||
|
|
||||||
**Plain.** Overrides the current note's fine-tune by applying a fixed 4096-TET offset. The index `$x` selects one of sixteen predefined pitch offsets, following ScreamTracker 3's Hz-based fine-tune table but expressed directly in Taud units. This command is implemented for ST3 compatibility.
|
**Plain.** Overrides the current note's fine-tune by applying a fixed 4096-TET offset. The index `$x` selects one of sixteen predefined pitch offsets, following ScreamTracker 3's Hz-based fine-tune table but expressed directly in Taud units. This command is implemented for ST3 compatibility.
|
||||||
|
|
||||||
**Compatibility.** The index scheme matches ST3 exactly: `$8` is the baseline (no change), `$0..$7` are progressively flatter, `$9..$F` are progressively sharper. The Hz reference values come from the ST3 User's Manual and are reproduced here for auditability; the Taud offset is `log2(Hz / 8363) × 4096`, rounded to the nearest integer. **Format converters are advised to apply offset to the note value directly.**
|
**Compatibility.** The index scheme matches ST3 exactly: `$8` is the baseline (no change), `$0..$7` are progressively flatter, `$9..$F` are progressively sharper. The Hz reference values come from the ST3 User's Manual and are reproduced here for auditability; the Taud offset is `log2(Hz / 8363) × 4096`, rounded to the nearest integer. **Format converters SHOULD apply the offset to the note value directly.**
|
||||||
|
|
||||||
| $x | Reference Hz | Taud offset |
|
| $x | Reference Hz | Taud offset |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1140,7 +1152,7 @@ The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or it
|
|||||||
|
|
||||||
**Compatibility.** ST3 `SBx` maps directly. ProTracker `E6x` maps to Taud `S $Bx00`.
|
**Compatibility.** ST3 `SBx` maps directly. ProTracker `E6x` maps to Taud `S $Bx00`.
|
||||||
|
|
||||||
ST3 has a long-documented bug where pattern delay (SEx) inside a pattern-loop range causes the loop counter to decrement multiple times per visit, producing unintended behaviour. **Taud fixes this bug.** On import, ST3 songs that relied on the bug will loop fewer times in Taud. Converters that want bit-exact ST3 playback should emit a warning when SBx and SEx appear in the same channel within a loop range, or optionally flatten loops by duplicating rows.
|
ST3 has a long-documented bug where pattern delay (SEx) inside a pattern-loop range causes the loop counter to decrement multiple times per visit, producing unintended behaviour. **Taud fixes this bug.** On import, ST3 songs that relied on the bug will loop fewer times in Taud. Converters that want bit-exact ST3 playback **SHOULD** emit a warning when SBx and SEx appear in the same channel within a loop range, and **MAY** flatten loops by duplicating rows.
|
||||||
|
|
||||||
**Implementation.** State per channel: `loop_start_row` (defaulting to 0 at each pattern entry) and `loop_count` (defaulting to 0).
|
**Implementation.** State per channel: `loop_start_row` (defaulting to 0 at each pattern entry) and `loop_count` (defaulting to 0).
|
||||||
|
|
||||||
@@ -1162,7 +1174,7 @@ on row event (S $Bx00):
|
|||||||
on pattern change: loop_start_row = 0; loop_count = 0
|
on pattern change: loop_start_row = 0; loop_count = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
The crucial bug fix relative to ST3: the loop-counter decrement happens **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine treats the whole delay group as a single visit. Implement this by gating the SBx decrement on `pattern_delay_repetition == 0`.
|
The crucial bug fix relative to ST3: the loop-counter decrement **MUST** happen **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine **MUST** treat the whole delay group as a single visit. Engines **SHOULD** implement this by gating the SBx decrement on `pattern_delay_repetition == 0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1172,7 +1184,7 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
|
|||||||
|
|
||||||
**Compatibility.** ST3 `SCx` maps directly. ProTracker `ECx` also maps directly. ST3 ignores `SC0` (treats it as no cut at all); Taud preserves this.
|
**Compatibility.** ST3 `SCx` maps directly. ProTracker `ECx` also maps directly. ST3 ignores `SC0` (treats it as no cut at all); Taud preserves this.
|
||||||
|
|
||||||
**Implementation.** On tick `$x`, set `output_volume = 0` but leave `base_volume` unchanged. If `$x ≥ speed`, the cut never fires. If `$x == 0`, the command is ignored. Set the `note_was_cut` flag so a later Q retrigger on the same row is suppressed.
|
**Implementation.** On tick `$x`, the engine **MUST** set `output_volume = 0` but **MUST** leave `base_volume` unchanged. If `$x ≥ speed`, the cut **MUST NOT** fire. If `$x == 0`, the command **MUST** be ignored. The engine **MUST** set the `note_was_cut` flag so that a later Q retrigger on the same row is suppressed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1180,9 +1192,9 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
|
|||||||
|
|
||||||
**Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues.
|
**Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues.
|
||||||
|
|
||||||
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed). Taud discards those notes. If such note events have been encountered during conversion, they must be corrected on the converter.
|
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note **MUST NOT** play on this row and **MUST NOT** carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed); Taud **MUST** discard those notes. If such note events have been encountered during conversion, they **MUST** be corrected by the converter.
|
||||||
|
|
||||||
**Implementation.** On row parse, defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger re-fires at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than current tick speed, the note must be discarded (see compatibility notes above)
|
**Implementation.** On row parse, the engine **MUST** defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, the engine **MUST** execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger **MUST** re-fire at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than the current tick speed, the note **MUST** be discarded (see compatibility notes above).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1190,7 +1202,7 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
|
|||||||
|
|
||||||
**Plain.** Repeats the current row `$x` additional times (so `$x = 0` means no repeat and the row plays once; `$x = 3` means the row plays four times total). Notes do not retrigger across repetitions, but per-tick effects re-run and tick-0 events (fine slides, delayed notes) re-fire on each repetition.
|
**Plain.** Repeats the current row `$x` additional times (so `$x = 0` means no repeat and the row plays once; `$x = 3` means the row plays four times total). Notes do not retrigger across repetitions, but per-tick effects re-run and tick-0 events (fine slides, delayed notes) re-fire on each repetition.
|
||||||
|
|
||||||
**Compatibility.** ST3 `SEx` maps directly. ProTracker `EEx` also maps directly. Simultaneous SEx on multiple channels: ST3 uses the first SEx in **pan order** (L1..L8 then R1..R8); **Taud uses the first SEx in ascending channel-index order** for predictability. Converters that encounter ST3 songs relying on the pan-order rule should emit a warning.
|
**Compatibility.** ST3 `SEx` maps directly. ProTracker `EEx` also maps directly. Simultaneous SEx on multiple channels: ST3 uses the first SEx in **pan order** (L1..L8 then R1..R8); **Taud uses the first SEx in ascending channel-index order** for predictability. Converters that encounter ST3 songs relying on the pan-order rule **SHOULD** emit a warning.
|
||||||
|
|
||||||
Q retrigger counters do **not** reset between SEx repetitions.
|
Q retrigger counters do **not** reset between SEx repetitions.
|
||||||
|
|
||||||
@@ -1202,7 +1214,7 @@ Q retrigger counters do **not** reset between SEx repetitions.
|
|||||||
|
|
||||||
**Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster.
|
**Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster.
|
||||||
|
|
||||||
**Compatibility.** ProTracker `EFx` is destructive — it XORs bytes directly in the sample data, permanently corrupting the sample. **Taud's implementation is non-destructive**: the XOR is applied at playback time through a per-instrument bit-mask, leaving source samples pristine. ST3 does not implement SFx at all and will parse Taud's S $Fx00 as a no-op; converters targeting ST3 should drop the effect. ProTracker `EFx` imports as Taud `S $Fyyy`, where `yyy = funk_table[x]`.
|
**Compatibility.** ProTracker `EFx` is destructive — it XORs bytes directly in the sample data, permanently corrupting the sample. **Taud's implementation MUST be non-destructive**: the XOR **MUST** be applied at playback time through a per-instrument bit-mask, leaving source samples pristine. ST3 does not implement SFx at all and will parse Taud's S $Fx00 as a no-op; converters targeting ST3 **SHOULD** drop the effect. ProTracker `EFx` imports as Taud `S $Fyyy`, where `yyy = funk_table[x]`.
|
||||||
|
|
||||||
**Implementation.** Each instrument carries a `funk_mask` bit array, one bit per byte of the loop region, all zero at song start. A per-channel counter `funk_accumulator` and a per-channel `funk_write_pos` track progress.
|
**Implementation.** Each instrument carries a `funk_mask` bit array, one bit per byte of the loop region, all zero at song start. A per-channel counter `funk_accumulator` and a per-channel `funk_write_pos` track progress.
|
||||||
|
|
||||||
@@ -1224,7 +1236,7 @@ on sample byte read during loop playback:
|
|||||||
output_byte = raw_byte
|
output_byte = raw_byte
|
||||||
```
|
```
|
||||||
|
|
||||||
`S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` resets to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` persist across notes. The `funk_mask` itself is **only cleared on cue-start reset** (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay always reproduces the same audio without needing to reload the song from disk.
|
`S $F000` **MUST** clear `funk_accumulator` but **MUST** leave `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` **MUST** reset to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` **MUST** persist across notes. The `funk_mask` itself **MUST** be cleared only on cue-start reset (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay **MUST** reproduce the same audio without needing to reload the song from disk.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1239,7 +1251,7 @@ Each cell carries a 6-bit value field plus a 2-bit selector field for the volume
|
|||||||
|
|
||||||
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M).
|
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M).
|
||||||
|
|
||||||
When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). Note that *converted* M and N still target `note_vol` here (vol-col semantics) — to preserve the original per-channel intent, emit them in the main effect column instead.
|
When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). Note that *converted* M and N still target `note_vol` here (vol-col semantics) — to preserve the original per-channel intent, converters **MUST** emit them in the main effect column instead.
|
||||||
|
|
||||||
NOTE: **`3.00` — is No-op**
|
NOTE: **`3.00` — is No-op**
|
||||||
|
|
||||||
@@ -1254,7 +1266,7 @@ The panning column uses the same 6-bit value + 2-bit selector layout:
|
|||||||
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit).
|
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit).
|
||||||
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
|
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
|
||||||
|
|
||||||
NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command takes precedence.
|
NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command **MUST** take precedence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1272,7 +1284,7 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
|
|||||||
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||||
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
|
||||||
|
|
||||||
- rrr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear.
|
- rrr = 0: Yes interpolation. The actual interpolation algorithm is implementation-dependent; Fast Sinc or Linear is **RECOMMENDED**.
|
||||||
- rrr = 1: No interpolation.
|
- rrr = 1: No interpolation.
|
||||||
- rrr = 2: Amiga 500 interpolation.
|
- rrr = 2: Amiga 500 interpolation.
|
||||||
- rrr = 3: Amiga 1200 interpolation.
|
- rrr = 3: Amiga 1200 interpolation.
|
||||||
@@ -1326,30 +1338,30 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
|
|||||||
|
|
||||||
These quirks of ST3 are worth preserving or flagging when importing S3M files into Taud:
|
These quirks of ST3 are worth preserving or flagging when importing S3M files into Taud:
|
||||||
|
|
||||||
**Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter must **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000.
|
**Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter **MUST** **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000.
|
||||||
|
|
||||||
**M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none.
|
**M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none.
|
||||||
|
|
||||||
**Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Decode on import; encode on export. Out-of-range BCD bytes (decimal 64 or higher) clamp to row 0.
|
**Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Converters **MUST** decode on import and encode on export. Out-of-range BCD bytes (decimal 64 or higher) **SHOULD** clamp to row 0.
|
||||||
|
|
||||||
**Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 25..280). Imported ST3 tempos must be shifted down by $19; Taud tempos below $07 and above $E6 cannot be represented in ST3 and should clamp on export.
|
**Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 25..280). Imported ST3 tempos **MUST** be shifted down by $19; Taud tempos below $07 and above $E6 cannot be represented in ST3 and **SHOULD** clamp on export.
|
||||||
|
|
||||||
**SBx + SEx interaction.** ST3 miscounts loop iterations when pattern delay is active inside a pattern loop; Taud fixes this. Songs that depended on the bug for their intended playback will loop fewer times in Taud. Flag such songs on import.
|
**SBx + SEx interaction.** ST3 miscounts loop iterations when pattern delay is active inside a pattern loop; Taud fixes this. Songs that depended on the bug for their intended playback will loop fewer times in Taud. Converters **SHOULD** flag such songs on import.
|
||||||
|
|
||||||
**Simultaneous SEx priority.** ST3 uses pan order (L1..L8, R1..R8); Taud uses ascending channel-index order. Rare; flag on import if multiple channels carry SEx in the same row.
|
**Simultaneous SEx priority.** ST3 uses pan order (L1..L8, R1..R8); Taud uses ascending channel-index order. Rare; converters **SHOULD** flag on import if multiple channels carry SEx in the same row.
|
||||||
|
|
||||||
**Muted channels.** ST3 skips all effect processing on muted channels (no volume change, no tempo change, no jumps); Taud follows this rule for strict compatibility but recommends that new compositions avoid muting channels that carry global effects.
|
**Muted channels.** ST3 skips all effect processing on muted channels (no volume change, no tempo change, no jumps); Taud **MUST** follow this rule for strict compatibility, but new compositions **SHOULD NOT** mute channels that carry global effects.
|
||||||
|
|
||||||
**Volume cap.** ST3's volume caps at $40; Taud's at $3F. Notes that reached $40 in ST3 (a rare edge) will play marginally quieter in Taud.
|
**Volume cap.** ST3's volume caps at $40; Taud's at $3F. Notes that reached $40 in ST3 (a rare edge) will play marginally quieter in Taud.
|
||||||
|
|
||||||
**Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import, truncated ÷4 on export.
|
**Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import and a truncated ÷4 on export. Converters **MUST** apply these scales.
|
||||||
|
|
||||||
**Linear pitch slides.** ST3's slide arithmetic is period-based; Taud supports both linear and period-based and selects between them via the song-table `f` flag. Conversion rules:
|
**Linear pitch slides.** ST3's slide arithmetic is period-based; Taud supports both linear and period-based and selects between them via the song-table `f` flag. Conversion rules:
|
||||||
|
|
||||||
- Clear `linear_slides`. Both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
|
- Clear `linear_slides`. Both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
|
||||||
- G (tone portamento) is always converted with `round(× 64/3)` and treated as linear, regardless of mode.
|
- G (tone portamento) **MUST** always be converted with `round(× 64/3)` and treated as linear, regardless of mode.
|
||||||
|
|
||||||
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters must remap on both import and export.
|
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters **MUST** remap on both import and export.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1361,7 +1373,7 @@ This section documents important implementation details that are not covered by
|
|||||||
|
|
||||||
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
|
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
|
||||||
|
|
||||||
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine maintains a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off:
|
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine **MUST** maintain a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off **MUST**:
|
||||||
|
|
||||||
```
|
```
|
||||||
fadeoutVolume -= storedFadeout / 1024.0
|
fadeoutVolume -= storedFadeout / 1024.0
|
||||||
@@ -1386,7 +1398,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
|||||||
- `storedFadeout = 32` → fade ≈ 640 ms
|
- `storedFadeout = 32` → fade ≈ 640 ms
|
||||||
- `storedFadeout = 1024` → ~20 ms (one tick)
|
- `storedFadeout = 1024` → ~20 ms (one tick)
|
||||||
|
|
||||||
**Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters scale the source value into Taud's 0..4095 field.
|
**Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters **MUST** scale the source value into Taud's 0..4095 field.
|
||||||
|
|
||||||
- **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:**
|
- **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:**
|
||||||
```python
|
```python
|
||||||
@@ -1416,7 +1428,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
|||||||
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
|
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
|
||||||
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
|
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
|
||||||
|
|
||||||
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it.
|
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player **MUST** write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects **MAY** override it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
con.reset_graphics();con.curs_set(0);con.clear();
|
con.reset_graphics();con.curs_set(0);con.clear();
|
||||||
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 15);graphics.setBackground(0,0,0);
|
graphics.resetPalette();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="));
|
|
||||||
|
|
||||||
|
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
|
// display logo in kickin' ass-style of panasonic
|
||||||
// hide entire framebuffer with black text to hide the slow image drawing
|
// 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();
|
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
||||||
// clear screen
|
// clear screen
|
||||||
graphics.clearPixels(255);con.color_pair(239,255);
|
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 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 put set-xxx commands here:
|
rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
|
||||||
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||||
set INCLPATH=\hopper\include;$INCLPATH
|
rem
|
||||||
set HELPPATH=\hopper\help;$HELPPATH
|
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||||
set KEYBOARD=us_colemak
|
rem run per-console here rather than once at boot.
|
||||||
|
|
||||||
rem this line specifies which shell to be presented after the boot precess:
|
|
||||||
tvdos/i18n/korean
|
tvdos/i18n/korean
|
||||||
zfm
|
|
||||||
|
rem The interactive shell for this console.
|
||||||
command -fancy
|
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)
|
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot script
|
// Boot script. The work is split across two files:
|
||||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
// \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")
|
// Environment first, boot and pane alike. Gives every pane the same
|
||||||
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
|
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
|
||||||
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
|
// \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()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
561
assets/disk0/tvdos/VTMGR.SYS
Normal file
561
assets/disk0/tvdos/VTMGR.SYS
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
// vtmgr — virtual console manager for TVDOS
|
||||||
|
//
|
||||||
|
// Spawns up to 6 independent shell sessions (virtual consoles), each in its
|
||||||
|
// own parallel GraalVM context with its own thread. Each pane runs a real
|
||||||
|
// `command -fancy` shell. The dispatcher (this file) owns the physical
|
||||||
|
// keyboard, polls Alt-N hotkeys at 30 Hz, blits the active pane's text
|
||||||
|
// plane to the GPU's text area, and routes typed characters into the
|
||||||
|
// active pane's input ring buffer.
|
||||||
|
//
|
||||||
|
// Hotkeys: Alt-1..Alt-6 switch to that VT (lazy-spawn on first use).
|
||||||
|
// Alt-0 cleanly tears down vtmgr.
|
||||||
|
// Builtins: `chvt N` from inside a pane writes to the switch register.
|
||||||
|
|
||||||
|
// ─── shared memory layout ───────────────────────────────────────────────────
|
||||||
|
// CTRL_AREA (64 bytes from base)
|
||||||
|
// +0 active_vt u8 (1..6)
|
||||||
|
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
|
||||||
|
// +2 debounce_held u8
|
||||||
|
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
|
||||||
|
// +4..63 reserved
|
||||||
|
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
|
||||||
|
// +0..7 reserved (cursor & color state lives inside text plane itself)
|
||||||
|
// +8 queue_head u8 (next-read index)
|
||||||
|
// +9 queue_tail u8 (next-write index)
|
||||||
|
// +10..11 reserved
|
||||||
|
// +12..267 queue_data (256-byte ring buffer; one slot lost to full/empty disambiguation)
|
||||||
|
// +268..271 reserved (alignment)
|
||||||
|
// +272..7953 text_plane (7682 bytes; mirrors GPU textArea layout exactly)
|
||||||
|
|
||||||
|
const MAX_VT = 6
|
||||||
|
const CTRL_AREA_SIZE = 64
|
||||||
|
const VT_BLOCK_SIZE = 8000
|
||||||
|
const TEXT_PLANE_OFFSET = 272
|
||||||
|
const TEXT_PLANE_SIZE = 7682
|
||||||
|
const QUEUE_DATA_OFFSET = 12
|
||||||
|
|
||||||
|
const CTRL_ACTIVE_VT = 0
|
||||||
|
const CTRL_SWITCH_REQUEST = 1
|
||||||
|
const CTRL_DEBOUNCE_HELD = 2
|
||||||
|
const CTRL_SPAWNED_BITS = 3
|
||||||
|
|
||||||
|
const GPU_TEXTAREA_OFFSET = 253950
|
||||||
|
const TEXT_COLS = 80
|
||||||
|
const TEXT_ROWS = 32
|
||||||
|
|
||||||
|
const TP_FORE_BASE = 2
|
||||||
|
const TP_BACK_BASE = 2 + 2560
|
||||||
|
const TP_TEXT_BASE = 2 + 2560 + 2560
|
||||||
|
|
||||||
|
const TOTAL_ALLOC_SIZE = CTRL_AREA_SIZE + MAX_VT * VT_BLOCK_SIZE
|
||||||
|
const BASE = sys.malloc(TOTAL_ALLOC_SIZE)
|
||||||
|
if (!BASE || BASE === 0) { printerrln("vtmgr: sys.malloc failed"); return 1 }
|
||||||
|
for (let i = 0; i < TOTAL_ALLOC_SIZE; i++) sys.poke(BASE + i, 0)
|
||||||
|
|
||||||
|
const CTRL = BASE
|
||||||
|
function vtBlockAddr(n) { return BASE + CTRL_AREA_SIZE + (n - 1) * VT_BLOCK_SIZE }
|
||||||
|
function vtTextPlaneAddr(n) { return vtBlockAddr(n) + TEXT_PLANE_OFFSET }
|
||||||
|
|
||||||
|
// ─── pane bootstrap ─────────────────────────────────────────────────────────
|
||||||
|
// Read TVDOS.SYS once at startup. Each pane's bootstrap embeds the source
|
||||||
|
// (via JSON.stringify-escaped string literal) and evaluates it together with
|
||||||
|
// the shell-start code as ONE direct-eval call. This matters because strict-
|
||||||
|
// mode direct eval is scope-isolated; if TVDOS.SYS and the shell launcher
|
||||||
|
// were two separate evals, the shell launcher wouldn't see `_TVDOS`,
|
||||||
|
// `files`, `execApp`, etc. defined by the first eval.
|
||||||
|
|
||||||
|
const TVDOS_SYS_SRC = files.open("A:/tvdos/TVDOS.SYS").sread()
|
||||||
|
|
||||||
|
// _BIOS is set by the real BIOS before TVDOS.SYS runs; TVDOS.SYS reads
|
||||||
|
// _BIOS.FIRST_BOOTABLE_PORT during init. Each pane is a fresh context with no
|
||||||
|
// BIOS, so capture the live value here (vtmgr runs in the main context where
|
||||||
|
// _BIOS is visible) and re-declare it in every pane bootstrap.
|
||||||
|
const BIOS_FIRST_BOOTABLE_PORT = JSON.stringify(_BIOS.FIRST_BOOTABLE_PORT)
|
||||||
|
|
||||||
|
// Environment no longer needs snapshotting/replaying: each pane re-evaluates
|
||||||
|
// TVDOS.SYS, whose boot block runs \commandrc in every context, so the pane
|
||||||
|
// gets the same PATH / KEYBOARD / etc. natively. The pane then runs
|
||||||
|
// \AUTOEXEC.BAT (the per-console launch script: IME + interactive shell).
|
||||||
|
|
||||||
|
function makePaneBootstrap(vtNum) {
|
||||||
|
const TP_BASE = vtTextPlaneAddr(vtNum)
|
||||||
|
const VT_BLK = vtBlockAddr(vtNum)
|
||||||
|
|
||||||
|
// Launcher code runs after TVDOS.SYS in the SAME eval scope, so `files`,
|
||||||
|
// `eval`, `_TVDOS` etc. resolve via lexical closure. TVDOS.SYS's boot
|
||||||
|
// block already ran \commandrc (env) and skipped its own AUTOEXEC because
|
||||||
|
// the pane sets _TVDOS_IS_VT_PANE; here we run \AUTOEXEC.BAT to launch the
|
||||||
|
// per-console shell.
|
||||||
|
const SHELL_START = ";\n"
|
||||||
|
+ "var _cmdfileSrc = files.open('A:/tvdos/bin/command.js').sread();\n"
|
||||||
|
+ "eval('var _VTSHELL=function(exec_args){' + _cmdfileSrc + '\\n};_VTSHELL')(['', '-c', '\\\\AUTOEXEC.BAT']);\n"
|
||||||
|
|
||||||
|
const combined = TVDOS_SYS_SRC + SHELL_START
|
||||||
|
|
||||||
|
const raw = `
|
||||||
|
globalThis.VT_NUM = ${vtNum}
|
||||||
|
globalThis.VT_TEXT_PLANE = ${TP_BASE}
|
||||||
|
globalThis.VT_BLOCK_ADDR = ${VT_BLK}
|
||||||
|
globalThis.VT_CTRL_ADDR = ${CTRL}
|
||||||
|
const TP = ${TP_BASE}
|
||||||
|
const VT_BLK = ${VT_BLK}
|
||||||
|
const CTRL = ${CTRL}
|
||||||
|
const QUEUE_DATA = VT_BLK + ${QUEUE_DATA_OFFSET}
|
||||||
|
const QUEUE_HEAD_ADDR = VT_BLK + 8
|
||||||
|
const QUEUE_TAIL_ADDR = VT_BLK + 9
|
||||||
|
const ACTIVE_VT_ADDR = CTRL + ${CTRL_ACTIVE_VT}
|
||||||
|
const COLS = ${TEXT_COLS}, ROWS = ${TEXT_ROWS}
|
||||||
|
const FORE_BASE = ${TP_FORE_BASE}, BACK_BASE = ${TP_BACK_BASE}, TEXT_BASE = ${TP_TEXT_BASE}
|
||||||
|
|
||||||
|
// ── output shims (write into the per-VT text-plane buffer in shared mem) ──
|
||||||
|
// This is a faithful JS port of the GPU's TTY interpreter (GlassTty.acceptChar
|
||||||
|
// + GraphicsAdapter handlers). TVDOS apps drive the screen by printing control
|
||||||
|
// bytes and escape sequences through print(), so the shim must interpret them
|
||||||
|
// exactly as the hardware would: the \\x84<decimal>u "emit char by code" escape
|
||||||
|
// (used by con.prnch), CSI cursor moves / erase / SGR colours, and the ?25
|
||||||
|
// cursor-visibility private sequence.
|
||||||
|
let curX = 0, curY = 0
|
||||||
|
let foreCol = 254
|
||||||
|
let backCol = 255
|
||||||
|
|
||||||
|
// Per-pane cursor visibility lives at VT_BLK+2 (1 = blink on, 0 = hidden).
|
||||||
|
// The compositor pushes the active pane's value into the GPU's blink bit.
|
||||||
|
const CURSOR_VIS_ADDR = VT_BLK + 2
|
||||||
|
sys.poke(CURSOR_VIS_ADDR, 1)
|
||||||
|
|
||||||
|
// SGR 30-37 / 40-47 → default 8-colour palette (matches GraphicsAdapter).
|
||||||
|
const SGR_PAL = [240, 211, 61, 230, 49, 219, 114, 254]
|
||||||
|
|
||||||
|
function writeCursor() {
|
||||||
|
let pos = curY * COLS + curX
|
||||||
|
sys.poke(TP + 0, pos & 0xFF)
|
||||||
|
sys.poke(TP + 1, (pos >> 8) & 0xFF)
|
||||||
|
}
|
||||||
|
function scrollBufUp(n) {
|
||||||
|
if (n < 1) n = 1
|
||||||
|
if (n > ROWS) n = ROWS
|
||||||
|
for (let p of [FORE_BASE, BACK_BASE, TEXT_BASE]) {
|
||||||
|
for (let y = 0; y < ROWS - n; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
sys.poke(TP + p + y * COLS + x, sys.peek(TP + p + (y + n) * COLS + x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let clearVal = (p === TEXT_BASE) ? 0 : (p === FORE_BASE ? foreCol : backCol)
|
||||||
|
for (let y = ROWS - n; y < ROWS; y++)
|
||||||
|
for (let x = 0; x < COLS; x++) sys.poke(TP + p + y * COLS + x, clearVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function putCharRaw(x, y, c) {
|
||||||
|
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return
|
||||||
|
let off = y * COLS + x
|
||||||
|
sys.poke(TP + TEXT_BASE + off, c & 0xFF)
|
||||||
|
sys.poke(TP + FORE_BASE + off, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + off, backCol)
|
||||||
|
}
|
||||||
|
// Mirror of GraphicsAdapter.setCursorPos: wrap on overflow x, scroll on
|
||||||
|
// overflow y, clamp y above the screen.
|
||||||
|
function setCursorPos(x, y) {
|
||||||
|
let nx = x, ny = y
|
||||||
|
if (nx >= COLS) { nx = 0; ny += 1 }
|
||||||
|
else if (nx < 0) nx = 0
|
||||||
|
if (ny < 0) ny = 0
|
||||||
|
else if (ny >= ROWS) { scrollBufUp(ny - ROWS + 1); ny = ROWS - 1 }
|
||||||
|
curX = nx; curY = ny
|
||||||
|
writeCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTY actions (mirror the GraphicsAdapter overrides) ────────────────────
|
||||||
|
function ttyPrintable(c) { putCharRaw(curX, curY, c); setCursorPos(curX + 1, curY) }
|
||||||
|
function ttyCrlf() {
|
||||||
|
let ny = curY + 1
|
||||||
|
setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny)
|
||||||
|
if (ny >= ROWS) scrollBufUp(1)
|
||||||
|
}
|
||||||
|
function ttyBackspace() { let x = curX, y = curY; setCursorPos(x - 1, y); putCharRaw(curX, curY, 0x20) }
|
||||||
|
function ttyTab() { setCursorPos(((curX / 8 | 0) + 1) * 8, curY) }
|
||||||
|
function ttyResetStatus() { foreCol = 253; backCol = 255 }
|
||||||
|
function ttyEmitChar(code) { putCharRaw(curX, curY, code); setCursorPos(curX + 1, curY) }
|
||||||
|
function ttyCursorUp(n) { setCursorPos(curX, curY - n) }
|
||||||
|
function ttyCursorDown(n) { let ny = curY + n; setCursorPos(curX, (ny >= ROWS) ? ROWS - 1 : ny) }
|
||||||
|
function ttyCursorFwd(n) { setCursorPos(curX + n, curY) }
|
||||||
|
function ttyCursorBack(n) { setCursorPos(curX - n, curY) }
|
||||||
|
function ttyCursorNextLine(n) { let ny = curY + n; setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny); if (ny >= ROWS) scrollBufUp(ny - ROWS + 1) }
|
||||||
|
function ttyCursorPrevLine(n) { setCursorPos(0, curY - n) }
|
||||||
|
function ttyCursorX(n) { setCursorPos(n, curY) }
|
||||||
|
function ttyCursorXY(row, col) { setCursorPos(col - 1, row - 1) }
|
||||||
|
function ttyEraseInDisp(arg) {
|
||||||
|
if (arg === 2) {
|
||||||
|
for (let i = 0; i < COLS * ROWS; i++) {
|
||||||
|
sys.poke(TP + TEXT_BASE + i, 0)
|
||||||
|
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + i, backCol)
|
||||||
|
}
|
||||||
|
curX = 0; curY = 0; writeCursor()
|
||||||
|
}
|
||||||
|
// other args: GraphicsAdapter TODOs (throws); we no-op for safety
|
||||||
|
}
|
||||||
|
function ttySgr1(arg) {
|
||||||
|
if (arg >= 30 && arg <= 37) foreCol = SGR_PAL[arg - 30]
|
||||||
|
else if (arg >= 40 && arg <= 47) backCol = SGR_PAL[arg - 40]
|
||||||
|
else if (arg === 7) { let t = foreCol; foreCol = backCol; backCol = t }
|
||||||
|
else if (arg === 0) { foreCol = 253; backCol = 255; sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||||
|
}
|
||||||
|
function ttySgr3(a1, a2, a3) {
|
||||||
|
if (a1 === 38 && a2 === 5) foreCol = a3
|
||||||
|
else if (a1 === 48 && a2 === 5) backCol = a3
|
||||||
|
}
|
||||||
|
function ttyPrivH(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||||
|
function ttyPrivL(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 0) }
|
||||||
|
|
||||||
|
// ── escape-sequence state machine (mirror of GlassTty.acceptChar) ─────────
|
||||||
|
// States: 0 INITIAL, 1 ESC, 2 CSI, 3 NUM1, 4 SEP1, 5 NUM2, 6 SEP2, 7 NUM3,
|
||||||
|
// 8 PRIVATESEQ, 9 PRIVATENUM, 10 XCSI, 11 XNUM1
|
||||||
|
let escState = 0
|
||||||
|
let escArgs = []
|
||||||
|
function isDig(c) { return c >= 0x30 && c <= 0x39 }
|
||||||
|
function escReset() { escState = 0; escArgs.length = 0 }
|
||||||
|
// reject() in hardware returns the char as printable; replicate by printing it
|
||||||
|
function escRejectPrint(c) { escReset(); ttyPrintable(c) }
|
||||||
|
|
||||||
|
function processByte(c) {
|
||||||
|
switch (escState) {
|
||||||
|
case 0: // INITIAL
|
||||||
|
if (c === 0x1B) escState = 1
|
||||||
|
else if (c === 0x84) escState = 10
|
||||||
|
else if (c === 0x0A) ttyCrlf()
|
||||||
|
else if (c === 0x08) ttyBackspace()
|
||||||
|
else if (c === 0x09) ttyTab()
|
||||||
|
else if (c === 0x07) { /* bell */ }
|
||||||
|
else if (c >= 0x00 && c <= 0x1F) { /* other control: ignored */ }
|
||||||
|
else ttyPrintable(c)
|
||||||
|
break
|
||||||
|
case 1: // ESC
|
||||||
|
if (c === 0x63) { ttyResetStatus(); escReset() } // 'c'
|
||||||
|
else if (c === 0x5B) escState = 2 // '['
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 2: // CSI
|
||||||
|
if (c === 0x41) { ttyCursorUp(1); escReset() }
|
||||||
|
else if (c === 0x42) { ttyCursorDown(1); escReset() }
|
||||||
|
else if (c === 0x43) { ttyCursorFwd(1); escReset() }
|
||||||
|
else if (c === 0x44) { ttyCursorBack(1); escReset() }
|
||||||
|
else if (c === 0x45) { ttyCursorNextLine(1); escReset() }
|
||||||
|
else if (c === 0x46) { ttyCursorPrevLine(1); escReset() }
|
||||||
|
else if (c === 0x47) { ttyCursorX(1); escReset() }
|
||||||
|
else if (c === 0x4A) { ttyEraseInDisp(0); escReset() }
|
||||||
|
else if (c === 0x4B) { escReset() } // eraseInLine: no-op
|
||||||
|
else if (c === 0x53) { scrollBufUp(1); escReset() } // S
|
||||||
|
else if (c === 0x54) { escReset() } // T scrollDown: no-op
|
||||||
|
else if (c === 0x6D) { ttySgr1(0); escReset() } // m
|
||||||
|
else if (c === 0x3F) escState = 8 // '?'
|
||||||
|
else if (c === 0x3B) { escArgs.push(0); escState = 4 } // ';'
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 3 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 3: // NUM1
|
||||||
|
if (c === 0x41) { ttyCursorUp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x42) { ttyCursorDown(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x43) { ttyCursorFwd(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x44) { ttyCursorBack(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x45) { ttyCursorNextLine(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x46) { ttyCursorPrevLine(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x47) { ttyCursorX(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x4A) { ttyEraseInDisp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x4B) { escArgs.pop(); escReset() }
|
||||||
|
else if (c === 0x53) { scrollBufUp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x54) { escArgs.pop(); escReset() }
|
||||||
|
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x3B) escState = 4
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 4: // SEP1 (seen "n;")
|
||||||
|
if (isDig(c)) { escArgs.push(c - 0x30); escState = 5 }
|
||||||
|
else if (c === 0x48) { let a1 = escArgs.pop(); ttyCursorXY(a1, 0); escReset() } // H
|
||||||
|
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() } // m (2-arg unimpl in HW)
|
||||||
|
else if (c === 0x3B) { escArgs.push(0); escState = 6 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 5: // NUM2 (seen "n;n")
|
||||||
|
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else if (c === 0x48) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttyCursorXY(a1, a2); escReset() }
|
||||||
|
else if (c === 0x6D) { escArgs.pop(); escArgs.pop(); escReset() } // 2-arg SGR unimpl in HW
|
||||||
|
else if (c === 0x3B) escState = 6
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 6: // SEP2 (seen "n;n;")
|
||||||
|
if (c === 0x6D) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, 0); escReset() }
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 7 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 7: // NUM3 (seen "n;n;n")
|
||||||
|
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else if (c === 0x6D) { let a3 = escArgs.pop(), a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, a3); escReset() }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 8: // PRIVATESEQ (seen "?")
|
||||||
|
if (isDig(c)) { escArgs.push(c - 0x30); escState = 9 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 9: // PRIVATENUM (seen "?n")
|
||||||
|
if (c === 0x68) { ttyPrivH(escArgs.pop()); escReset() } // h
|
||||||
|
else if (c === 0x6C) { ttyPrivL(escArgs.pop()); escReset() } // l
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 10: // XCSI (seen \\x84)
|
||||||
|
if (c === 0x75) { ttyEmitChar(0); escReset() } // 'u'
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 11 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 11: // XNUM1 (seen \\x84<digits>)
|
||||||
|
if (c === 0x75) { ttyEmitChar(escArgs.pop()); escReset() } // 'u'
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print = function(s) {
|
||||||
|
if (s === undefined || s === null) return
|
||||||
|
let str = '' + s
|
||||||
|
for (let i = 0; i < str.length; i++) processByte(str.charCodeAt(i))
|
||||||
|
}
|
||||||
|
println = function(s) {
|
||||||
|
if (s === undefined) print("\\n")
|
||||||
|
else print(s + "\\n")
|
||||||
|
}
|
||||||
|
printerr = function(s) { print(s) }
|
||||||
|
printerrln = function(s) { println(s) }
|
||||||
|
|
||||||
|
// command.js's shell.execute reassigns the global print/println/printerr/
|
||||||
|
// printerrln to shell.stdio.out.* (which call sys.print → physical GPU,
|
||||||
|
// bypassing these shims). Expose the buffer writers through a global hook so
|
||||||
|
// shell.stdio.out can delegate to them when running inside a VT pane. The
|
||||||
|
// non-VT path in command.js stays unchanged (hook is undefined there).
|
||||||
|
globalThis.__VT_OUT = { print: print, println: println, printerr: printerr, printerrln: printerrln }
|
||||||
|
|
||||||
|
// con.move / con.getyx are 1-based in TVDOS (graphics.setCursorYX does cx-1,
|
||||||
|
// getCursorYX returns cx+1). Internal curX/curY are 0-based, so convert.
|
||||||
|
con.move = function(y, x) {
|
||||||
|
curY = Math.max(0, Math.min(ROWS - 1, (y | 0) - 1))
|
||||||
|
curX = Math.max(0, Math.min(COLS - 1, (x | 0) - 1))
|
||||||
|
writeCursor()
|
||||||
|
}
|
||||||
|
con.getyx = function() { return [curY + 1, curX + 1] }
|
||||||
|
con.getmaxyx = function() { return [ROWS, COLS] }
|
||||||
|
con.color_pair = function(f, b) { foreCol = f & 0xFF; backCol = b & 0xFF }
|
||||||
|
con.color_fore = function(n) { foreCol = n & 0xFF }
|
||||||
|
con.color_back = function(n) { backCol = n & 0xFF }
|
||||||
|
con.get_color_fore = function() { return foreCol }
|
||||||
|
con.get_color_back = function() { return backCol }
|
||||||
|
// addch writes a glyph at the cursor WITHOUT advancing — matching
|
||||||
|
// graphics.putSymbol(). TVDOS code pairs addch with explicit curs_right();
|
||||||
|
// advancing here would double-step and leave gaps (e.g. the fancy prompt).
|
||||||
|
con.addch = function(c) { putCharRaw(curX, curY, c) }
|
||||||
|
con.mvaddch = function(y, x, c) { con.move(y, x); con.addch(c) }
|
||||||
|
con.curs_up = function(n) { n = n || 1; curY = Math.max(0, curY - n); writeCursor() }
|
||||||
|
con.curs_down = function(n) { n = n || 1; curY = Math.min(ROWS - 1, curY + n); writeCursor() }
|
||||||
|
con.curs_left = function(n) { n = n || 1; curX = Math.max(0, curX - n); writeCursor() }
|
||||||
|
con.curs_right = function(n) { n = n || 1; curX = Math.min(COLS - 1, curX + n); writeCursor() }
|
||||||
|
con.curs_set = function(arg) { sys.poke(CURSOR_VIS_ADDR, ((arg | 0) === 0) ? 0 : 1) }
|
||||||
|
con.video_reverse = function() { /* unsupported; ANSI swallowed */ }
|
||||||
|
con.reset_graphics = function() { foreCol = 254; backCol = 255 }
|
||||||
|
con.clear = function() {
|
||||||
|
for (let i = 0; i < COLS * ROWS; i++) {
|
||||||
|
sys.poke(TP + TEXT_BASE + i, 0)
|
||||||
|
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + i, backCol)
|
||||||
|
}
|
||||||
|
curX = 0; curY = 0; writeCursor()
|
||||||
|
}
|
||||||
|
// prnch prints a glyph and DOES advance (unlike addch) — the real impl emits
|
||||||
|
// it through print() as \\x84<code>u, so route it through the interpreter.
|
||||||
|
con.prnch = function(c) {
|
||||||
|
if (Array.isArray(c)) c.forEach(x => ttyEmitChar(x))
|
||||||
|
else ttyEmitChar(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── input shims ──────────────────────────────────────────────────────────
|
||||||
|
// Pane reads from its own ring buffer in shared mem. NEVER touches physical
|
||||||
|
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
|
||||||
|
// gate on active_vt keeps background panes parked when they call getch.
|
||||||
|
|
||||||
|
function queuePop() {
|
||||||
|
let head = sys.peek(QUEUE_HEAD_ADDR)
|
||||||
|
let tail = sys.peek(QUEUE_TAIL_ADDR)
|
||||||
|
if (head === tail) return -1
|
||||||
|
let b = sys.peek(QUEUE_DATA + head)
|
||||||
|
sys.poke(QUEUE_HEAD_ADDR, (head + 1) & 0xFF)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
con.getch = function() {
|
||||||
|
while (true) {
|
||||||
|
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
|
||||||
|
let k = queuePop()
|
||||||
|
if (k >= 0) return k
|
||||||
|
}
|
||||||
|
sys.sleep(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
con.hitterminate = function() { return false }
|
||||||
|
con.hiteof = function() { return false }
|
||||||
|
con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) }
|
||||||
|
con.poll_keys = function() { return [0,0,0,0,0,0,0,0] }
|
||||||
|
|
||||||
|
// ── TVDOS.SYS init flags + BIOS stub ───────────────────────────────────────
|
||||||
|
globalThis._TVDOS_IS_VT_PANE = true
|
||||||
|
globalThis._BIOS = { FIRST_BOOTABLE_PORT: ${BIOS_FIRST_BOOTABLE_PORT} }
|
||||||
|
|
||||||
|
// ── load TVDOS.SYS and run AUTOEXEC.BAT (the per-console shell) in one direct-eval ─────
|
||||||
|
// Strict-mode direct eval is scope-isolated, so TVDOS.SYS's \`const _TVDOS\`
|
||||||
|
// only survives within the eval scope. The shell launcher must run inside
|
||||||
|
// the same eval to access it (via lexical closure into nested evals).
|
||||||
|
eval(${JSON.stringify(combined)})
|
||||||
|
`
|
||||||
|
// The outer execApp's injectIntChk rewrote the first while/for/do (each
|
||||||
|
// kind) in our literal source to call a per-exec SIGTERM check function.
|
||||||
|
// Some of those rewrites landed inside this template literal — the pane
|
||||||
|
// has no such symbol in scope. Strip them; panes don't need SIGTERM
|
||||||
|
// checks (parallel.kill handles teardown).
|
||||||
|
return raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── pane lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
// Lazy spawn: VT 1 at boot; VT 2-6 the first time the user requests them.
|
||||||
|
// Re-spawn if the previous pane's thread has died (e.g. user typed `exit`).
|
||||||
|
|
||||||
|
const panes = {} // n -> { runner, thread }
|
||||||
|
|
||||||
|
function isPaneAlive(n) {
|
||||||
|
return panes[n] && parallel.isRunning(panes[n].thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPane(n) {
|
||||||
|
serial.println("[vtmgr] spawning VT " + n)
|
||||||
|
let runner = parallel.spawnNewContext()
|
||||||
|
let thread = parallel.attachProgram("vt" + n, runner, makePaneBootstrap(n))
|
||||||
|
parallel.launch(thread)
|
||||||
|
panes[n] = { runner: runner, thread: thread }
|
||||||
|
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) | (1 << (n - 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePane(n) {
|
||||||
|
if (!isPaneAlive(n)) {
|
||||||
|
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) & ~(1 << (n - 1)))
|
||||||
|
spawnPane(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePane(1)
|
||||||
|
sys.poke(CTRL + CTRL_ACTIVE_VT, 1)
|
||||||
|
// VT 1's TVDOS.SYS eval is slow; give it room before we start compositing.
|
||||||
|
sys.sleep(800)
|
||||||
|
|
||||||
|
// ─── compositor / dispatcher loop ───────────────────────────────────────────
|
||||||
|
// 30 Hz: blit active pane → GPU text area; honour switch_request; detect
|
||||||
|
// Alt-N with debounce; drain typed chars into active pane's queue.
|
||||||
|
|
||||||
|
const gpuBase = graphics.getGpuMemBase()
|
||||||
|
const TEXTAREA_BASE_ABS = gpuBase - GPU_TEXTAREA_OFFSET
|
||||||
|
function blitVt(srcAddr) {
|
||||||
|
sys.memcpy(srcAddr, TEXTAREA_BASE_ABS, TEXT_PLANE_SIZE - 2)
|
||||||
|
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 2), sys.peek(srcAddr + TEXT_PLANE_SIZE - 2))
|
||||||
|
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 1), sys.peek(srcAddr + TEXT_PLANE_SIZE - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU textmode-attribute MMIO byte (offset 6): bit 0 = blinkCursor, bit 1 =
|
||||||
|
// rawMode, bits 4-7 = chrrom. We flip only bit 0 to match the active pane's
|
||||||
|
// cursor visibility. getGpuMemBase() = -1 - 1MB*slot; the peripheral's MMIO
|
||||||
|
// window sits at IOSpace offset 128KB*slot, so MMIO byte k = -1 - (128KB*slot + k).
|
||||||
|
const gpuSlot = (((-gpuBase) - 1) / 1048576) | 0
|
||||||
|
const GPU_MMIO_ATTR = -1 - (131072 * gpuSlot + 6)
|
||||||
|
let lastCursorVis = -1
|
||||||
|
function applyCursorVis(active) {
|
||||||
|
let vis = sys.peek(vtBlockAddr(active) + 2)
|
||||||
|
if (vis === lastCursorVis) return
|
||||||
|
let attr = sys.peek(GPU_MMIO_ATTR)
|
||||||
|
sys.poke(GPU_MMIO_ATTR, vis ? (attr | 1) : (attr & 0xFE))
|
||||||
|
lastCursorVis = vis
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePush(vtN, byte) {
|
||||||
|
let qBase = vtBlockAddr(vtN)
|
||||||
|
let head = sys.peek(qBase + 8)
|
||||||
|
let tail = sys.peek(qBase + 9)
|
||||||
|
let next = (tail + 1) & 0xFF
|
||||||
|
if (next === head) return false
|
||||||
|
sys.poke(qBase + QUEUE_DATA_OFFSET + tail, byte)
|
||||||
|
sys.poke(qBase + 9, next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTo(n) {
|
||||||
|
if (n < 1 || n > MAX_VT) return
|
||||||
|
ensurePane(n)
|
||||||
|
sys.poke(CTRL + CTRL_ACTIVE_VT, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.poke(-39, 1) // enable physical keyboard input collection
|
||||||
|
|
||||||
|
let running = true
|
||||||
|
while (running) {
|
||||||
|
let active = sys.peek(CTRL + CTRL_ACTIVE_VT)
|
||||||
|
if (active < 1 || active > MAX_VT) active = 1
|
||||||
|
blitVt(vtTextPlaneAddr(active))
|
||||||
|
applyCursorVis(active)
|
||||||
|
|
||||||
|
// honour chvt's switch request
|
||||||
|
let req = sys.peek(CTRL + CTRL_SWITCH_REQUEST)
|
||||||
|
if (req >= 1 && req <= MAX_VT) {
|
||||||
|
if (req !== active) {
|
||||||
|
serial.println("[vtmgr] chvt switch -> VT " + req)
|
||||||
|
switchTo(req)
|
||||||
|
}
|
||||||
|
sys.poke(CTRL + CTRL_SWITCH_REQUEST, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt-N (and Alt-0 = exit) detection
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
let keys = [sys.peek(-41), sys.peek(-42), sys.peek(-43), sys.peek(-44),
|
||||||
|
sys.peek(-45), sys.peek(-46), sys.peek(-47), sys.peek(-48)]
|
||||||
|
let altHeld = keys.indexOf(57) >= 0 || keys.indexOf(58) >= 0
|
||||||
|
let digit = -1
|
||||||
|
for (let n = 0; n <= MAX_VT; n++) {
|
||||||
|
if (keys.indexOf(7 + n) >= 0) { digit = n; break }
|
||||||
|
}
|
||||||
|
let debounce = sys.peek(CTRL + CTRL_DEBOUNCE_HELD) !== 0
|
||||||
|
|
||||||
|
if (debounce) {
|
||||||
|
if (!altHeld && digit < 0) sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 0)
|
||||||
|
}
|
||||||
|
else if (altHeld && digit === 0) {
|
||||||
|
serial.println("[vtmgr] Alt-0 -> exit")
|
||||||
|
running = false
|
||||||
|
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||||
|
sys.poke(-39, 1)
|
||||||
|
}
|
||||||
|
else if (altHeld && digit >= 1) {
|
||||||
|
serial.println("[vtmgr] Alt-" + digit + " -> switching to VT " + digit)
|
||||||
|
switchTo(digit)
|
||||||
|
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||||
|
sys.poke(-39, 1) // swallow the digit char so it doesn't leak into the queue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running) break
|
||||||
|
|
||||||
|
// drain typed chars into the active pane's queue
|
||||||
|
while (sys.peek(-50) !== 0) {
|
||||||
|
let k = sys.peek(-38)
|
||||||
|
if (k < 0) k += 256
|
||||||
|
queuePush(active, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.sleep(33)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let n = 1; n <= MAX_VT; n++) if (panes[n]) parallel.kill(panes[n].thread)
|
||||||
|
con.color_pair(254, 255)
|
||||||
|
con.clear()
|
||||||
|
println("vtmgr exited.")
|
||||||
|
return 0
|
||||||
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()
|
const shellID = makeHash()
|
||||||
|
|
||||||
function print_prompt_text() {
|
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 (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)
|
con.color_pair(239,161)
|
||||||
print(" "+CURRENT_DRIVE+":")
|
print(" "+CURRENT_DRIVE+":")
|
||||||
con.color_pair(161,253)
|
con.color_pair(161,253)
|
||||||
@@ -49,9 +60,9 @@ function print_prompt_text() {
|
|||||||
else {
|
else {
|
||||||
// con.color_pair(253,255)
|
// con.color_pair(253,255)
|
||||||
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
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
|
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) {
|
panic: function(args) {
|
||||||
throw Error("Panicking command.js")
|
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
|
// define command aliases here
|
||||||
@@ -636,10 +662,14 @@ shell.coreutils.where = shell.coreutils.which
|
|||||||
Object.freeze(shell.coreutils)
|
Object.freeze(shell.coreutils)
|
||||||
shell.stdio = {
|
shell.stdio = {
|
||||||
out: {
|
out: {
|
||||||
print: function(s) { sys.print(s) },
|
// When running inside a vtmgr virtual console, __VT_OUT routes output
|
||||||
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
|
// to the pane's text-plane buffer instead of the physical GPU (which
|
||||||
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
|
// the compositor would otherwise overwrite). Outside a VT the hook is
|
||||||
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
|
// 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: {
|
pipe: {
|
||||||
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
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)
|
Object.freeze(shell)
|
||||||
_G.shell = 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
|
// ensure USERCONFIGPATH directory exists
|
||||||
@@ -1012,23 +1270,133 @@ if (goInteractive) {
|
|||||||
print_prompt_text()
|
print_prompt_text()
|
||||||
|
|
||||||
var cmdbuf = ""
|
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) {
|
while (true) {
|
||||||
let key = con.getch()
|
let key = con.getch()
|
||||||
|
|
||||||
// printable chars
|
// printable chars
|
||||||
if (key >= 32 && key <= 126) {
|
if (key >= 32 && key <= 126) {
|
||||||
var s = String.fromCharCode(key)
|
let s = String.fromCharCode(key)
|
||||||
cmdbuf += s
|
let atEnd = (caret === cmdbuf.length)
|
||||||
print(s)
|
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
|
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
|
||||||
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
|
else if (key === con.KEY_TAB) {
|
||||||
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
|
tryAutocomplete()
|
||||||
print(String.fromCharCode(key))
|
}
|
||||||
|
// 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
|
// enter
|
||||||
else if (key === 10 || key === con.KEY_RETURN) {
|
else if (key === 10 || key === con.KEY_RETURN) {
|
||||||
|
caret = cmdbuf.length; gotoCaret()
|
||||||
println()
|
println()
|
||||||
|
|
||||||
errorlevel = shell.execute(cmdbuf)
|
errorlevel = shell.execute(cmdbuf)
|
||||||
@@ -1044,32 +1412,17 @@ if (goInteractive) {
|
|||||||
// up arrow
|
// up arrow
|
||||||
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
||||||
cmdHistoryScroll += 1
|
cmdHistoryScroll += 1
|
||||||
|
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||||
// 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)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
// down arrow
|
// down arrow
|
||||||
else if (key === con.KEY_DOWN) {
|
else if (key === con.KEY_DOWN) {
|
||||||
if (cmdHistoryScroll > 0) {
|
if (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)
|
|
||||||
|
|
||||||
cmdHistoryScroll -= 1
|
cmdHistoryScroll -= 1
|
||||||
|
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||||
}
|
}
|
||||||
else {
|
else if (cmdHistoryScroll === 1) {
|
||||||
// back the cursor in order to type new cmd
|
cmdHistoryScroll = 0
|
||||||
var x = 0
|
setBuf("")
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||||
//// default searches from ProperName
|
//// default searches from ProperName
|
||||||
// hopper {install,in} query [-v version]
|
// hopper {install,in} query [-v version]
|
||||||
|
// hopper {upgrade,up} [package...]
|
||||||
|
//// no package names upgrades every user-installed package
|
||||||
// hopper {remove,rm} query
|
// hopper {remove,rm} query
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -372,14 +374,31 @@ function findProviders(idx, name) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
// Sort candidates by preference. Normally installed-first (no churn) then
|
||||||
function sortCandidates(cands) {
|
// 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) => {
|
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)
|
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)
|
// 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
|
// recursive resolve() call over each requirement. Replacing this with
|
||||||
// clause learning / a watched-literals scheme later would be local.
|
// 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 chosen = new Map()
|
||||||
const issues = []
|
const issues = []
|
||||||
|
|
||||||
@@ -407,10 +426,16 @@ function resolveAll(idx, requirements) {
|
|||||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||||
|
|
||||||
function _resolve(reqName, constraint, trail) {
|
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)
|
const existing = chosen.get(reqName)
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
const v = providedVersionOf(existing, reqName)
|
const v = providedVersionOf(existing, reqName)
|
||||||
return satisfies(v, constraint)
|
return (satisfies(v, constraint) && (!pin || satisfies(existing.version, pin)))
|
||||||
? { ok: true }
|
? { ok: true }
|
||||||
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
: { 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
|
// Satisfaction checks the virtual version the candidate exposes
|
||||||
// for `reqName` (HopperProvides), not necessarily the package's
|
// for `reqName` (HopperProvides), not necessarily the package's
|
||||||
// own HopperPackageVersion.
|
// own HopperPackageVersion. A package-version pin is applied on top,
|
||||||
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
// 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) {
|
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(", ")
|
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||||
}
|
}
|
||||||
@@ -728,43 +763,11 @@ function _installOne(action, candidate) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdInstall(args) {
|
// Shared tail for `install` and `upgrade`: turn a resolver result into an
|
||||||
let query = undefined
|
// actual on-disk change. Prints the plan, runs the pre-flight checks
|
||||||
let version = undefined
|
// (system-package and missing-payload blockers, modem availability), asks
|
||||||
for (let i = 0; i < args.length; i++) {
|
// for confirmation, then fetches and writes every changing package.
|
||||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
function commitResolution(idx, chosen, issues, planLabel, confirmMsg) {
|
||||||
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)
|
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
printerrln("Resolution failed:")
|
printerrln("Resolution failed:")
|
||||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||||
@@ -774,7 +777,7 @@ function cmdInstall(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const plan = classifyPlan(idx, chosen)
|
const plan = classifyPlan(idx, chosen)
|
||||||
printPlan(plan, query)
|
printPlan(plan, planLabel)
|
||||||
|
|
||||||
const changing = plan.filter(a => a.action !== "keep")
|
const changing = plan.filter(a => a.action !== "keep")
|
||||||
if (changing.length === 0) return 0
|
if (changing.length === 0) return 0
|
||||||
@@ -804,7 +807,7 @@ function cmdInstall(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println("")
|
println("")
|
||||||
if (!confirm("Proceed with installation?", true)) {
|
if (!confirm(confirmMsg, true)) {
|
||||||
println("Aborted.")
|
println("Aborted.")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -835,6 +838,132 @@ function cmdInstall(args) {
|
|||||||
return 0
|
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
|
// Remove
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -929,6 +1058,7 @@ function printUsage() {
|
|||||||
println("Usage:")
|
println("Usage:")
|
||||||
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||||
println(" hopper {install,in} <package> [-v <version>]")
|
println(" hopper {install,in} <package> [-v <version>]")
|
||||||
|
println(" hopper {upgrade,up} [<package>...]")
|
||||||
println(" hopper {remove,rm} <package>")
|
println(" hopper {remove,rm} <package>")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,6 +1073,9 @@ switch (_hopperCmd) {
|
|||||||
case "install":
|
case "install":
|
||||||
case "in":
|
case "in":
|
||||||
return cmdInstall(_hopperRest)
|
return cmdInstall(_hopperRest)
|
||||||
|
case "upgrade":
|
||||||
|
case "up":
|
||||||
|
return cmdUpgrade(_hopperRest)
|
||||||
case "remove":
|
case "remove":
|
||||||
case "rm":
|
case "rm":
|
||||||
return cmdRemove(_hopperRest)
|
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
|
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setPcmQueueCapacityIndex(0, 2)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
audio.resetParams(PLAYHEAD)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
audio.play(0)
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
audio.mp2Init()
|
audio.mp2Init()
|
||||||
|
|
||||||
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||||
@@ -91,8 +95,8 @@ try {
|
|||||||
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
if (audio.getPosition(PLAYHEAD) >= QUEUE_MAX) {
|
||||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
while (audio.getPosition(PLAYHEAD) >= (QUEUE_MAX >>> 1)) {
|
||||||
if (interactive) gui.audioRender()
|
if (interactive) gui.audioRender()
|
||||||
sys.sleep(bufRealTimeLen)
|
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 framesRead = 0
|
||||||
let audioFired = false
|
let audioFired = false
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
||||||
for (let k = 0; k < length; k+=2) {
|
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
|
// defer audio playback until a first frame is sent
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +267,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
|
|
||||||
// defer audio playback until a first frame is sent
|
// defer audio playback until a first frame is sent
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +330,9 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
// RAW PCM packets (decode on the fly)
|
// RAW PCM packets (decode on the fly)
|
||||||
else if (packetType == 0x1000 || packetType == 0x1001) {
|
else if (packetType == 0x1000 || packetType == 0x1001) {
|
||||||
let frame = seqread.readBytes(readLength)
|
let frame = seqread.readBytes(readLength)
|
||||||
audio.putPcmDataByPtr(0, frame, readLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, frame, readLength, 0)
|
||||||
audio.setSampleUploadLength(0, readLength)
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
sys.free(frame)
|
sys.free(frame)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -382,14 +386,14 @@ finally {
|
|||||||
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
||||||
|
|
||||||
}
|
}
|
||||||
//audio.stop(0)
|
//audio.stop(PLAYHEAD)
|
||||||
|
|
||||||
let timeTook = (endTime - startTime) / 1000000000.0
|
let timeTook = (endTime - startTime) / 1000000000.0
|
||||||
|
|
||||||
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
||||||
|
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
con.clear()
|
con.clear()
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ function bytesToSec(i) { return i / byterate }
|
|||||||
seqread.prepare(filePath)
|
seqread.prepare(filePath)
|
||||||
|
|
||||||
const readPtr = sys.malloc(BLOCK_SIZE)
|
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
gui.audioInit({
|
gui.audioInit({
|
||||||
@@ -42,7 +46,7 @@ try {
|
|||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
const queueSize = audio.getPosition(0)
|
const queueSize = audio.getPosition(PLAYHEAD)
|
||||||
if (queueSize <= 1) {
|
if (queueSize <= 1) {
|
||||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||||
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||||
@@ -54,13 +58,13 @@ try {
|
|||||||
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||||
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, readPtr, readLength, 0)
|
||||||
audio.setSampleUploadLength(0, readLength)
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
if (repeat > 1) sys.sleep(10)
|
if (repeat > 1) sys.sleep(10)
|
||||||
}
|
}
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
|
|||||||
@@ -109,13 +109,17 @@ function bytesToSec(i) {
|
|||||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setPcmQueueCapacityIndex(0, 2)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
audio.resetParams(PLAYHEAD)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
audio.play(0)
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
gui.audioInit({
|
gui.audioInit({
|
||||||
@@ -163,7 +167,7 @@ try {
|
|||||||
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||||
|
|
||||||
audio.tadDecode()
|
audio.tadDecode()
|
||||||
audio.tadUploadDecoded(0, sampleCount)
|
audio.tadUploadDecoded(PLAYHEAD, sampleCount)
|
||||||
// After upload tadDecodedBin still holds the chunk until the next
|
// After upload tadDecodedBin still holds the chunk until the next
|
||||||
// tadDecode call, so it's safe to keep slicing samples out of it
|
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||||
// during the playback wait below.
|
// 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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -87,8 +87,8 @@ const COL_LABEL = 220 // amber panel label
|
|||||||
const COL_DIM = 235 // muted text
|
const COL_DIM = 235 // muted text
|
||||||
const COL_TITLE = 230 // bright white-yellow song title
|
const COL_TITLE = 230 // bright white-yellow song title
|
||||||
const COL_VALUE = 254 // bright white numeric values
|
const COL_VALUE = 254 // bright white numeric values
|
||||||
const COL_TICK_LIVE = 46 // green tick light
|
const COL_TICK_LIVE = 76 // green tick light
|
||||||
const COL_TICK_DEAD = 22 // dim green
|
const COL_TICK_DEAD = 20 // dim green
|
||||||
const COL_ORDER_PAST = 235
|
const COL_ORDER_PAST = 235
|
||||||
const COL_ORDER_CUR = 226 // bright yellow active cue
|
const COL_ORDER_CUR = 226 // bright yellow active cue
|
||||||
const COL_ORDER_FUT = 250
|
const COL_ORDER_FUT = 250
|
||||||
@@ -304,9 +304,13 @@ function parseTaud(path, songIndex) {
|
|||||||
const song = parseTaud(filePath, songArg)
|
const song = parseTaud(filePath, songArg)
|
||||||
|
|
||||||
// ── Hand the file to the audio adapter ─────────────────────────────────────
|
// ── Hand the file to the audio adapter ─────────────────────────────────────
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so launching
|
||||||
audio.purgeQueue(0)
|
// playtaud doesn't cut off music already playing on another playhead. Falls
|
||||||
taud.uploadTaudFile(filePath, songArg, 0)
|
// 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 ─────────────────────────────────────
|
// ── Instrument archetype classification ─────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -565,8 +569,8 @@ function pad(n, w) {
|
|||||||
|
|
||||||
let lastStatus = ''
|
let lastStatus = ''
|
||||||
function drawStatus(curCue) {
|
function drawStatus(curCue) {
|
||||||
const bpm = audio.getBPM(0) || song.bpm
|
const bpm = audio.getBPM(PLAYHEAD) || song.bpm
|
||||||
const tick = audio.getTickRate(0) || song.tickRate
|
const tick = audio.getTickRate(PLAYHEAD) || song.tickRate
|
||||||
const cueStr = pad(curCue, 3) + '/' + pad(song.lastCue, 3)
|
const cueStr = pad(curCue, 3) + '/' + pad(song.lastCue, 3)
|
||||||
const s = 'BPM ' + pad(bpm,3) + ' Tick ' + pad(tick,2) +
|
const s = 'BPM ' + pad(bpm,3) + ' Tick ' + pad(tick,2) +
|
||||||
' Voices ' + pad(song.numVoices,2) + ' Cue ' + cueStr
|
' Voices ' + pad(song.numVoices,2) + ' Cue ' + cueStr
|
||||||
@@ -714,7 +718,7 @@ function spawnEventsForRow(cueIdx, rowIdx) {
|
|||||||
const arch = archByInst[effInst]
|
const arch = archByInst[effInst]
|
||||||
let pan = 128
|
let pan = 128
|
||||||
if (panSel === 0) pan = (panVal / 63 * 255) | 0
|
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
|
if (typeof livePan === 'number' && livePan !== 128) pan = livePan
|
||||||
// Replace whatever was in voice v's slot. peakVol seeds at 0 and is
|
// 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,
|
// 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
|
// Behind the event lanes runs a "terminal matrix" of the raw tracker data,
|
||||||
// five lanes, walk the active events, and draw each into its lane. Painting
|
// re-spelled as pseudo-opcodes and streamed one row's worth at a time in
|
||||||
// is reasonably cheap because (a) the bordered side columns stay untouched,
|
// lock-step with the playhead's row cadence. Each tracker cell on the current
|
||||||
// and (b) blanking inside is two cells per row × ~14 rows = 30 cells.
|
// 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() {
|
// Palette runs dim → bright per the spec; fresh text takes the bright end.
|
||||||
colour(COL_DIM, COL_BG)
|
const BG_PALETTE = [244,243,242,241] // index 0 = freshest .. last = oldest
|
||||||
const blank = ' '.repeat(LANE_W)
|
const BG_LIFE = 32 // rows a cell stays lit before going dark
|
||||||
for (let r = ROW_TONAL_TOP; r <= ROW_TONAL_BOT; r++)
|
|
||||||
mvtext(r, COL_INSIDE_L, blank)
|
const bgChar = new Uint8Array(BG_ROWS * BG_COLS)
|
||||||
for (let r = ROW_DRUMS_TOP; r <= ROW_DRUMS_BOT; r++)
|
const bgLvl = new Int8Array(BG_ROWS * BG_COLS) // 0 = dark, BG_LIFE = freshest
|
||||||
mvtext(r, COL_INSIDE_L, blank)
|
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) {
|
function envColour(arch, volFrac) {
|
||||||
@@ -849,9 +1021,12 @@ function drawEventLead(ev, stage, volFrac, livePan, liveNote) {
|
|||||||
: stage === STAGE_RELEASE ? 0xF9 /*·*/
|
: stage === STAGE_RELEASE ? 0xF9 /*·*/
|
||||||
: 0xC4 /*─*/
|
: 0xC4 /*─*/
|
||||||
for (let i = 1; i <= tailLen; i++) {
|
for (let i = 1; i <= tailLen; i++) {
|
||||||
const x = cx - i
|
const xl = cx - i
|
||||||
if (x >= COL_INSIDE_L && x <= COL_INSIDE_R)
|
if (xl >= COL_INSIDE_L && xl <= COL_INSIDE_R)
|
||||||
mvprn(y, x, trailChar)
|
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 /*■*/
|
const head = stage === STAGE_ATTACK ? 0xFE /*■*/
|
||||||
: stage === STAGE_RELEASE ? 0x09 /*°*/
|
: stage === STAGE_RELEASE ? 0x09 /*°*/
|
||||||
@@ -880,18 +1055,18 @@ function drawEventMetal(ev, stage, volFrac, liveNote) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEvents() {
|
function renderEvents() {
|
||||||
blankLanes()
|
drawBackground()
|
||||||
for (let v = 0; v < song.numVoices; v++) {
|
for (let v = 0; v < song.numVoices; v++) {
|
||||||
const ev = events[v]
|
const ev = events[v]
|
||||||
if (!ev) continue
|
if (!ev) continue
|
||||||
// The engine's `active` flag is the source of truth — set by note-on,
|
// 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.
|
// 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.
|
// 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 liveVol = audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0
|
||||||
const livePan = audio.getVoiceEffectivePan(0, v)
|
const livePan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||||
const liveNote = audio.getVoiceNote(0, v)
|
const liveNote = audio.getVoiceNote(PLAYHEAD, v)
|
||||||
|
|
||||||
if (liveVol > ev.peakVol) ev.peakVol = liveVol
|
if (liveVol > ev.peakVol) ev.peakVol = liveVol
|
||||||
ev.ageFrames++
|
ev.ageFrames++
|
||||||
@@ -923,10 +1098,10 @@ function drawStereo() {
|
|||||||
const W = LANE_W
|
const W = LANE_W
|
||||||
const bins = new Float32Array(W)
|
const bins = new Float32Array(W)
|
||||||
for (let v = 0; v < song.numVoices; v++) {
|
for (let v = 0; v < song.numVoices; v++) {
|
||||||
if (!audio.getVoiceActive(0, v)) continue
|
if (!audio.getVoiceActive(PLAYHEAD, v)) continue
|
||||||
const vol = Math.pow(audio.getVoiceEffectiveVolume(0, v) || 0, 0.125)
|
const vol = Math.pow(audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0, 0.125)
|
||||||
if (vol <= 0) continue
|
if (vol <= 0) continue
|
||||||
const pan = audio.getVoiceEffectivePan(0, v)
|
const pan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||||
let col = Math.round((pan / 255) * (W - 1))
|
let col = Math.round((pan / 255) * (W - 1))
|
||||||
if (col < 0) col = 0
|
if (col < 0) col = 0
|
||||||
if (col >= W) col = W - 1
|
if (col >= W) col = W - 1
|
||||||
@@ -972,7 +1147,7 @@ function drawTickLights(tickInRow, tickRate) {
|
|||||||
// Voice activity counter on the right.
|
// Voice activity counter on the right.
|
||||||
let nActive = 0
|
let nActive = 0
|
||||||
for (let v = 0; v < song.numVoices; v++) {
|
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)
|
colour(COL_DIM, COL_BG)
|
||||||
const s = 'ACTIVE ' + pad(nActive, 2) + '/' + pad(song.numVoices, 2)
|
const s = 'ACTIVE ' + pad(nActive, 2) + '/' + pad(song.numVoices, 2)
|
||||||
@@ -986,10 +1161,10 @@ drawStatus(0)
|
|||||||
drawOrderStrip(0)
|
drawOrderStrip(0)
|
||||||
|
|
||||||
// ── Playback ────────────────────────────────────────────────────────────────
|
// ── Playback ────────────────────────────────────────────────────────────────
|
||||||
audio.setCuePosition(0, 0)
|
audio.setCuePosition(PLAYHEAD, 0)
|
||||||
audio.setTrackerRow(0, 0)
|
audio.setTrackerRow(PLAYHEAD, 0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
|
|
||||||
let stopReq = false
|
let stopReq = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
@@ -1003,22 +1178,25 @@ let errorlevel = 0
|
|||||||
let ticksPerRow = Math.max(1, song.tickRate)
|
let ticksPerRow = Math.max(1, song.tickRate)
|
||||||
let synthTick = 0 // tick within current row, 0..ticksPerRow-1
|
let synthTick = 0 // tick within current row, 0..ticksPerRow-1
|
||||||
try {
|
try {
|
||||||
while (audio.isPlaying(0) && !stopReq) {
|
while (audio.isPlaying(PLAYHEAD) && !stopReq) {
|
||||||
// Backspace polling (mirrors playtad).
|
// Backspace polling (mirrors playtad).
|
||||||
sys.poke(-40, 1)
|
sys.poke(-40, 1)
|
||||||
if (sys.peek(-41) === 67) stopReq = true
|
if (sys.peek(-41) === 67) stopReq = true
|
||||||
|
|
||||||
const curCue = audio.getCuePosition(0)
|
const curCue = audio.getCuePosition(PLAYHEAD)
|
||||||
const curRow = audio.getTrackerRow(0)
|
const curRow = audio.getTrackerRow(PLAYHEAD)
|
||||||
if (curCue !== lastSeenCue || curRow !== lastSeenRow) {
|
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)
|
spawnEventsForRow(curCue, curRow)
|
||||||
|
bgAdvanceRow(curCue, curRow, curCue !== lastSeenCue)
|
||||||
lastSeenCue = curCue
|
lastSeenCue = curCue
|
||||||
lastSeenRow = curRow
|
lastSeenRow = curRow
|
||||||
synthTick = 0
|
synthTick = 0
|
||||||
// Pull a fresh tickRate read here in case a T effect changed it
|
// Pull a fresh tickRate read here in case a T effect changed it
|
||||||
// mid-song.
|
// mid-song.
|
||||||
ticksPerRow = Math.max(1, audio.getTickRate(0) || song.tickRate)
|
ticksPerRow = Math.max(1, audio.getTickRate(PLAYHEAD) || song.tickRate)
|
||||||
} else {
|
} else {
|
||||||
// Same row — advance the synthetic tick counter against wall time.
|
// Same row — advance the synthetic tick counter against wall time.
|
||||||
// Tick period (ms) = (60000 / BPM) / 24 ... but the spec is
|
// Tick period (ms) = (60000 / BPM) / 24 ... but the spec is
|
||||||
@@ -1038,7 +1216,7 @@ try {
|
|||||||
drawStereo()
|
drawStereo()
|
||||||
drawTickLights(synthTick, ticksPerRow)
|
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) {
|
catch (e) {
|
||||||
@@ -1046,7 +1224,7 @@ catch (e) {
|
|||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
con.move(ROW_BOT_BORDER + 1, 1)
|
con.move(ROW_BOT_BORDER + 1, 1)
|
||||||
con.curs_set(1)
|
con.curs_set(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const ADDRESSING_INTERNAL = 0x02
|
|||||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||||
const SND_MEM_ADDR = audio.getMemAddr()
|
const SND_MEM_ADDR = audio.getMemAddr()
|
||||||
const pcm = require("pcm")
|
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 MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||||
const TAV_TEMPORAL_LEVELS = 2
|
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)
|
graphics.clearPixels2(0)
|
||||||
|
|
||||||
// Initialize audio
|
// Initialize audio
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
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
|
// set colour zero as half-opaque black
|
||||||
graphics.setPalette(0, 0, 0, 0, 9)
|
graphics.setPalette(0, 0, 0, 0, 9)
|
||||||
@@ -791,14 +795,14 @@ try {
|
|||||||
if (isInterlaced) {
|
if (isInterlaced) {
|
||||||
// fire audio after frame 1
|
// fire audio after frame 1
|
||||||
if (!audioFired && frameCount > 0) {
|
if (!audioFired && frameCount > 0) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// fire audio after frame 0
|
// fire audio after frame 0
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -900,8 +904,8 @@ finally {
|
|||||||
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
||||||
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
||||||
|
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
//con.clear()
|
//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)
|
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so
|
||||||
audio.purgeQueue(0)
|
// playback doesn't cut off audio already running on another playhead.
|
||||||
audio.setPcmMode(0)
|
// Falls back to #0 when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
let readLength = 1
|
let readLength = 1
|
||||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
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++) {
|
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||||
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||||
@@ -153,13 +157,13 @@ try {
|
|||||||
// before queueing — the buffer is reused next iteration.
|
// before queueing — the buffer is reused next iteration.
|
||||||
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, decodePtr, decodedSampleLength, 0)
|
||||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
audio.setSampleUploadLength(PLAYHEAD, decodedSampleLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
sys.spin()
|
sys.spin()
|
||||||
}
|
}
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive) {
|
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:
|
Tags:
|
||||||
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
<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
|
<c> - centre the line. If the line spans multiple lines, centre each line
|
||||||
<r> - align right
|
<r> - align right
|
||||||
<l> - align left
|
<l> - align left
|
||||||
@@ -145,7 +146,7 @@ Mixer flags define how should the mixer behave.
|
|||||||
|
|
||||||
// assemble help text pieces to complete help message
|
// 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.
|
// 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.
|
// The shared typesetter module owns the palette and the markup expander.
|
||||||
|
|||||||
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`),
|
"adpcm": (f) => _G.shell.execute(`playwav "${f}" -i`),
|
||||||
// "mp3": (f) => _G.shell.execute(`playmp3 "${f}" -i`),
|
// "mp3": (f) => _G.shell.execute(`playmp3 "${f}" -i`),
|
||||||
"mp2": (f) => _G.shell.execute(`playmp2 "${f}" -i`),
|
"mp2": (f) => _G.shell.execute(`playmp2 "${f}" -i`),
|
||||||
"mv1": (f) => _G.shell.execute(`playmv1 "${f}" -i`),
|
"mv1": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"mv2": (f) => _G.shell.execute(`playtev "${f}" -i`),
|
"mv2": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"mv3": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"mv3": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"tav": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"tav": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"im3": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"im3": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"tap": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"tap": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"tad": (f) => _G.shell.execute(`playtad "${f}" -i`),
|
"tad": (f) => _G.shell.execute(`playtad "${f}" -i`),
|
||||||
"pcm": (f) => _G.shell.execute(`playpcm "${f}" -i`),
|
"pcm": (f) => _G.shell.execute(`playpcm "${f}" -i`),
|
||||||
"ipf": (f) => _G.shell.execute(`decodeipf "${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 }
|
||||||
@@ -332,12 +332,6 @@ const AG_BX_TL = 0xC9, AG_BX_TR = 0xBB, AG_BX_BL = 0xC8, AG_BX_BR = 0xBC
|
|||||||
const AG_BX_V = 0xBA, AG_BX_H = 0xCD
|
const AG_BX_V = 0xBA, AG_BX_H = 0xCD
|
||||||
const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6
|
const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6
|
||||||
|
|
||||||
// Half-block glyphs for wavescope
|
|
||||||
const AG_HB_NONE = 0x20 // ' '
|
|
||||||
const AG_HB_TOP = 0xDF // '▀'
|
|
||||||
const AG_HB_BOT = 0xDC // '▄'
|
|
||||||
const AG_HB_BOTH = 0xDB // '█'
|
|
||||||
|
|
||||||
// Density stairs for visualiser + stereo bar
|
// Density stairs for visualiser + stereo bar
|
||||||
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
|
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
|
||||||
|
|
||||||
@@ -596,17 +590,358 @@ function ag_analyseHaar() {
|
|||||||
ag_bassEnergy = bassRms > 1 ? 1 : bassRms
|
ag_bassEnergy = bassRms > 1 ? 1 : bassRms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mini-AAlib (embedded, for the wavescope) ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Stripped port of `disk0/hopper/include/aa.mjs`, sized to one job: convert a
|
||||||
|
// small pixel-space brightness buffer into ASCII glyphs with three monochrome
|
||||||
|
// intensities (DIM / NORMAL / BOLD). No dither. No brightness / contrast /
|
||||||
|
// gamma / inversion. No REVERSE / SPECIAL / BOLDFONT attribute support.
|
||||||
|
// See aa.mjs for the full algorithm, credits (Jan Hubicka & the AA-group,
|
||||||
|
// 1997), and the long-form comments — those are not duplicated here.
|
||||||
|
//
|
||||||
|
// Tables (params + 65536-entry LUT + filltable) are built once on first use
|
||||||
|
// from the TSVM 7×14 font ROM, so the wavescope's glyph-selection matches the
|
||||||
|
// brightness profile of the cells the hardware text mode actually paints.
|
||||||
|
|
||||||
|
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
|
||||||
|
const AA_NORMAL = 0
|
||||||
|
const AA_DIM = 1
|
||||||
|
const AA_BOLD = 2
|
||||||
|
const AA_NATTRS = 3
|
||||||
|
const AA_NCHARS = 256 * AA_NATTRS
|
||||||
|
const AA_DIMMUL = 5.3
|
||||||
|
const AA_BOLDMUL = 2.7
|
||||||
|
const AA_MUL = 8
|
||||||
|
const AA_VAL = 13 // uniform-cell threshold
|
||||||
|
const AA_PRIORITY = [4, 5, 3] // NORMAL, DIM, BOLD (matches aalib)
|
||||||
|
|
||||||
|
let aa_font = null // { width, height, data }
|
||||||
|
let aa_params = null // Uint16Array((NCHARS+1)*5)
|
||||||
|
let aa_table = null // Uint16Array(65536)
|
||||||
|
let aa_filltable = null // Uint16Array(256)
|
||||||
|
|
||||||
|
function aa_loadFont() {
|
||||||
|
if (aa_font) return aa_font
|
||||||
|
const fh = files.open(AA_FONT_PATH)
|
||||||
|
if (!fh.exists) throw Error("playgui: font ROM not found: " + AA_FONT_PATH)
|
||||||
|
const blob = fh.bread()
|
||||||
|
const FW = 7, FH = 14, ROM = 1920
|
||||||
|
if (blob.length !== ROM && blob.length !== ROM * 2) {
|
||||||
|
throw Error("playgui: bad font ROM size " + blob.length)
|
||||||
|
}
|
||||||
|
const data = new Uint8Array(256 * FW * FH)
|
||||||
|
const halves = blob.length / ROM
|
||||||
|
const startHalf = (halves === 2) ? 0 : 1
|
||||||
|
for (let h = 0; h < halves; h++) {
|
||||||
|
const romStart = h * ROM
|
||||||
|
const charBase = (startHalf + h) * 128
|
||||||
|
for (let c = 0; c < 128; c++) {
|
||||||
|
const srcBase = romStart + c * FH
|
||||||
|
const dstBase = (charBase + c) * FW * FH
|
||||||
|
for (let r = 0; r < FH; r++) {
|
||||||
|
const b = blob[srcBase + r] & 0xFF
|
||||||
|
for (let x = 0; x < FW; x++) {
|
||||||
|
data[dstBase + r * FW + x] = ((b >> (6 - x)) & 1) ? 0xFF : 0x00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aa_font = { width: FW, height: FH, data: data }
|
||||||
|
return aa_font
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_alowed(i) {
|
||||||
|
const c = i & 0xff
|
||||||
|
const attr = (i >>> 8)
|
||||||
|
if (attr >= AA_NATTRS) return false
|
||||||
|
// printable ASCII, space, or extended (>160) — keep AA_EIGHT chars so the
|
||||||
|
// glyph palette includes the TSVM ROM's box-drawing / shade / dot range.
|
||||||
|
if (!(c >= 33 && c <= 126) && c !== 0x20 && !(c > 160)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// (NE, NW, SE, SW) brightness for glyph `code` under `attr`. Quadrant labelling
|
||||||
|
// follows aalib's bit-numbering quirk; the LUT lookup later swaps the halves
|
||||||
|
// back to natural orientation. See aa.mjs:_glyphValues for the long-form note.
|
||||||
|
function aa_glyphValues(code, attr, out) {
|
||||||
|
const fd = aa_font.data
|
||||||
|
const fw = aa_font.width
|
||||||
|
const fh = aa_font.height
|
||||||
|
const base = code * fw * fh
|
||||||
|
const halfW = fw >> 1
|
||||||
|
const halfH = fh >> 1
|
||||||
|
const leftW = halfW
|
||||||
|
const topH = halfH
|
||||||
|
let v1 = 0, v2 = 0, v3 = 0, v4 = 0
|
||||||
|
for (let r = 0; r < topH; r++) {
|
||||||
|
const rowBase = base + r * fw
|
||||||
|
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v2++
|
||||||
|
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v1++
|
||||||
|
}
|
||||||
|
for (let r = topH; r < fh; r++) {
|
||||||
|
const rowBase = base + r * fw
|
||||||
|
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v4++
|
||||||
|
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v3++
|
||||||
|
}
|
||||||
|
v1 *= AA_MUL; v2 *= AA_MUL; v3 *= AA_MUL; v4 *= AA_MUL
|
||||||
|
if (attr === AA_DIM) {
|
||||||
|
v1 = (v1 + 1) / AA_DIMMUL
|
||||||
|
v2 = (v2 + 1) / AA_DIMMUL
|
||||||
|
v3 = (v3 + 1) / AA_DIMMUL
|
||||||
|
v4 = (v4 + 1) / AA_DIMMUL
|
||||||
|
} else if (attr === AA_BOLD) {
|
||||||
|
v1 *= AA_BOLDMUL
|
||||||
|
v2 *= AA_BOLDMUL
|
||||||
|
v3 *= AA_BOLDMUL
|
||||||
|
v4 *= AA_BOLDMUL
|
||||||
|
}
|
||||||
|
out[0] = v1; out[1] = v2; out[2] = v3; out[3] = v4
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_calcparams() {
|
||||||
|
aa_loadFont()
|
||||||
|
aa_params = new Uint16Array((AA_NCHARS + 1) * 5)
|
||||||
|
const tmp = new Float64Array(4)
|
||||||
|
let ma1 = 0, ma2 = 0, ma3 = 0, ma4 = 0, msum = 0
|
||||||
|
let mi1 = 50000, mi2 = 50000, mi3 = 50000, mi4 = 50000, misum = 50000
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
if (!aa_alowed(i)) continue
|
||||||
|
aa_glyphValues(i & 0xff, i >>> 8, tmp)
|
||||||
|
const v1 = tmp[0], v2 = tmp[1], v3 = tmp[2], v4 = tmp[3]
|
||||||
|
if (v1 > ma1) ma1 = v1
|
||||||
|
if (v2 > ma2) ma2 = v2
|
||||||
|
if (v3 > ma3) ma3 = v3
|
||||||
|
if (v4 > ma4) ma4 = v4
|
||||||
|
const s = v1 + v2 + v3 + v4
|
||||||
|
if (s > msum) msum = s
|
||||||
|
if (v1 < mi1) mi1 = v1
|
||||||
|
if (v2 < mi2) mi2 = v2
|
||||||
|
if (v3 < mi3) mi3 = v3
|
||||||
|
if (v4 < mi4) mi4 = v4
|
||||||
|
if (s < misum) misum = s
|
||||||
|
}
|
||||||
|
msum -= misum
|
||||||
|
mi1 = misum / 4; mi2 = misum / 4; mi3 = misum / 4; mi4 = misum / 4
|
||||||
|
ma1 = msum / 4; ma2 = msum / 4; ma3 = msum / 4; ma4 = msum / 4
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
aa_glyphValues(i & 0xff, i >>> 8, tmp)
|
||||||
|
const v1r = tmp[0], v2r = tmp[1], v3r = tmp[2], v4r = tmp[3]
|
||||||
|
const sr = v1r + v2r + v3r + v4r
|
||||||
|
let sum = Math.floor((sr - misum) * (1020 / msum) + 0.5)
|
||||||
|
let v1 = Math.floor((v1r - mi1) * (255 / ma1) + 0.5)
|
||||||
|
let v2 = Math.floor((v2r - mi2) * (255 / ma2) + 0.5)
|
||||||
|
let v3 = Math.floor((v3r - mi3) * (255 / ma3) + 0.5)
|
||||||
|
let v4 = Math.floor((v4r - mi4) * (255 / ma4) + 0.5)
|
||||||
|
if (v1 > 255) v1 = 255; else if (v1 < 0) v1 = 0
|
||||||
|
if (v2 > 255) v2 = 255; else if (v2 < 0) v2 = 0
|
||||||
|
if (v3 > 255) v3 = 255; else if (v3 < 0) v3 = 0
|
||||||
|
if (v4 > 255) v4 = 255; else if (v4 < 0) v4 = 0
|
||||||
|
if (sum > 1020) sum = 1020; else if (sum < 0) sum = 0
|
||||||
|
aa_params[i * 5 + 0] = v1
|
||||||
|
aa_params[i * 5 + 1] = v2
|
||||||
|
aa_params[i * 5 + 2] = v3
|
||||||
|
aa_params[i * 5 + 3] = v4
|
||||||
|
aa_params[i * 5 + 4] = sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_pow2(x) { return x * x }
|
||||||
|
function aa_pos(i1, i2, i3, i4) { return (i1 << 12) + (i2 << 8) + (i3 << 4) + i4 }
|
||||||
|
function aa_dist(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
|
||||||
|
return 2 * (aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4))
|
||||||
|
+ aa_pow2(i5 - y5)
|
||||||
|
}
|
||||||
|
function aa_dist1(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
|
||||||
|
return aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4)
|
||||||
|
+ 2 * aa_pow2(i5 - y5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_mktable() {
|
||||||
|
if (!aa_params) aa_calcparams()
|
||||||
|
aa_table = new Uint16Array(65536)
|
||||||
|
aa_filltable = new Uint16Array(256)
|
||||||
|
const next = new Int32Array(65536)
|
||||||
|
for (let i = 0; i < 65536; i++) next[i] = i
|
||||||
|
let first = -1, last = -1
|
||||||
|
function add(i) {
|
||||||
|
if (next[i] === i && last !== i) {
|
||||||
|
if (last !== -1) { next[last] = i; last = i }
|
||||||
|
else { last = first = i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
if (!aa_alowed(i)) continue
|
||||||
|
const i1 = aa_params[i * 5 + 0]
|
||||||
|
const i2 = aa_params[i * 5 + 1]
|
||||||
|
const i3 = aa_params[i * 5 + 2]
|
||||||
|
const i4 = aa_params[i * 5 + 3]
|
||||||
|
const i5 = aa_params[i * 5 + 4]
|
||||||
|
const p1 = i1 >> 4, p2 = i2 >> 4, p3 = i3 >> 4, p4 = i4 >> 4
|
||||||
|
const p = aa_pos(p1, p2, p3, p4)
|
||||||
|
if (aa_table[p]) {
|
||||||
|
const ex = aa_table[p]
|
||||||
|
const ex1 = aa_params[ex * 5 + 0]
|
||||||
|
const ex2 = aa_params[ex * 5 + 1]
|
||||||
|
const ex3 = aa_params[ex * 5 + 2]
|
||||||
|
const ex4 = aa_params[ex * 5 + 3]
|
||||||
|
const ex5 = aa_params[ex * 5 + 4]
|
||||||
|
const pp1 = (p1 << 4) | p1
|
||||||
|
const pp2 = (p2 << 4) | p2
|
||||||
|
const pp3 = (p3 << 4) | p3
|
||||||
|
const pp4 = (p4 << 4) | p4
|
||||||
|
const ppsum = pp1 + pp2 + pp3 + pp4
|
||||||
|
const dNew = aa_dist(i1, i2, i3, i4, i5, pp1, pp2, pp3, pp4, ppsum)
|
||||||
|
const dOld = aa_dist(ex1, ex2, ex3, ex4, ex5, pp1, pp2, pp3, pp4, ppsum)
|
||||||
|
if (dNew > dOld) continue
|
||||||
|
if (dNew === dOld && AA_PRIORITY[(i >>> 8)] <= AA_PRIORITY[(ex >>> 8)]) continue
|
||||||
|
}
|
||||||
|
aa_table[p] = i
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
|
for (let q = 0; q < 256; q++) {
|
||||||
|
let mindist = Infinity
|
||||||
|
let best = 0
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
if (!aa_alowed(i)) continue
|
||||||
|
const d1 = aa_dist1(aa_params[i * 5 + 0], aa_params[i * 5 + 1],
|
||||||
|
aa_params[i * 5 + 2], aa_params[i * 5 + 3],
|
||||||
|
aa_params[i * 5 + 4],
|
||||||
|
q, q, q, q, q * 4)
|
||||||
|
if (d1 < mindist ||
|
||||||
|
(d1 === mindist && AA_PRIORITY[(i >>> 8)] > AA_PRIORITY[(best >>> 8)])) {
|
||||||
|
aa_filltable[q] = i
|
||||||
|
mindist = d1
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BFS propagation: claim neighbour slots that we cover better than whoever
|
||||||
|
// got there first. Lifted verbatim from aamktabl.c via aa.mjs.
|
||||||
|
while (true) {
|
||||||
|
if (last !== -1) next[last] = last
|
||||||
|
else break
|
||||||
|
const blocked = last
|
||||||
|
let i = first
|
||||||
|
if (i === -1) break
|
||||||
|
first = -1; last = -1
|
||||||
|
let prev
|
||||||
|
do {
|
||||||
|
const m0 = (i >> 12) & 15
|
||||||
|
const m1 = (i >> 8) & 15
|
||||||
|
const m2 = (i >> 4) & 15
|
||||||
|
const m3 = i & 15
|
||||||
|
const c = aa_table[i]
|
||||||
|
const cp0 = aa_params[c * 5 + 0]
|
||||||
|
const cp1 = aa_params[c * 5 + 1]
|
||||||
|
const cp2 = aa_params[c * 5 + 2]
|
||||||
|
const cp3 = aa_params[c * 5 + 3]
|
||||||
|
const cp4 = aa_params[c * 5 + 4]
|
||||||
|
for (let dm = 0; dm < 4; dm++) {
|
||||||
|
for (let sgn = -1; sgn <= 1; sgn += 2) {
|
||||||
|
let n0 = m0, n1 = m1, n2 = m2, n3 = m3
|
||||||
|
if (dm === 0) { n0 += sgn; if (n0 < 0 || n0 >= 16) continue }
|
||||||
|
else if (dm === 1) { n1 += sgn; if (n1 < 0 || n1 >= 16) continue }
|
||||||
|
else if (dm === 2) { n2 += sgn; if (n2 < 0 || n2 >= 16) continue }
|
||||||
|
else { n3 += sgn; if (n3 < 0 || n3 >= 16) continue }
|
||||||
|
const index = aa_pos(n0, n1, n2, n3)
|
||||||
|
const ch = aa_table[index]
|
||||||
|
if (ch === c || index === blocked) continue
|
||||||
|
let replace = !ch
|
||||||
|
if (!replace) {
|
||||||
|
const ii1 = (n0 << 4) | n0
|
||||||
|
const ii2 = (n1 << 4) | n1
|
||||||
|
const ii3 = (n2 << 4) | n2
|
||||||
|
const ii4 = (n3 << 4) | n3
|
||||||
|
const iisum = ii1 + ii2 + ii3 + ii4
|
||||||
|
const dNew = aa_dist(ii1, ii2, ii3, ii4, iisum,
|
||||||
|
cp0, cp1, cp2, cp3, cp4)
|
||||||
|
const dOld = aa_dist(ii1, ii2, ii3, ii4, iisum,
|
||||||
|
aa_params[ch * 5 + 0],
|
||||||
|
aa_params[ch * 5 + 1],
|
||||||
|
aa_params[ch * 5 + 2],
|
||||||
|
aa_params[ch * 5 + 3],
|
||||||
|
aa_params[ch * 5 + 4])
|
||||||
|
if (dNew < dOld) replace = true
|
||||||
|
}
|
||||||
|
if (replace) { aa_table[index] = c; add(index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = i
|
||||||
|
i = next[i]
|
||||||
|
next[prev] = prev
|
||||||
|
} while (i !== prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render an imgW × imgH brightness buffer (imgW = scrW*2, imgH = scrH*2) into
|
||||||
|
// per-cell (glyph, attr) outputs. No dither, no params.
|
||||||
|
function aa_render(img, scrW, scrH, tbOut, attrOut) {
|
||||||
|
if (!aa_table) aa_mktable()
|
||||||
|
const tbl = aa_table
|
||||||
|
const fill = aa_filltable
|
||||||
|
const wi = scrW * 2
|
||||||
|
for (let y = 0; y < scrH; y++) {
|
||||||
|
let pos = 2 * y * wi
|
||||||
|
let pos1 = y * scrW
|
||||||
|
for (let x = 0; x < scrW; x++) {
|
||||||
|
const i1 = img[pos + 1] // NE
|
||||||
|
const i2 = img[pos] // NW
|
||||||
|
const i3 = img[pos + wi + 1] // SE
|
||||||
|
const i4 = img[pos + wi] // SW
|
||||||
|
const s = i1 + i2 + i3 + i4
|
||||||
|
const avg = s >> 2
|
||||||
|
let val
|
||||||
|
if (Math.abs(i1 - avg) < AA_VAL &&
|
||||||
|
Math.abs(i2 - avg) < AA_VAL &&
|
||||||
|
Math.abs(i3 - avg) < AA_VAL &&
|
||||||
|
Math.abs(i4 - avg) < AA_VAL) {
|
||||||
|
val = fill[avg]
|
||||||
|
} else {
|
||||||
|
val = tbl[((i2 >> 4) << 12) | ((i1 >> 4) << 8) |
|
||||||
|
((i4 >> 4) << 4) | (i3 >> 4)]
|
||||||
|
}
|
||||||
|
attrOut[pos1] = val >> 8
|
||||||
|
tbOut[pos1] = val & 0xff
|
||||||
|
pos += 2
|
||||||
|
pos1 += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Wavescope (rows 3..5) ──────────────────────────────────────────────────
|
// ── Wavescope (rows 3..5) ──────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Peak-detected envelope: each column shows the range [min, max] of its slice
|
// Peak-detected envelope plotted into a 156×6 pixel buffer (2× cell res),
|
||||||
// of the snapshot using half-block characters for 6 vertical sub-positions.
|
// then converted to ASCII glyphs by the mini-AAlib above. Mid-signal only —
|
||||||
// Mid-signal only — for stereo information you read the bottom bar.
|
// stereo info lives on the bottom bar.
|
||||||
|
//
|
||||||
|
// Three monochrome intensities pick out the wave's body / peaks: DIM cells
|
||||||
|
// are the dim trace, NORMAL cells are the bulk of the waveform, BOLD cells
|
||||||
|
// land on the brightest patches (full-blocked peaks). Amber → white ramp
|
||||||
|
// mimics phosphor bloom.
|
||||||
|
|
||||||
|
const AA_WAVE_W = AG_LANE_W // 78 cells
|
||||||
|
const AA_WAVE_H = AG_ROW_WAVE_BOT - AG_ROW_WAVE_TOP + 1 // 3 cells
|
||||||
|
const AA_WAVE_IW = AA_WAVE_W * 2 // 156 px
|
||||||
|
const AA_WAVE_IH = AA_WAVE_H * 2 // 6 px
|
||||||
|
|
||||||
|
const ag_waveImg = new Uint8Array(AA_WAVE_IW * AA_WAVE_IH)
|
||||||
|
const ag_waveTb = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
|
||||||
|
const ag_waveAttr = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
|
||||||
|
|
||||||
|
// AA_NORMAL=0, AA_DIM=1, AA_BOLD=2 → amber phosphor palette.
|
||||||
|
const AG_WAVE_FG = [166, 130, AG_COL_LABEL]
|
||||||
|
|
||||||
function ag_drawWavescope() {
|
function ag_drawWavescope() {
|
||||||
const N = AG_SNAPSHOT_N
|
const N = AG_SNAPSHOT_N
|
||||||
const samplesPerCol = N / AG_LANE_W
|
const IW = AA_WAVE_IW
|
||||||
// 6 sub-positions: 0..5 from top to bottom.
|
const IH = AA_WAVE_IH
|
||||||
for (let c = 0; c < AG_LANE_W; c++) {
|
const img = ag_waveImg
|
||||||
|
img.fill(0)
|
||||||
|
|
||||||
|
// Per-pixel-column envelope: vertical line from max to min sample value.
|
||||||
|
const samplesPerCol = N / IW
|
||||||
|
const yScale = (IH - 1) * 0.5
|
||||||
|
for (let c = 0; c < IW; c++) {
|
||||||
const s = (c * samplesPerCol) | 0
|
const s = (c * samplesPerCol) | 0
|
||||||
const e = (((c + 1) * samplesPerCol) | 0)
|
const e = (((c + 1) * samplesPerCol) | 0)
|
||||||
let mn = 1.0, mx = -1.0
|
let mn = 1.0, mx = -1.0
|
||||||
@@ -615,26 +950,27 @@ function ag_drawWavescope() {
|
|||||||
if (v < mn) mn = v
|
if (v < mn) mn = v
|
||||||
if (v > mx) mx = v
|
if (v > mx) mx = v
|
||||||
}
|
}
|
||||||
// Map [-1, 1] → [0, 5] (top..bottom). +1 → 0, -1 → 5.
|
// [-1, 1] → [0, IH-1]; +1 sits at the top, -1 at the bottom.
|
||||||
let yMax = ((1 - mx) * 0.5 * 6) | 0
|
let yT = ((1 - mx) * yScale + 0.5) | 0
|
||||||
let yMin = ((1 - mn) * 0.5 * 6) | 0
|
let yB = ((1 - mn) * yScale + 0.5) | 0
|
||||||
if (yMax < 0) yMax = 0; if (yMax > 5) yMax = 5
|
if (yT < 0) yT = 0; else if (yT > IH - 1) yT = IH - 1
|
||||||
if (yMin < 0) yMin = 0; if (yMin > 5) yMin = 5
|
if (yB < 0) yB = 0; else if (yB > IH - 1) yB = IH - 1
|
||||||
// yMax is the top of the bar (smaller y = higher up), yMin is bottom.
|
for (let y = yT; y <= yB; y++) img[y * IW + c] = 0xFF
|
||||||
for (let row = 0; row < 3; row++) {
|
}
|
||||||
const subTop = row * 2
|
|
||||||
const subBot = row * 2 + 1
|
aa_render(img, AA_WAVE_W, AA_WAVE_H, ag_waveTb, ag_waveAttr)
|
||||||
const hitTop = (yMax <= subTop) && (yMin >= subTop)
|
|
||||||
const hitBot = (yMax <= subBot) && (yMin >= subBot)
|
// Blit, skipping cells whose packed (attr<<8 | glyph) key is unchanged.
|
||||||
let g = AG_HB_NONE
|
for (let r = 0; r < AA_WAVE_H; r++) {
|
||||||
if (hitTop && hitBot) g = AG_HB_BOTH
|
for (let c = 0; c < AA_WAVE_W; c++) {
|
||||||
else if (hitTop) g = AG_HB_TOP
|
const idx = r * AA_WAVE_W + c
|
||||||
else if (hitBot) g = AG_HB_BOT
|
const att = ag_waveAttr[idx]
|
||||||
const idx = row * AG_LANE_W + c
|
const ch = ag_waveTb[idx]
|
||||||
if (ag_waveGlyph[idx] === g) continue
|
const key = (att << 8) | ch
|
||||||
ag_waveGlyph[idx] = g
|
if (ag_waveGlyph[idx] === key) continue
|
||||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
ag_waveGlyph[idx] = key
|
||||||
ag_mvprn(AG_ROW_WAVE_TOP + row, AG_COL_INSIDE_L + c, g)
|
ag_color(AG_WAVE_FG[att] || AG_COL_LABEL, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_ROW_WAVE_TOP + r, AG_COL_INSIDE_L + c, ch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
@@ -83,11 +83,13 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
pos = 8
|
pos = 8
|
||||||
|
|
||||||
// -- 3. Parse header ------------------------------------------------------
|
// -- 3. Parse header ------------------------------------------------------
|
||||||
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes
|
// magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + signature(14)
|
||||||
|
// = 32 bytes (terranmon.txt §Header).
|
||||||
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||||
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||||
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
||||||
pos += 18 // skip reserved(2) + signature(16)
|
let projOff = _peekU32LE(filePtr, pos); pos += 4
|
||||||
|
pos += 14 // signature
|
||||||
// pos == 32 == TAUD_HEADER_SIZE
|
// pos == 32 == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
if (songIndex < 0 || songIndex >= numSongs) {
|
if (songIndex < 0 || songIndex >= numSongs) {
|
||||||
@@ -155,6 +157,50 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||||
audio.setSongMixingVolume(playhead, songMixingVolume)
|
audio.setSongMixingVolume(playhead, songMixingVolume)
|
||||||
|
|
||||||
|
// -- 9. Project Data — walk Ixmp blocks for multi-sample instruments -----
|
||||||
|
// Terranmon spec: Project Data starts at `projOff` (zero = absent), magic is
|
||||||
|
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
|
||||||
|
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,
|
||||||
|
// etc.) are skipped so the player apps remain free to parse them.
|
||||||
|
if (projOff !== 0 && projOff + 16 <= fileSize) {
|
||||||
|
const projMagic = [0x1E,0x54,0x61,0x75,0x64,0x50,0x72,0x4A] // \x1ETaudPrJ
|
||||||
|
let prjOk = true
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if ((sys.peek(filePtr + projOff + i) & 0xFF) !== projMagic[i]) { prjOk = false; break }
|
||||||
|
}
|
||||||
|
if (prjOk) {
|
||||||
|
const PATCH_SIZE = 31
|
||||||
|
let p = projOff + 16 // skip magic(8) + reserved(8)
|
||||||
|
while (p + 8 <= fileSize) {
|
||||||
|
const fc = String.fromCharCode(
|
||||||
|
sys.peek(filePtr + p) & 0xFF, sys.peek(filePtr + p + 1) & 0xFF,
|
||||||
|
sys.peek(filePtr + p + 2) & 0xFF, sys.peek(filePtr + p + 3) & 0xFF)
|
||||||
|
const secLen = _peekU32LE(filePtr, p + 4)
|
||||||
|
const payload = p + 8
|
||||||
|
if (payload + secLen > fileSize) break
|
||||||
|
if (fc === 'Ixmp') {
|
||||||
|
// Each entry: Uint8 instId + Uint24 patchCount + (patchCount × PATCH_SIZE) bytes.
|
||||||
|
let q = payload
|
||||||
|
const qEnd = payload + secLen
|
||||||
|
while (q + 4 <= qEnd) {
|
||||||
|
const instId = sys.peek(filePtr + q) & 0xFF; q++
|
||||||
|
const cntLo = sys.peek(filePtr + q) & 0xFF; q++
|
||||||
|
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
|
||||||
|
let buf = new Array(blobLen)
|
||||||
|
for (let k = 0; k < blobLen; k++) buf[k] = sys.peek(filePtr + q + k) & 0xFF
|
||||||
|
audio.uploadInstrumentPatches(instId, buf)
|
||||||
|
q += blobLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = payload + secLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fileHandle.close()
|
fileHandle.close()
|
||||||
sys.free(filePtr)
|
sys.free(filePtr)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* Markup
|
* Markup
|
||||||
* ------
|
* ------
|
||||||
* <b>...</b> emphasised foreground colour
|
* <b>...</b> emphasised foreground colour
|
||||||
|
* <b>...</b> de-emphasised foreground colour
|
||||||
* <c>...</c> centre-align this source line
|
* <c>...</c> centre-align this source line
|
||||||
* <r>...</r> right-align this source line
|
* <r>...</r> right-align this source line
|
||||||
* <l>...</l> left-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_TEXT = 239 // popup body default (== colWHITE)
|
||||||
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
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 = 211 // first half of "Microtone"
|
||||||
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||||
|
|
||||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||||
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||||
const ESC_EMPH = fgEsc(COL_EMPH)
|
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}`
|
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +126,8 @@ function tokenise(line) {
|
|||||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
// 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 + 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 + 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 head3 = line.slice(i, i + 3).toLowerCase()
|
||||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||||
@@ -327,5 +332,6 @@ exports = {
|
|||||||
COL_BRAND_DIM,
|
COL_BRAND_DIM,
|
||||||
ESC_DEFAULT,
|
ESC_DEFAULT,
|
||||||
ESC_EMPH,
|
ESC_EMPH,
|
||||||
|
ESC_DEEMPH,
|
||||||
MICROTONE,
|
MICROTONE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,28 +49,22 @@ class WindowObject {
|
|||||||
let tt = ''+this.title
|
let tt = ''+this.title
|
||||||
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
||||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`)
|
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;${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.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
}
|
}
|
||||||
if (this.titleLeft !== undefined) {
|
if (this.titleLeft !== undefined) {
|
||||||
let tt = ''+this.titleLeft
|
let tt = ''+this.titleLeft
|
||||||
con.move(this.y, this.x)
|
con.move(this.y, this.x + 1)
|
||||||
print(`\x84${charset[0]}u`)
|
|
||||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
||||||
print(`\x1B[38;5;${colourText}m`);print(tt)
|
print(`\x1B[38;5;${colourText}m`);print(tt)
|
||||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
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) {
|
if (this.titleRight !== undefined) {
|
||||||
let tt = ''+this.titleRight
|
let tt = ''+this.titleRight
|
||||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2)
|
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 1)
|
||||||
print(`\x84${charset[4]}u`)
|
|
||||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -217,6 +211,14 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
|||||||
// action string to close the dialog,
|
// action string to close the dialog,
|
||||||
// or null to stay open.
|
// or null to stay open.
|
||||||
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
// 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).
|
// bg: number?, -- list background colour (default 242).
|
||||||
// },
|
// },
|
||||||
//
|
//
|
||||||
@@ -287,6 +289,8 @@ function showDialog(opts) {
|
|||||||
: Array.isArray(message) ? message
|
: Array.isArray(message) ? message
|
||||||
: ('' + message).split('\n')
|
: ('' + message).split('\n')
|
||||||
|
|
||||||
|
const list = opts.list || null
|
||||||
|
const drawWell = list?.drawWell ?? true
|
||||||
const c = opts.colours || {}
|
const c = opts.colours || {}
|
||||||
const fg = (c.fg != null) ? c.fg : 254
|
const fg = (c.fg != null) ? c.fg : 254
|
||||||
const bg = (c.bg != null) ? c.bg : 244
|
const bg = (c.bg != null) ? c.bg : 244
|
||||||
@@ -294,17 +298,22 @@ function showDialog(opts) {
|
|||||||
const dimFg = (c.dimFg != null) ? c.dimFg : 249
|
const dimFg = (c.dimFg != null) ? c.dimFg : 249
|
||||||
const hlFg = (c.hlFg != null) ? c.hlFg : 240
|
const hlFg = (c.hlFg != null) ? c.hlFg : 240
|
||||||
const focusBg = (c.focusBg != null) ? c.focusBg : 253
|
const focusBg = (c.focusBg != null) ? c.focusBg : 253
|
||||||
const listBg = (c.listBg != null) ? c.listBg : 243
|
const listBg = (c.listBg != null) ? c.listBg : (drawWell) ? 243 : bg
|
||||||
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
||||||
|
|
||||||
// List state
|
// List state
|
||||||
const list = opts.list || null
|
|
||||||
const listItems = list ? (list.items || []) : []
|
const listItems = list ? (list.items || []) : []
|
||||||
const listSelectable = list && list.selectable ? list.selectable : (() => true)
|
const listSelectable = list && list.selectable ? list.selectable : (() => true)
|
||||||
const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0
|
const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0
|
||||||
const hasList = !!list
|
const hasList = !!list
|
||||||
const listOnActivate = list ? list.onActivate : null
|
const listOnActivate = list ? list.onActivate : null
|
||||||
const listBgColour = (list && list.bg != null) ? list.bg : listBg
|
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) {
|
function firstSelectable(from, dir) {
|
||||||
if (!hasList || listItems.length === 0) return -1
|
if (!hasList || listItems.length === 0) return -1
|
||||||
let i = from
|
let i = from
|
||||||
@@ -474,17 +483,21 @@ function showDialog(opts) {
|
|||||||
const sbar = listScrollbarNeeded()
|
const sbar = listScrollbarNeeded()
|
||||||
|
|
||||||
// Top border (drawField style)
|
// Top border (drawField style)
|
||||||
con.color_pair(listBgColour, bg)
|
if (drawWell) {
|
||||||
con.move(lbRow, lbCol)
|
con.color_pair(listBgColour, bg)
|
||||||
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
|
con.move(lbRow, lbCol)
|
||||||
|
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
|
||||||
|
}
|
||||||
|
|
||||||
// Side borders + rows
|
// Side borders + rows
|
||||||
for (let r = 0; r < listHeight; r++) {
|
for (let r = 0; r < listHeight; r++) {
|
||||||
con.color_pair(listBgColour, bg)
|
if (drawWell) {
|
||||||
con.move(lbRow + 1 + r, lbCol)
|
con.color_pair(listBgColour, bg)
|
||||||
print('\u00AB')
|
con.move(lbRow + 1 + r, lbCol)
|
||||||
con.move(lbRow + 1 + r, lbCol + lw - 1)
|
print('\u00AB')
|
||||||
print('\u00AA')
|
con.move(lbRow + 1 + r, lbCol + lw - 1)
|
||||||
|
print('\u00AA')
|
||||||
|
}
|
||||||
|
|
||||||
const idx = listScroll + r
|
const idx = listScroll + r
|
||||||
con.move(lbRow + 1 + r, lbCol + 1)
|
con.move(lbRow + 1 + r, lbCol + 1)
|
||||||
@@ -525,16 +538,20 @@ function showDialog(opts) {
|
|||||||
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
||||||
const maxScroll = Math.max(1, listItems.length - listHeight)
|
const maxScroll = Math.max(1, listItems.length - listHeight)
|
||||||
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
||||||
let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB
|
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
|
||||||
con.addch(r === indPos ? (trough + 3) : trough)
|
// 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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom border
|
// Bottom border
|
||||||
con.color_pair(listBgColour, bg)
|
if (drawWell) {
|
||||||
con.move(lbRow + 1 + listHeight, lbCol)
|
con.color_pair(listBgColour, bg)
|
||||||
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
|
con.move(lbRow + 1 + listHeight, lbCol)
|
||||||
con.color_pair(fg, bg)
|
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawButton(i, regions) {
|
function drawButton(i, regions) {
|
||||||
|
|||||||
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}
|
\begin{itemlist}
|
||||||
\item Song, Minjae. 2021. ``Terran BASIC Reference Manual for Language Version 1.2, Third Edition''.
|
\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. ``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)}.
|
\item Wikipedia. ``Pipeline (software).'' Updated 2022-07-17 06:21. \url{https://en.wikipedia.org/wiki/Pipeline_(software)}.
|
||||||
\end{itemlist}
|
\end{itemlist}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
\section{Specs}
|
\section{Specs}
|
||||||
|
|
||||||
\begin{outline}
|
\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 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 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 Built-in mouse input support
|
||||||
\1 4 serial ports to connect disk drives, modems and other computers
|
\1 4 serial ports to connect disk drives, modems and other computers
|
||||||
\end{outline}
|
\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}
|
\section{Javascript Extensions}
|
||||||
|
|
||||||
@@ -36,7 +36,29 @@ Your Javascript program is stored into the Program Memory, and since its capacit
|
|||||||
|
|
||||||
\chapter{Libraries}
|
\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}
|
\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\_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\_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{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{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.}
|
\1\formalsynopsis{reset\_graphics}{}{Resets foreground and background colour to defaults and makes the cursor visible if it was hidden.}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
@@ -146,7 +170,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\formalsynopsis{poke}{address: Int, value: Int}{Puts a value into the memory of the specified address.}
|
\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{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{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{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}}
|
\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{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{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{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{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}] $$}
|
\1\formalsynopsis{getMallocStatus}{}[IntArray(2)]{Returns the \code{malloc} status in following order:$$ [\mathrm{Malloc\ unit\ size,\ allocated\ block\ counts}] $$}
|
||||||
\end{outline}
|
\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}
|
\chapter{Serial Communication}
|
||||||
|
|
||||||
Some peripherals such as disk drives are connected through the ``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}
|
\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}
|
\subsection{Keycodes}
|
||||||
|
|
||||||
@@ -451,7 +512,14 @@ F12 & 142 \\
|
|||||||
|
|
||||||
\section{Mouse}
|
\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}
|
\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(0,12.5) node[anchor=north east] {Start};
|
||||||
\draw(6,12.5) node[anchor=north west] {End};
|
\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,7.5) node[anchor=mid] {\memlabel{MMIO Area}};
|
||||||
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
||||||
\draw(3,5.5) node[anchor=mid] {\memlabel{Peripheral Memory \#2}};
|
\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) \\
|
-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) \\
|
-49 & RO & System Flags A (\code{0b r000 000t}, where r: RESET button held, t: STOP button held) \\
|
||||||
-50..-52 & RO & Unused System Flags \\
|
-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) \\
|
-69 & WO & Counter Latch (\code{0b01}--Uptime, \code{0b10}--RTC) \\
|
||||||
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
||||||
-81..-88 & RO & RTC 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}
|
\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.
|
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}
|
\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}
|
\subsection{Colour Palette}
|
||||||
\label{colourpalette}
|
\label{colourpalette}
|
||||||
@@ -940,14 +1043,55 @@ TODO
|
|||||||
|
|
||||||
\section{The Graphics Library}
|
\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}
|
\namespaceis{Graphics}{graphics}
|
||||||
|
|
||||||
|
Drawing:
|
||||||
\begin{outline}
|
\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}
|
\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}
|
\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} \\
|
-1310209..-1310720 & RW & Palettes in This Pattern: {\ttfamily 0b RRRR GGGG; 0b BBBB AAAA} \\
|
||||||
-1310721..-1561600 & RW & Second Framebuffer \\
|
-1310721..-1561600 & RW & Second Framebuffer \\
|
||||||
\end{tabulary}
|
\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{center}
|
||||||
\begin{tabulary}{\textwidth}{ll}
|
\begin{tabulary}{\textwidth}{ll}
|
||||||
Zeroth Edition (for version 1.0): & \thepublishingdate
|
\theedition\ (for version \tsvmver): & \thepublishingdate
|
||||||
\end{tabulary}
|
\end{tabulary}
|
||||||
\end{center}
|
\end{center}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
\newcommand{\codeline}[1]{%
|
\newcommand{\codeline}[1]{%
|
||||||
\colorbox{lgrey}{%
|
\colorbox{lgrey}{%
|
||||||
\begin{tabular*}{\textwidth}{l}%
|
\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*}%
|
\end{tabular*}%
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
\newcommand{\thismachine}{TSVM}
|
\newcommand{\thismachine}{TSVM}
|
||||||
\newcommand{\thedos}{TVDOS}
|
\newcommand{\thedos}{TVDOS}
|
||||||
\newcommand{\tsvmver}{1.2}
|
\newcommand{\tsvmver}{1.2}
|
||||||
\newcommand{\theedition}{Zeroth Edition}
|
\newcommand{\theedition}{First Edition}
|
||||||
\newcommand{\thepublishingdate}{0000-00-00}
|
\newcommand{\thepublishingdate}{2026-06-06}
|
||||||
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
||||||
|
|
||||||
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
||||||
@@ -247,10 +247,10 @@
|
|||||||
|
|
||||||
% \input{changesmeta}
|
% \input{changesmeta}
|
||||||
|
|
||||||
\part{The Virtual Machine}
|
\part{\thismachine: The Virtual Machine}
|
||||||
\input{implementation}
|
\input{implementation}
|
||||||
|
|
||||||
\part{The DOS}
|
\part{\thedos: The Disk Operating System}
|
||||||
\input{tvdos}
|
\input{tvdos}
|
||||||
|
|
||||||
\part*{Bibliography}
|
\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}
|
\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}
|
\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}
|
\section{The Bootloader}
|
||||||
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
||||||
|
|
||||||
\section{TVDOS.SYS}
|
\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:
|
Boot Procedure:
|
||||||
|
|
||||||
@@ -26,21 +26,58 @@ Boot Procedure:
|
|||||||
\item initialise DOS variables
|
\item initialise DOS variables
|
||||||
\item install filesystem drivers
|
\item install filesystem drivers
|
||||||
\item install input device drivers
|
\item install input device drivers
|
||||||
\item install GL using the external file
|
\item install \code{GL} using the external file
|
||||||
\item execute \code{AUTOEXEC.BAT}
|
\item run \code{\rs{}commandrc} to set up the environment
|
||||||
\begin{enumerate}
|
\item hand the screen to the virtual-console manager, \code{\rs{}tvdos\rs{}VTMGR.SYS}
|
||||||
\item execute \code{command.js} with proper arguments
|
\item when the manager exits, run \code{\rs{}AUTOEXEC.BAT} as a bare fallback shell
|
||||||
\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}
|
|
||||||
\end{enumerate}
|
\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}
|
\chapter{Coreutils}
|
||||||
@@ -50,6 +87,7 @@ Variables can be set or changed using \textbf{SET} commands.
|
|||||||
\begin{outline}
|
\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{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{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{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{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}
|
\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{exit}{Exits the current command processor.}
|
||||||
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
||||||
\1\dossynopsis{mv}[from to]{Moves or renames the file. Aliase: move}
|
\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{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{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{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}
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\chapter{Built-in Apps}
|
\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}
|
\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{command}{The default text-based \thedos\ shell. Call with \code{command -fancy} for a more \ae sthetically pleasing look.}
|
||||||
\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{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC ROM is present, nothing is done.}
|
||||||
\1\dossynopsis{color}{Changes the background and the foreground of the active session.}
|
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, invokes the BASIC interpreter stored on the disk.}
|
||||||
\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.}
|
|
||||||
\end{outline}
|
\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}
|
\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.
|
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}
|
\section{Invoking Coreutils on the user Apps}
|
||||||
|
|
||||||
DOS coreutils and some of the internal functions can be used on Javascript program.
|
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.
|
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}
|
\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.
|
\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}
|
\2\argsynopsis{sread}{returns an empty string}
|
||||||
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
||||||
\2\argsynopsis{pread}{returns the specified number of zeros}
|
\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}
|
\1\inlinesynopsis{CON}{manipulates the screen text buffer, disregarding the colours}
|
||||||
\2\argsynopsis{pread}{reads the texts as bytes.}
|
\2\argsynopsis{pread}{reads the texts as bytes.}
|
||||||
\2\argsynopsis{bread}{reads the texts as bytes.}
|
\2\argsynopsis{bread}{reads the texts as bytes.}
|
||||||
@@ -256,24 +658,34 @@ Functions:
|
|||||||
|
|
||||||
\subsection{Input Events}
|
\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}
|
\begin{outline}
|
||||||
\1event name --- one of following: \textbf{key\_down}, \textbf{mouse\_down}, \textbf{mouse\_move}
|
|
||||||
\1arguments for \textbf{key\_down}:
|
\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{2}}{Repeat count of the key event}
|
||||||
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the pressed keys}
|
\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{1}}{X-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{2}}{Y-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}:
|
\1arguments for \textbf{mouse\_move}:
|
||||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{2}}{Y-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{3}}{Currently-held button mask (non-zero while dragging)}
|
||||||
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame (previous V-blank of the screen)}
|
\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{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}
|
\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:
|
External libraries can be stored in following locations:
|
||||||
|
|
||||||
\begin{enumerate}
|
\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 a path relative to the user program
|
||||||
\item an absolute path that can be anywhere
|
\item an absolute path that can be anywhere
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -336,7 +749,7 @@ External libraries can be stored in following locations:
|
|||||||
and can be loaded by:
|
and can be loaded by:
|
||||||
|
|
||||||
\begin{enumerate}
|
\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(./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
|
\item \code{let name = require(A:/path/to/library.mjs)} // full path WITH the .mjs extension
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -357,3 +770,240 @@ const BAR = 127
|
|||||||
// following line exports the function and the variable
|
// following line exports the function and the variable
|
||||||
exports = { foo, BAR }
|
exports = { foo, BAR }
|
||||||
\end{lstlisting}
|
\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}
|
||||||
|
|||||||
234
it2taud.py
234
it2taud.py
@@ -57,6 +57,7 @@ from taud_common import (
|
|||||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
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_INST_LEN, cue_instruction_len,
|
||||||
build_project_data, detect_subsongs,
|
build_project_data, detect_subsongs,
|
||||||
|
IXMP_PAN_NO_OVERRIDE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -435,7 +436,10 @@ class ITInstrument:
|
|||||||
'vol_env_loop', 'pan_env_loop', 'pf_env_loop',
|
'vol_env_loop', 'pan_env_loop', 'pf_env_loop',
|
||||||
'vol_env_sus', 'pan_env_sus', 'pf_env_sus',
|
'vol_env_sus', 'pan_env_sus', 'pf_env_sus',
|
||||||
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
|
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
|
||||||
'dct', 'dca')
|
'dct', 'dca', 'keyboard')
|
||||||
|
# keyboard: list[int], 120 entries — keyboard[it_note] = sample_1based (0 = none).
|
||||||
|
# Carried verbatim from the IT file so the Ixmp emitter can build patches that
|
||||||
|
# cover non-canonical-sample note ranges. terranmon.txt "Ixmp" + Schism iti.c:80.
|
||||||
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
|
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
|
||||||
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
|
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
|
||||||
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
|
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
|
||||||
@@ -478,6 +482,7 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
|
|||||||
kb_note = data[ptr + 0x44 + n*2]
|
kb_note = data[ptr + 0x44 + n*2]
|
||||||
kb_smp = data[ptr + 0x44 + n*2 + 1]
|
kb_smp = data[ptr + 0x44 + n*2 + 1]
|
||||||
keyboard.append(kb_smp) # 0 = no sample
|
keyboard.append(kb_smp) # 0 = no sample
|
||||||
|
inst.keyboard = keyboard
|
||||||
|
|
||||||
# Pick C-5 (note 60) sample; fall back to most-frequent non-zero
|
# Pick C-5 (note 60) sample; fall back to most-frequent non-zero
|
||||||
c5_smp = keyboard[60] if 60 < len(keyboard) else 0
|
c5_smp = keyboard[60] if 60 < len(keyboard) else 0
|
||||||
@@ -1119,6 +1124,133 @@ def _remap_bc_effects(chunks: list, chunk_map: list,
|
|||||||
f"subsong boundary; clamped to cue {default_target}")
|
f"subsong boundary; clamped to cue {default_target}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ixmp patch builder (multi-sample IT instruments) ─────────────────────────
|
||||||
|
|
||||||
|
def _it_note_to_taud(note: int, clamp_low: bool = False, clamp_high: bool = False) -> int:
|
||||||
|
"""IT note (0..119, C-5 = 60) → Taud 4096-TET noteVal anchored at TAUD_C4.
|
||||||
|
`clamp_low`/`clamp_high` expand the bottom/top of the keyboard to cover the
|
||||||
|
full Taud playable range, so patches at the keyboard's edges don't leave
|
||||||
|
notes outside the trigger rectangle unmatched."""
|
||||||
|
if clamp_low: return 0x0000
|
||||||
|
if clamp_high: return 0xFFFF
|
||||||
|
val = round(TAUD_C4 + (note - 60) * 4096 / 12)
|
||||||
|
return max(0x0020, min(0xFFFF, val))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_it_ixmp_patches(inst, samples, extras_offsets) -> list:
|
||||||
|
"""For one IT instrument, return a list of Ixmp patch dicts covering every
|
||||||
|
keyboard cell that maps to a NON-canonical sample. The canonical sample is
|
||||||
|
served by the base instrument record so no patch is emitted for it (the
|
||||||
|
engine falls through to the base inst when no patch matches).
|
||||||
|
|
||||||
|
Note ranges are contiguous runs of keyboard cells that point at the same
|
||||||
|
sample. Per the Ixmp spec each (pitch_start..pitch_end, volume_start..end)
|
||||||
|
rectangle MUST NOT overlap any other patch on the same instrument; this is
|
||||||
|
guaranteed here because the keyboard mapping itself is a partition."""
|
||||||
|
canonical = inst.canonical_sample
|
||||||
|
kbd = getattr(inst, 'keyboard', None)
|
||||||
|
if not kbd:
|
||||||
|
return []
|
||||||
|
# Distinct non-canonical samples referenced.
|
||||||
|
distinct = []
|
||||||
|
seen = set()
|
||||||
|
for kb_smp in kbd:
|
||||||
|
if kb_smp == 0 or kb_smp == canonical:
|
||||||
|
continue
|
||||||
|
if kb_smp not in seen and 1 <= kb_smp <= len(samples) and samples[kb_smp - 1] is not None:
|
||||||
|
seen.add(kb_smp); distinct.append(kb_smp)
|
||||||
|
if not distinct:
|
||||||
|
return []
|
||||||
|
|
||||||
|
patches = []
|
||||||
|
for smp_1based in distinct:
|
||||||
|
si = smp_1based - 1
|
||||||
|
s = samples[si]
|
||||||
|
if not s.sample_data:
|
||||||
|
continue
|
||||||
|
sample_ptr = extras_offsets.get(('it_smp', si))
|
||||||
|
if sample_ptr is None:
|
||||||
|
continue # not in the pool — bin overflow or corrupt source
|
||||||
|
|
||||||
|
# Per-sample loop / sustain encoding (mirrors build_sample_inst_bin_it).
|
||||||
|
if s.flags & IT_SMP_SUS_LOOP:
|
||||||
|
ls = min(s.sus_beg, 65535); le = min(s.sus_end, 65535)
|
||||||
|
sustain_bit = 0x4
|
||||||
|
pingpong = bool(s.flags & IT_SMP_PINGPONG_SUS)
|
||||||
|
has_loop = True
|
||||||
|
elif s.has_loop:
|
||||||
|
ls = min(s.loop_beg, 65535); le = min(s.loop_end, 65535)
|
||||||
|
sustain_bit = 0x0
|
||||||
|
pingpong = bool(s.flags & IT_SMP_PINGPONG)
|
||||||
|
has_loop = True
|
||||||
|
else:
|
||||||
|
ls = 0; le = 0
|
||||||
|
sustain_bit = 0x0
|
||||||
|
pingpong = False
|
||||||
|
has_loop = False
|
||||||
|
loop_mode = (2 if (has_loop and pingpong) else (1 if has_loop else 0)) | sustain_bit
|
||||||
|
|
||||||
|
# Per-sample default volume / pan / auto-vibrato — mirrors the
|
||||||
|
# use_instruments inst-record path so behaviour is identical when the
|
||||||
|
# patch sample matches what the base instrument would have stored.
|
||||||
|
smp_vol = min(getattr(s, 'vol', 64), 64)
|
||||||
|
dnv = min(255, round(smp_vol * 255 / 64))
|
||||||
|
smp_dfp = getattr(s, 'dfp', 0)
|
||||||
|
default_pan = (min(255, max(0, round((smp_dfp & 0x7F) * 255 / 64)))
|
||||||
|
if (smp_dfp & 0x80) else IXMP_PAN_NO_OVERRIDE)
|
||||||
|
vib_speed_taud = min(255, round(getattr(s, 'av_speed', 0) * 255 / 64))
|
||||||
|
vib_depth_taud = min(255, round(getattr(s, 'av_depth', 0) * 255 / 64))
|
||||||
|
vib_rate_taud = getattr(s, 'av_sweep', 0) & 0xFF
|
||||||
|
vib_wave_taud = getattr(s, 'av_wave', 0) & 0x07
|
||||||
|
|
||||||
|
# Find contiguous IT-note ranges where the keyboard points at this sample.
|
||||||
|
run_start = None
|
||||||
|
for n in range(120):
|
||||||
|
if kbd[n] == smp_1based:
|
||||||
|
if run_start is None:
|
||||||
|
run_start = n
|
||||||
|
else:
|
||||||
|
if run_start is not None:
|
||||||
|
_emit_patch(patches, run_start, n - 1, sample_ptr, s,
|
||||||
|
ls, le, loop_mode, default_pan, dnv,
|
||||||
|
vib_speed_taud, vib_depth_taud, vib_rate_taud, vib_wave_taud)
|
||||||
|
run_start = None
|
||||||
|
if run_start is not None:
|
||||||
|
_emit_patch(patches, run_start, 119, sample_ptr, s,
|
||||||
|
ls, le, loop_mode, default_pan, dnv,
|
||||||
|
vib_speed_taud, vib_depth_taud, vib_rate_taud, vib_wave_taud)
|
||||||
|
return patches
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_patch(patches, it_lo, it_hi, sample_ptr, s,
|
||||||
|
ls, le, loop_mode, default_pan, dnv,
|
||||||
|
vib_speed, vib_depth, vib_rate, vib_wave):
|
||||||
|
"""Append one patch dict covering IT-note range [it_lo, it_hi] inclusive."""
|
||||||
|
taud_lo = _it_note_to_taud(it_lo, clamp_low=(it_lo == 0))
|
||||||
|
taud_hi = _it_note_to_taud(it_hi, clamp_high=(it_hi == 119))
|
||||||
|
patches.append({
|
||||||
|
'pitch_start': taud_lo,
|
||||||
|
'pitch_end': taud_hi,
|
||||||
|
'volume_start': 0,
|
||||||
|
'volume_end': 63,
|
||||||
|
'sample_ptr': sample_ptr,
|
||||||
|
'sample_length': min(s.length, 65535),
|
||||||
|
'play_start': 0,
|
||||||
|
'loop_start': ls,
|
||||||
|
'loop_end': le,
|
||||||
|
'sampling_rate': min(getattr(s, 'c5_speed', 8363), 65535),
|
||||||
|
'sample_detune': 0,
|
||||||
|
'loop_mode': loop_mode,
|
||||||
|
'default_pan': default_pan,
|
||||||
|
'default_note_volume': dnv,
|
||||||
|
'vibrato_speed': vib_speed,
|
||||||
|
'vibrato_sweep': 0, # IT-side; FT2 sweep stays 0
|
||||||
|
'vibrato_depth': vib_depth,
|
||||||
|
'vibrato_rate': vib_rate,
|
||||||
|
'vibrato_waveform': vib_wave,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
|
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
|
||||||
|
|
||||||
def build_sample_inst_bin_it(samples_or_proxy: list,
|
def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||||
@@ -1190,13 +1322,29 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
|||||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||||
offsets = {}
|
offsets = {}
|
||||||
pos = 0
|
pos = 0
|
||||||
|
# IT use_instruments mode points many Taud instrument slots at the same
|
||||||
|
# underlying sample object (e.g. seven "ChipBass.*" instruments all play
|
||||||
|
# "ChipBass.looped"). Write each distinct sample's PCM into the pool once and
|
||||||
|
# let every referencing slot share the offset, rather than emitting one
|
||||||
|
# identical copy per slot. `pool_order` records the distinct samples in
|
||||||
|
# ascending-offset order — the order taut.js's sample viewer expects SNam to
|
||||||
|
# follow (it dedupes instrument records by (ptr,len), sorts by ptr, and
|
||||||
|
# matches SNam[i+1] positionally — see taut.js buildSampleIndex).
|
||||||
|
written = {} # id(sample) -> pool offset already written
|
||||||
|
pool_order = [] # distinct sample objects, in pool (ascending-offset) order
|
||||||
for idx, s in pcm_list:
|
for idx, s in pcm_list:
|
||||||
|
shared = written.get(id(s))
|
||||||
|
if shared is not None:
|
||||||
|
offsets[idx] = shared
|
||||||
|
continue
|
||||||
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
|
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
|
||||||
if n <= 0:
|
if n <= 0:
|
||||||
vprint(f" warning: sample bin full, dropping '{s.name}'")
|
vprint(f" warning: sample bin full, dropping '{s.name}'")
|
||||||
offsets[idx] = 0; s.length = 0; continue
|
offsets[idx] = 0; s.length = 0; continue
|
||||||
sample_bin[pos:pos+n] = s.sample_data[:n]
|
sample_bin[pos:pos+n] = s.sample_data[:n]
|
||||||
offsets[idx] = pos
|
offsets[idx] = pos
|
||||||
|
written[id(s)] = pos
|
||||||
|
pool_order.append(s)
|
||||||
if n < len(s.sample_data):
|
if n < len(s.sample_data):
|
||||||
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}")
|
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}")
|
||||||
s.length = n
|
s.length = n
|
||||||
@@ -1384,7 +1532,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
|||||||
|
|
||||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
||||||
|
|
||||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio, pool_order
|
||||||
|
|
||||||
|
|
||||||
# ── Pattern builder ───────────────────────────────────────────────────────────
|
# ── Pattern builder ───────────────────────────────────────────────────────────
|
||||||
@@ -1805,6 +1953,10 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
# Pattern cells carry IT instrument numbers; for use_instruments mode, those
|
# Pattern cells carry IT instrument numbers; for use_instruments mode, those
|
||||||
# are instrument indices; we remap to samples below.
|
# are instrument indices; we remap to samples below.
|
||||||
# Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order.
|
# Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order.
|
||||||
|
# Map IT sample (0-based) → IXMP patch dict template used when building the
|
||||||
|
# per-instrument patch list. Populated by the use_instruments branch below.
|
||||||
|
it_sample_patch_meta = {}
|
||||||
|
|
||||||
if h.use_instruments:
|
if h.use_instruments:
|
||||||
# Build a proxy sample list where Taud inst slot = IT inst index,
|
# Build a proxy sample list where Taud inst slot = IT inst index,
|
||||||
# resolved to the canonical sample. Slot 0 unused.
|
# resolved to the canonical sample. Slot 0 unused.
|
||||||
@@ -1899,16 +2051,60 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
'dct': inst.dct,
|
'dct': inst.dct,
|
||||||
'dca': inst.dca,
|
'dca': inst.dca,
|
||||||
}
|
}
|
||||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
# ── Ixmp: pool keyboard-referenced extra samples beyond slot 255 ───────
|
||||||
|
# IT instruments can map different IT notes to different samples via the
|
||||||
|
# keyboard table (IMPI+0x44). The canonical sample is already in the proxy
|
||||||
|
# at the instrument's Taud slot; extras (any other sample referenced in
|
||||||
|
# the keyboard) get appended past index 256 so build_sample_inst_bin_it
|
||||||
|
# pools them (its inst-record loop skips i >= 256 — see the same file).
|
||||||
|
# We then look up their bin offsets via the returned offsets dict and
|
||||||
|
# emit one Ixmp patch per (sample, contiguous-note-range) pair.
|
||||||
|
extras_keys = [] # ordered list of ('it_smp', si) — index into the proxy is 256 + position
|
||||||
|
for ii, inst in enumerate(instruments):
|
||||||
|
if inst is None: continue
|
||||||
|
canonical = inst.canonical_sample
|
||||||
|
kbd = getattr(inst, 'keyboard', None) or []
|
||||||
|
for kb_smp in kbd:
|
||||||
|
if kb_smp == 0 or kb_smp == canonical:
|
||||||
|
continue
|
||||||
|
si = kb_smp - 1
|
||||||
|
if 0 <= si < len(samples) and samples[si] is not None and samples[si].sample_data:
|
||||||
|
key = ('it_smp', si)
|
||||||
|
if key not in extras_keys:
|
||||||
|
extras_keys.append(key)
|
||||||
|
extras_base = len(proxy)
|
||||||
|
for key in extras_keys:
|
||||||
|
proxy.append(samples[key[1]])
|
||||||
|
|
||||||
|
sampleinst_raw, bin_offsets, sample_ratio, pool_order = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
||||||
|
# Map ('it_smp', si) → sample-bin offset.
|
||||||
|
extras_offsets = {key: bin_offsets.get(extras_base + j, 0)
|
||||||
|
for j, key in enumerate(extras_keys)}
|
||||||
|
# Also include each canonical sample at its taud-slot offset so the patch
|
||||||
|
# builder can reuse them when an instrument's keyboard cell references the
|
||||||
|
# canonical sample at a non-canonical note range.
|
||||||
|
for ii, inst in enumerate(instruments):
|
||||||
|
if inst is None: continue
|
||||||
|
taud_slot = ii + 1
|
||||||
|
if taud_slot >= 256: continue
|
||||||
|
canon = inst.canonical_sample
|
||||||
|
if canon == 0: continue
|
||||||
|
si = canon - 1
|
||||||
|
if 0 <= si < len(samples) and samples[si] is not None and ('it_smp', si) not in extras_offsets:
|
||||||
|
# Look up the pool offset for the canonical via the proxy slot.
|
||||||
|
if taud_slot in bin_offsets:
|
||||||
|
extras_offsets[('it_smp', si)] = bin_offsets[taud_slot]
|
||||||
else:
|
else:
|
||||||
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
|
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused).
|
||||||
|
# No instruments in the file → no multi-sample mapping → no Ixmp patches.
|
||||||
proxy = [None] + list(samples)
|
proxy = [None] + list(samples)
|
||||||
inst_vols = {
|
inst_vols = {
|
||||||
i+1: min(s.vol, 0x3F)
|
i+1: min(s.vol, 0x3F)
|
||||||
for i, s in enumerate(samples)
|
for i, s in enumerate(samples)
|
||||||
if s is not None
|
if s is not None
|
||||||
}
|
}
|
||||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy)
|
sampleinst_raw, bin_offsets, sample_ratio, pool_order = build_sample_inst_bin_it(proxy)
|
||||||
|
extras_offsets = {}
|
||||||
|
|
||||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||||
|
|
||||||
@@ -1961,12 +2157,36 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
|||||||
if with_project_data:
|
if with_project_data:
|
||||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||||
for inst in instruments[:255]]
|
for inst in instruments[:255]]
|
||||||
smp_names = [''] + [(s.name if s is not None else '')
|
# SNam mirrors the deduplicated sample pool: one entry per distinct
|
||||||
for s in samples[:255]]
|
# sample, in pool order, named after the sample itself. taut.js dedupes
|
||||||
|
# instrument records by (ptr,len), sorts ascending by ptr, and matches
|
||||||
|
# SNam[i+1] positionally to that list, so this ordering labels every
|
||||||
|
# sample correctly and a shared sample (e.g. "ChipBass.looped") appears
|
||||||
|
# exactly once instead of once per referencing instrument slot.
|
||||||
|
smp_names = [''] + [(getattr(s, 'name', '') or '')
|
||||||
|
for s in pool_order[:255]]
|
||||||
|
|
||||||
|
# Ixmp patches — only the use_instruments branch maps IT notes to multiple
|
||||||
|
# samples; the sample-mode branch has nothing to emit because there's no
|
||||||
|
# keyboard table on a raw IT sample.
|
||||||
|
ixmp_patches = {}
|
||||||
|
if h.use_instruments and extras_offsets:
|
||||||
|
for ii, inst in enumerate(instruments):
|
||||||
|
if inst is None: continue
|
||||||
|
taud_slot = ii + 1
|
||||||
|
if taud_slot >= 256: continue
|
||||||
|
patches = _build_it_ixmp_patches(inst, samples, extras_offsets)
|
||||||
|
if patches:
|
||||||
|
ixmp_patches[taud_slot] = patches
|
||||||
|
if ixmp_patches:
|
||||||
|
vprint(f" ixmp: {sum(len(p) for p in ixmp_patches.values())} "
|
||||||
|
f"patches across {len(ixmp_patches)} instruments")
|
||||||
|
|
||||||
proj_data = build_project_data(
|
proj_data = build_project_data(
|
||||||
project_name=h.title,
|
project_name=h.title,
|
||||||
instrument_names=inst_names,
|
instrument_names=inst_names,
|
||||||
sample_names=smp_names,
|
sample_names=smp_names,
|
||||||
|
ixmp_patches=ixmp_patches or None,
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = cur_off
|
proj_off = cur_off
|
||||||
|
|||||||
22
s3m2taud.py
22
s3m2taud.py
@@ -138,7 +138,11 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
|
|||||||
continue
|
continue
|
||||||
inst = S3MInstrument()
|
inst = S3MInstrument()
|
||||||
inst.itype = data[ptr]
|
inst.itype = data[ptr]
|
||||||
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
|
# 12-byte DOS filename field; null-terminated with possible trailing
|
||||||
|
# garbage after the terminator (ST3 doesn't zero the tail). Truncate at
|
||||||
|
# the first null. This field carries the per-sample short name (e.g.
|
||||||
|
# 'HIT1') as distinct from the 28-byte title at 0x30.
|
||||||
|
inst.filename = data[ptr+1:ptr+13].split(b'\x00', 1)[0].decode('latin-1', errors='replace')
|
||||||
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
|
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
|
||||||
memseg_hi = data[ptr + 0x0D]
|
memseg_hi = data[ptr + 0x0D]
|
||||||
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
|
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
|
||||||
@@ -939,17 +943,21 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
|||||||
cur_off += len(pat_comp) + len(cue_comp)
|
cur_off += len(pat_comp) + len(cue_comp)
|
||||||
|
|
||||||
# ── Project Data (optional) ──────────────────────────────────────────────
|
# ── Project Data (optional) ──────────────────────────────────────────────
|
||||||
# S3M instruments and samples share the same slot space, so the names go
|
# S3M instruments and samples share the same slot space, but carry two
|
||||||
# into both INam and SNam (1-based; slot 0 empty).
|
# distinct name fields: the 28-byte title (inst.name → INam) and the
|
||||||
|
# 12-byte DOS filename (inst.filename → SNam). e.g. WHEN.s3m instrument #1
|
||||||
|
# is titled "(c) Purple Motion / 1994" with sample name 'HIT1'.
|
||||||
proj_data = b''
|
proj_data = b''
|
||||||
proj_off = 0
|
proj_off = 0
|
||||||
if with_project_data:
|
if with_project_data:
|
||||||
names = [''] + [(inst.name if inst is not None else '')
|
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||||
for inst in instruments[:255]]
|
for inst in instruments[:255]]
|
||||||
|
sample_names = [''] + [(inst.filename if inst is not None else '')
|
||||||
|
for inst in instruments[:255]]
|
||||||
proj_data = build_project_data(
|
proj_data = build_project_data(
|
||||||
project_name=h.title,
|
project_name=h.title,
|
||||||
instrument_names=names,
|
instrument_names=inst_names,
|
||||||
sample_names=names,
|
sample_names=sample_names,
|
||||||
)
|
)
|
||||||
if proj_data:
|
if proj_data:
|
||||||
proj_off = cur_off
|
proj_off = cur_off
|
||||||
|
|||||||
@@ -543,13 +543,93 @@ def _name_table_blob(names) -> bytes:
|
|||||||
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
|
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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
|
||||||
|
IXMP_PAN_NO_OVERRIDE = 0xFF
|
||||||
|
IXMP_DNV_NO_OVERRIDE = 0
|
||||||
|
IXMP_VIBWAVE_NO_OVERRIDE = 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
def encode_ixmp_patch(p: dict) -> bytes:
|
||||||
|
"""Encode a single patch dict into 31 bytes.
|
||||||
|
|
||||||
|
Expected keys (numeric values; defaults are applied for missing optional fields):
|
||||||
|
pitch_start, pitch_end : Taud 4096-TET noteVal (Uint16)
|
||||||
|
volume_start, volume_end : 0..63 (Uint8)
|
||||||
|
sample_ptr : Uint32 (sample bin offset)
|
||||||
|
sample_length : Uint16
|
||||||
|
play_start, loop_start, loop_end : Uint16
|
||||||
|
sampling_rate : Uint16 (same encoding as base inst byte 6-7)
|
||||||
|
sample_detune : Int16, signed 4096-TET (default 0)
|
||||||
|
loop_mode : Uint8 (default 0)
|
||||||
|
default_pan : Uint8, 0xFF = no override (default 0xFF)
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
pitch_start = max(0, min(0xFFFF, int(p['pitch_start'])))
|
||||||
|
pitch_end = max(0, min(0xFFFF, int(p['pitch_end'])))
|
||||||
|
vol_start = max(0, min(63, int(p.get('volume_start', 0))))
|
||||||
|
vol_end = max(0, min(63, int(p.get('volume_end', 63))))
|
||||||
|
sample_ptr = int(p['sample_ptr']) & 0xFFFFFFFF
|
||||||
|
sample_len = max(0, min(0xFFFF, int(p['sample_length'])))
|
||||||
|
play_start = max(0, min(0xFFFF, int(p.get('play_start', 0))))
|
||||||
|
loop_start = max(0, min(0xFFFF, int(p.get('loop_start', 0))))
|
||||||
|
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(
|
||||||
|
'<BHHBBIHHHHHhBBBBBBBB',
|
||||||
|
1, # patch version
|
||||||
|
pitch_start, pitch_end,
|
||||||
|
vol_start, vol_end,
|
||||||
|
sample_ptr,
|
||||||
|
sample_len,
|
||||||
|
play_start, loop_start, loop_end,
|
||||||
|
rate,
|
||||||
|
detune,
|
||||||
|
int(p.get('loop_mode', 0)) & 0x07,
|
||||||
|
int(p.get('default_pan', IXMP_PAN_NO_OVERRIDE)) & 0xFF,
|
||||||
|
int(p.get('default_note_volume', IXMP_DNV_NO_OVERRIDE)) & 0xFF,
|
||||||
|
int(p.get('vibrato_speed', 0)) & 0xFF,
|
||||||
|
int(p.get('vibrato_sweep', 0)) & 0xFF,
|
||||||
|
int(p.get('vibrato_depth', 0)) & 0xFF,
|
||||||
|
int(p.get('vibrato_rate', 0)) & 0xFF,
|
||||||
|
int(p.get('vibrato_waveform', IXMP_VIBWAVE_NO_OVERRIDE)) & 0xFF,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_ixmp_payload(patches_by_inst: dict) -> bytes:
|
||||||
|
"""Encode a dict {instrument_id: [patch_dict, ...]} as one Ixmp section payload
|
||||||
|
(the body that follows the FourCC + length header). Instruments are written in
|
||||||
|
ascending id order. Overlapping pitch+volume rectangles within one instrument
|
||||||
|
are INVALID per spec and the caller is responsible for keeping them disjoint."""
|
||||||
|
if not patches_by_inst:
|
||||||
|
return b''
|
||||||
|
out = bytearray()
|
||||||
|
for inst_id in sorted(patches_by_inst):
|
||||||
|
patches = patches_by_inst[inst_id]
|
||||||
|
if not patches:
|
||||||
|
continue
|
||||||
|
out.append(int(inst_id) & 0xFF)
|
||||||
|
cnt = len(patches)
|
||||||
|
out += bytes([cnt & 0xFF, (cnt >> 8) & 0xFF, (cnt >> 16) & 0xFF]) # Uint24 LE
|
||||||
|
for patch in patches:
|
||||||
|
out += encode_ixmp_patch(patch)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
def build_project_data(*, project_name: str = '',
|
def build_project_data(*, project_name: str = '',
|
||||||
author: str = '',
|
author: str = '',
|
||||||
copyright_str: str = '',
|
copyright_str: str = '',
|
||||||
sample_names=None,
|
sample_names=None,
|
||||||
instrument_names=None,
|
instrument_names=None,
|
||||||
pattern_names=None,
|
pattern_names=None,
|
||||||
song_metadata=None) -> bytes:
|
song_metadata=None,
|
||||||
|
ixmp_patches=None) -> bytes:
|
||||||
"""Build the optional PROJECT DATA section payload.
|
"""Build the optional PROJECT DATA section payload.
|
||||||
|
|
||||||
Returns the full block (8-byte magic + 8 reserved bytes + concatenated
|
Returns the full block (8-byte magic + 8 reserved bytes + concatenated
|
||||||
@@ -604,6 +684,9 @@ def build_project_data(*, project_name: str = '',
|
|||||||
smet += payload
|
smet += payload
|
||||||
add(b'sMet', bytes(smet))
|
add(b'sMet', bytes(smet))
|
||||||
|
|
||||||
|
if ixmp_patches:
|
||||||
|
add(b'Ixmp', encode_ixmp_payload(ixmp_patches))
|
||||||
|
|
||||||
if not sections:
|
if not sections:
|
||||||
return b''
|
return b''
|
||||||
|
|
||||||
|
|||||||
@@ -2218,7 +2218,7 @@ from source.
|
|||||||
* FastTracker2 has instrumentwise config (0..255)
|
* FastTracker2 has instrumentwise config (0..255)
|
||||||
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
|
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
|
||||||
176 Uint8 Vibrato sweep
|
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)
|
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
|
* 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)
|
178 Uint16 Pitch-pan centre (4096-TET note value)
|
||||||
@@ -2226,7 +2226,7 @@ from source.
|
|||||||
181 Uint8 Pan swing (0..255 full range)
|
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)
|
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)
|
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
|
186 Bit8 Instrument Flag
|
||||||
0b 000 www nn
|
0b 000 www nn
|
||||||
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
|
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
|
||||||
@@ -2409,8 +2409,10 @@ TODO:
|
|||||||
[x] expose song table on UI (test with `insaniq2.taud`)
|
[x] expose song table on UI (test with `insaniq2.taud`)
|
||||||
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
|
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
|
||||||
[ ] establish hooks for the interrupts
|
[ ] establish hooks for the interrupts
|
||||||
[ ] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
[x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
||||||
follow the ImpulseTracker design first, then improve from there
|
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
|
||||||
|
|
||||||
TODO - list of demo songs that MUST ship with Microtone:
|
TODO - list of demo songs that MUST ship with Microtone:
|
||||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||||
@@ -2469,7 +2471,7 @@ Audio Adapter MMIO
|
|||||||
Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
|
Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
|
||||||
Write 1 to decode the frame as MP2
|
Write 1 to decode the frame as MP2
|
||||||
|
|
||||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR
|
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR — except for the flag 0x11, in which the hardware must initialise then immediately start decoding.
|
||||||
|
|
||||||
41 RO: MP2 Decoder Status
|
41 RO: MP2 Decoder Status
|
||||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||||
@@ -2619,6 +2621,17 @@ This is a file format for Taud tracker data. Taud can be extended with Microtone
|
|||||||
|
|
||||||
Endianness: Little
|
Endianness: Little
|
||||||
|
|
||||||
|
# Conformance language
|
||||||
|
(RFC 2119+8174)
|
||||||
|
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
|
||||||
|
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
|
||||||
|
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
|
||||||
|
(IMPLEMENTATION DETAILS)
|
||||||
|
- **INVALID.** Blame the encoder; decoder MUST stop decoding with appropriate errors.
|
||||||
|
- **UNDEFINED BEHAVIOUR.** Encoder MAY encode it; decoder MAY do whatever it wants to, including spawning a daemon out of your nose.
|
||||||
|
- **IGNORED.** Encoder MAY encode it; decoder MUST skip past it.
|
||||||
|
- **RESERVED.** Encoder MUST NOT encode it. Decoder MUST skip past it.
|
||||||
|
|
||||||
# File Structure
|
# File Structure
|
||||||
\x1F T S V M a u d
|
\x1F T S V M a u d
|
||||||
[HEADER]
|
[HEADER]
|
||||||
@@ -2656,7 +2669,7 @@ Endianness: Little
|
|||||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||||
0b 000 rrr ff
|
0b 000 rrr ff
|
||||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: RESERVED)
|
||||||
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation)
|
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation)
|
||||||
Uint8 Song global volume
|
Uint8 Song global volume
|
||||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||||
@@ -2664,7 +2677,7 @@ Endianness: Little
|
|||||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||||
Uint32 Compressed size of PATTERN BIN for this song
|
Uint32 Compressed size of PATTERN BIN for this song
|
||||||
Uint32 Compressed size of CUE SHEET for this song
|
Uint32 Compressed size of CUE SHEET for this song
|
||||||
Byte[6] Reserved
|
Byte[6] RESERVED
|
||||||
|
|
||||||
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
||||||
|
|
||||||
@@ -2686,7 +2699,7 @@ Endianness: Little
|
|||||||
Project Data is just a concatenation of blocks identified by their FourCC.
|
Project Data is just a concatenation of blocks identified by their FourCC.
|
||||||
|
|
||||||
Byte[8] Magic (\x1E T a u d P r J)
|
Byte[8] Magic (\x1E T a u d P r J)
|
||||||
Byte[8] Reserved
|
Byte[8] RESERVED
|
||||||
* Repetition of
|
* Repetition of
|
||||||
Byte[4] Title of the section (fourcc)
|
Byte[4] Title of the section (fourcc)
|
||||||
Uint32 Section length
|
Uint32 Section length
|
||||||
@@ -2737,11 +2750,11 @@ prefixes:
|
|||||||
* Repetition of:
|
* Repetition of:
|
||||||
Uint8 Notation index (starting from zero) used by songs
|
Uint8 Notation index (starting from zero) used by songs
|
||||||
Uint32 Size of this notation following this field
|
Uint32 Size of this notation following this field
|
||||||
Uint16 Reserved for flags
|
Uint16 RESERVED for flags
|
||||||
Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0.
|
Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0.
|
||||||
Uint16 Reserved for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
|
Uint16 RESERVED for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
|
||||||
Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11
|
Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11
|
||||||
Byte[8] Reserved
|
Byte[8] RESERVED
|
||||||
Byte[*] Name, null terminated. Encoding: UTF-8
|
Byte[*] Name, null terminated. Encoding: UTF-8
|
||||||
Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset
|
Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset
|
||||||
Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right
|
Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right
|
||||||
@@ -2762,6 +2775,45 @@ prefixes:
|
|||||||
Uint8 Version (Ascii 'a')
|
Uint8 Version (Ascii 'a')
|
||||||
Bytes Notation definitions (see above)
|
Bytes Notation definitions (see above)
|
||||||
|
|
||||||
|
* Ixmp. Instrument extra samples
|
||||||
|
* Repetition of:
|
||||||
|
Uint8 Instrument ID
|
||||||
|
Uint24 Count of patches
|
||||||
|
** Repetition of:
|
||||||
|
Uint8 Patch definition version (always 1)
|
||||||
|
Uint16 Pitch start ; Taud 4096-TET noteVal (same scale as pattern-cell note)
|
||||||
|
Uint16 Pitch end (inclusive)
|
||||||
|
Uint8 Volume start ; 0..63
|
||||||
|
Uint8 Volume end (inclusive) ; 0..63
|
||||||
|
- Above four parameters define a rectangle over the Pitch-Volume space. See Notes 4 and 5
|
||||||
|
Uint32 Sample pointer
|
||||||
|
Uint16 Sample length
|
||||||
|
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
|
||||||
|
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 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)
|
||||||
|
Uint8 vibratoSweep ; per-sample auto-vibrato (mirrors base inst byte 176)
|
||||||
|
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"
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
**S3M (ScreamTracker 3) to Taud conversion notes**
|
**S3M (ScreamTracker 3) to Taud conversion notes**
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ class AudioJSR223Delegate(private val vm: VM) {
|
|||||||
fun stop(playhead: Int) { getPlayhead(playhead)?.isPlaying = false }
|
fun stop(playhead: Int) { getPlayhead(playhead)?.isPlaying = false }
|
||||||
fun isPlaying(playhead: Int) = getPlayhead(playhead)?.isPlaying
|
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 setPosition(playhead: Int, pos: Int) { getPlayhead(playhead)?.position = pos and 65535 }
|
||||||
fun getPosition(playhead: Int) = getPlayhead(playhead)?.position
|
fun getPosition(playhead: Int) = getPlayhead(playhead)?.position
|
||||||
|
|
||||||
@@ -163,6 +174,24 @@ class AudioJSR223Delegate(private val vm: VM) {
|
|||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
|
||||||
|
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
|
||||||
|
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
|
||||||
|
fun getVoiceFunkSpeed(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.funkSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
|
||||||
|
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
|
||||||
|
* empty array when the instrument has never been funk-repeated. The render thread mutates the
|
||||||
|
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
|
||||||
|
fun getInstrumentFunkMask(slot: Int): IntArray {
|
||||||
|
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
|
||||||
|
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
|
||||||
|
}
|
||||||
|
|
||||||
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
|
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
|
||||||
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
|
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
|
||||||
* inactive voices. */
|
* inactive voices. */
|
||||||
@@ -247,6 +276,64 @@ class AudioJSR223Delegate(private val vm: VM) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
||||||
|
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 }
|
||||||
|
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 }
|
||||||
|
fun u32(o: Int) = (bytes[o] and 0xFF) or
|
||||||
|
((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(
|
||||||
|
pitchStart = u16(o + 1),
|
||||||
|
pitchEnd = u16(o + 3),
|
||||||
|
volumeStart = u8 (o + 5),
|
||||||
|
volumeEnd = u8 (o + 6),
|
||||||
|
samplePtr = u32(o + 7),
|
||||||
|
sampleLength = u16(o + 11),
|
||||||
|
playStart = u16(o + 13),
|
||||||
|
loopStart = u16(o + 15),
|
||||||
|
loopEnd = u16(o + 17),
|
||||||
|
samplingRate = u16(o + 19),
|
||||||
|
sampleDetune = s16(o + 21),
|
||||||
|
loopMode = u8 (o + 23),
|
||||||
|
defaultPan = u8 (o + 24),
|
||||||
|
defaultNoteVolume = u8 (o + 25),
|
||||||
|
vibratoSpeed = u8 (o + 26),
|
||||||
|
vibratoSweep = u8 (o + 27),
|
||||||
|
vibratoDepth = u8 (o + 28),
|
||||||
|
vibratoRate = u8 (o + 29),
|
||||||
|
vibratoWaveform = u8 (o + 30)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
inst.extraPatches = patches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
/** Clear any Ixmp patches previously uploaded to instrument [slot]. */
|
||||||
|
fun clearInstrumentPatches(slot: Int) {
|
||||||
|
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches = null
|
||||||
|
}
|
||||||
|
|
||||||
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
|
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
|
||||||
fun uploadPattern(slot: Int, bytes: IntArray) {
|
fun uploadPattern(slot: Int, bytes: IntArray) {
|
||||||
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
|
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
|
||||||
@@ -297,6 +384,13 @@ class AudioJSR223Delegate(private val vm: VM) {
|
|||||||
getPlayhead(playhead)?.resetParams()
|
getPlayhead(playhead)?.resetParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear funk-repeat (S$Fx) state (per-voice run-state + per-instrument loop-inversion masks)
|
||||||
|
* without disturbing tempo / volume / position. Call on a fresh play-from-start so stale funk
|
||||||
|
* state from a prior playback doesn't bleed into the replay. */
|
||||||
|
fun resetFunkState(playhead: Int) {
|
||||||
|
getPlayhead(playhead)?.resetFunkState()
|
||||||
|
}
|
||||||
|
|
||||||
fun purgeQueue(playhead: Int) {
|
fun purgeQueue(playhead: Int) {
|
||||||
getPlayhead(playhead)?.purgeQueue()
|
getPlayhead(playhead)?.purgeQueue()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* Copy a single progressive GOP frame out of videoBuffer (Java heap) into a
|
||||||
* Handles field extraction and temporal deinterlacing for GOP frames.
|
* 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 frameIndex Current frame index in GOP (0-based)
|
||||||
* @param gopSize Total number of frames in GOP
|
* @param gopSize Total number of frames in GOP
|
||||||
* @param width Frame width
|
* @param width Frame width
|
||||||
* @param fieldHeight Height of each field (half of display height)
|
* @param fieldHeight Height of each field (half of display height)
|
||||||
* @param fullHeight Full display height (2 * fieldHeight)
|
* @param fullHeight Full display height (2 * fieldHeight) — unused here, kept for call symmetry
|
||||||
* @param frameCount Global frame counter for dithering
|
* @param frameCount Global frame counter (deinterlacer cadence)
|
||||||
* @param bufferOffset Start offset of GOP in videoBuffer
|
* @param bufferOffset Start offset of GOP in videoBuffer
|
||||||
* @param prevFieldAddr Memory address for previous field buffer
|
* @param prevFieldAddr Memory address for previous field buffer (scratch)
|
||||||
* @param currentFieldAddr Memory address for current field buffer
|
* @param currentFieldAddr Memory address for current field buffer (scratch)
|
||||||
* @param nextFieldAddr Memory address for next field buffer
|
* @param nextFieldAddr Memory address for next field buffer (scratch)
|
||||||
* @param deinterlaceOutputAddr Memory address for deinterlaced output
|
* @param deinterlaceOutputAddr Destination RGB888 buffer (width*fullHeight*3 bytes)
|
||||||
*/
|
*/
|
||||||
fun uploadInterlacedGopFrameToFramebuffer(
|
fun tavDeinterlaceGopFrameToRGB(
|
||||||
frameIndex: Int,
|
frameIndex: Int,
|
||||||
gopSize: Int,
|
gopSize: Int,
|
||||||
width: Int,
|
width: Int,
|
||||||
@@ -7158,8 +7202,43 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
|||||||
prevFieldAddr, currentFieldAddr, nextFieldAddr,
|
prevFieldAddr, currentFieldAddr, nextFieldAddr,
|
||||||
deinterlaceOutputAddr, "yadif"
|
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)
|
uploadRGBToFramebuffer(deinterlaceOutputAddr, width, fullHeight, frameCount, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1357,9 +1357,55 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
const val OP_Z = 0x23
|
const val OP_Z = 0x23
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double =
|
// Active-sample-aware playback rate. Reads from the Voice's snapshotted sample
|
||||||
inst.samplingRate.toDouble() / SAMPLING_RATE *
|
// view (set by [applyActiveSample]) so Ixmp-overlaid instruments use the patch's
|
||||||
2.0.pow((noteVal - MIDDLE_C + inst.sampleDetuneSigned) / 4096.0)
|
// samplingRate / detune, not the base inst's.
|
||||||
|
private fun computePlaybackRate(voice: Voice, noteVal: Int): Double =
|
||||||
|
voice.activeSamplingRate.toDouble() / SAMPLING_RATE *
|
||||||
|
2.0.pow((noteVal - MIDDLE_C + voice.activeSampleDetune) / 4096.0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot the sample-scope state for [voice] from either the base instrument
|
||||||
|
* or a resolved Ixmp patch. Called by every fresh trigger; the per-tick read
|
||||||
|
* sites then go through voice.active* instead of inst.* so multi-sample
|
||||||
|
* (IT/XM keyboard table) instruments select the right sample per note.
|
||||||
|
*
|
||||||
|
* Sentinels on the patch: defaultPan == 0xFF, defaultNoteVolume == 0,
|
||||||
|
* vibratoWaveform == 0xFF all defer to the base instrument. Other fields
|
||||||
|
* are always carried by the patch (converter responsibility).
|
||||||
|
*/
|
||||||
|
private fun applyActiveSample(voice: Voice, inst: TaudInst, patch: TaudInstPatch?) {
|
||||||
|
if (patch == null) {
|
||||||
|
voice.activeSamplePtr = inst.samplePtr
|
||||||
|
voice.activeSampleLength = inst.sampleLength
|
||||||
|
voice.activeSamplePlayStart = inst.samplePlayStart
|
||||||
|
voice.activeSampleLoopStart = inst.sampleLoopStart
|
||||||
|
voice.activeSampleLoopEnd = inst.sampleLoopEnd
|
||||||
|
voice.activeSamplingRate = inst.samplingRate
|
||||||
|
voice.activeSampleDetune = inst.sampleDetuneSigned
|
||||||
|
voice.activeLoopMode = inst.loopMode
|
||||||
|
voice.activeVibratoSpeed = inst.vibratoSpeed
|
||||||
|
voice.activeVibratoSweep = inst.vibratoSweep
|
||||||
|
voice.activeVibratoDepth = inst.vibratoDepth
|
||||||
|
voice.activeVibratoRate = inst.vibratoRate
|
||||||
|
voice.activeVibratoWaveform = inst.vibratoWaveform
|
||||||
|
} else {
|
||||||
|
voice.activeSamplePtr = patch.samplePtr
|
||||||
|
voice.activeSampleLength = patch.sampleLength
|
||||||
|
voice.activeSamplePlayStart = patch.playStart
|
||||||
|
voice.activeSampleLoopStart = patch.loopStart
|
||||||
|
voice.activeSampleLoopEnd = patch.loopEnd
|
||||||
|
voice.activeSamplingRate = patch.samplingRate
|
||||||
|
voice.activeSampleDetune = patch.sampleDetune
|
||||||
|
voice.activeLoopMode = patch.loopMode
|
||||||
|
voice.activeVibratoSpeed = patch.vibratoSpeed
|
||||||
|
voice.activeVibratoSweep = patch.vibratoSweep
|
||||||
|
voice.activeVibratoDepth = patch.vibratoDepth
|
||||||
|
voice.activeVibratoRate = patch.vibratoRate
|
||||||
|
voice.activeVibratoWaveform =
|
||||||
|
if (patch.vibratoWaveform == 0xFF) inst.vibratoWaveform else patch.vibratoWaveform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert a 4096-TET noteVal to its Amiga-period equivalent (Double, no rounding).
|
// Convert a 4096-TET noteVal to its Amiga-period equivalent (Double, no rounding).
|
||||||
private fun noteValToAmigaPeriod(noteVal: Int): Double =
|
private fun noteValToAmigaPeriod(noteVal: Int): Double =
|
||||||
@@ -1754,16 +1800,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
* 0 means full depth immediately).
|
* 0 means full depth immediately).
|
||||||
*/
|
*/
|
||||||
private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int {
|
private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int {
|
||||||
// Depth from byte 187 (full 0..255). Speed from byte 175 (FT2 0..255 scale).
|
// Reads come from the voice's active-sample snapshot (patch-aware) so multi-sample
|
||||||
val depth0 = inst.vibratoDepth
|
// IT/XM instruments use the per-sample auto-vibrato that the trigger resolved to.
|
||||||
if (depth0 == 0 || inst.vibratoSpeed == 0) return 0
|
// [inst] is retained in the signature for callsite continuity but only the voice's
|
||||||
|
// active fields are consulted here.
|
||||||
|
val depth0 = voice.activeVibratoDepth
|
||||||
|
if (depth0 == 0 || voice.activeVibratoSpeed == 0) return 0
|
||||||
|
|
||||||
// Two ramp-in semantics:
|
// Two ramp-in semantics:
|
||||||
// FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep.
|
// FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep.
|
||||||
// IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick,
|
// IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick,
|
||||||
// capped at depth0 * 256, then divided by 256.
|
// capped at depth0 * 256, then divided by 256.
|
||||||
val ftSweep = inst.vibratoSweep
|
val ftSweep = voice.activeVibratoSweep
|
||||||
val itRate = inst.vibratoRate
|
val itRate = voice.activeVibratoRate
|
||||||
val t = voice.autoVibTicksSinceTrigger
|
val t = voice.autoVibTicksSinceTrigger
|
||||||
val rampDepth = when {
|
val rampDepth = when {
|
||||||
ftSweep != 0 -> ((depth0 * t / ftSweep).coerceAtMost(depth0))
|
ftSweep != 0 -> ((depth0 * t / ftSweep).coerceAtMost(depth0))
|
||||||
@@ -1772,17 +1821,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
}
|
}
|
||||||
voice.autoVibTicksSinceTrigger++
|
voice.autoVibTicksSinceTrigger++
|
||||||
|
|
||||||
// Vibrato waveform selector lives in instrumentFlag bits 2-4.
|
// Vibrato waveform selector lives in instrumentFlag bits 2-4 (snapshotted onto voice).
|
||||||
// 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only).
|
// 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only).
|
||||||
// lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down.
|
// lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down.
|
||||||
val wave = inst.vibratoWaveform
|
val wave = voice.activeVibratoWaveform
|
||||||
val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1)
|
val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1)
|
||||||
else lfoSample(voice.autoVibPhase, wave and 3)
|
else lfoSample(voice.autoVibPhase, wave and 3)
|
||||||
// 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the
|
// 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the
|
||||||
// shift compensates so depth ≈255 yields a similar musical excursion
|
// shift compensates so depth ≈255 yields a similar musical excursion
|
||||||
// (~±9 cents) to the old depth ≈15.
|
// (~±9 cents) to the old depth ≈15.
|
||||||
val pitchDelta = (rawSample * rampDepth) shr 10
|
val pitchDelta = (rawSample * rampDepth) shr 10
|
||||||
voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF
|
voice.autoVibPhase = (voice.autoVibPhase + voice.activeVibratoSpeed * 2) and 0xFF
|
||||||
return pitchDelta
|
return pitchDelta
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1790,10 +1839,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
* Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's
|
* Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's
|
||||||
* funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the
|
* funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the
|
||||||
* caller is responsible for wrapping into a loop region first if loop semantics apply.
|
* caller is responsible for wrapping into a loop region first if loop semantics apply.
|
||||||
|
*
|
||||||
|
* Sample-geometry reads come from the voice's active-sample snapshot so Ixmp-patched
|
||||||
|
* voices read the right bytes. The funk-mask continues to live on the base instrument
|
||||||
|
* (PT2 effect; doesn't combine with multi-sample IT/XM in practice).
|
||||||
*/
|
*/
|
||||||
private fun readSamplePoint(inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double {
|
private fun readSamplePoint(voice: Voice, inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double {
|
||||||
val i = idx.coerceIn(0, sampleLen - 1)
|
val i = idx.coerceIn(0, sampleLen - 1)
|
||||||
var b = sampleBin[(inst.samplePtr + i).coerceAtMost(binMax).toLong()].toUint()
|
var b = sampleBin[(voice.activeSamplePtr + i).coerceAtMost(binMax).toLong()].toUint()
|
||||||
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
||||||
val ls = inst.sampleLoopStart
|
val ls = inst.sampleLoopStart
|
||||||
if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF
|
if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF
|
||||||
@@ -1804,9 +1857,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double {
|
private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double {
|
||||||
if (inst.index == 0) return 0.0
|
if (inst.index == 0) return 0.0
|
||||||
|
|
||||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
val sampleLen = voice.activeSampleLength.coerceAtLeast(1)
|
||||||
val loopStart = inst.sampleLoopStart.toDouble()
|
val loopStart = voice.activeSampleLoopStart.toDouble()
|
||||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
val loopEnd = voice.activeSampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||||
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
||||||
|
|
||||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||||
@@ -1826,7 +1879,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac.
|
// Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac.
|
||||||
for (j in -SINC_WIDTH .. SINC_WIDTH) {
|
for (j in -SINC_WIDTH .. SINC_WIDTH) {
|
||||||
val coeff = sincTap(frac, j)
|
val coeff = sincTap(frac, j)
|
||||||
if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff
|
if (coeff != 0.0) acc += readSamplePoint(voice, inst, i0 + j, sampleLen, binMax) * coeff
|
||||||
}
|
}
|
||||||
acc
|
acc
|
||||||
}
|
}
|
||||||
@@ -1837,10 +1890,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// formula in integer arithmetic, then map (out >> 1) back to [-1, 1].
|
// formula in integer arithmetic, then map (out >> 1) back to [-1, 1].
|
||||||
// The (out & 0xffff) → int16 cast after the third tap reproduces the
|
// The (out & 0xffff) → int16 cast after the third tap reproduces the
|
||||||
// SNES hardware mid-sum overflow (the famous gauss "chirp").
|
// SNES hardware mid-sum overflow (the famous gauss "chirp").
|
||||||
val oldest = (readSamplePoint(inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt()
|
val oldest = (readSamplePoint(voice, inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt()
|
||||||
val olders = (readSamplePoint(inst, i0, sampleLen, binMax) * 32767.0).toInt()
|
val olders = (readSamplePoint(voice, inst, i0, sampleLen, binMax) * 32767.0).toInt()
|
||||||
val olds = (readSamplePoint(inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt()
|
val olds = (readSamplePoint(voice, inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt()
|
||||||
val news = (readSamplePoint(inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt()
|
val news = (readSamplePoint(voice, inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt()
|
||||||
val offset = (frac * 256.0).toInt().coerceIn(0, 255)
|
val offset = (frac * 256.0).toInt().coerceIn(0, 255)
|
||||||
var out = (SNES_GAUSS[0xff - offset] * oldest) shr 10
|
var out = (SNES_GAUSS[0xff - offset] * oldest) shr 10
|
||||||
out += (SNES_GAUSS[0x1ff - offset] * olders) shr 10
|
out += (SNES_GAUSS[0x1ff - offset] * olders) shr 10
|
||||||
@@ -1868,7 +1921,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// a mid-rail seed), reproducing DMC's coarse quantisation. Per-voice
|
// a mid-rail seed), reproducing DMC's coarse quantisation. Per-voice
|
||||||
// counter persists across samples and is reseeded to mid-rail on note
|
// counter persists across samples and is reseeded to mid-rail on note
|
||||||
// trigger (see triggerNote).
|
// trigger (see triggerNote).
|
||||||
val target = readSamplePoint(inst, i0, sampleLen, binMax)
|
val target = readSamplePoint(voice, inst, i0, sampleLen, binMax)
|
||||||
val targetLevel = ((target + 1.0) * 63.5).toInt().coerceIn(0, 127)
|
val targetLevel = ((target + 1.0) * 63.5).toInt().coerceIn(0, 127)
|
||||||
when {
|
when {
|
||||||
targetLevel > voice.nesDpcmCounter && voice.nesDpcmCounter <= 125 ->
|
targetLevel > voice.nesDpcmCounter && voice.nesDpcmCounter <= 125 ->
|
||||||
@@ -1881,8 +1934,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
INTERP_NONE, INTERP_A500, INTERP_A1200 ->
|
INTERP_NONE, INTERP_A500, INTERP_A1200 ->
|
||||||
// Paula-style ZOH — emit the integer-indexed sample byte without
|
// Paula-style ZOH — emit the integer-indexed sample byte without
|
||||||
// sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs.
|
// sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs.
|
||||||
readSamplePoint(inst, i0, sampleLen, binMax)
|
readSamplePoint(voice, inst, i0, sampleLen, binMax)
|
||||||
else -> readSamplePoint(inst, i0, sampleLen, binMax)
|
else -> readSamplePoint(voice, inst, i0, sampleLen, binMax)
|
||||||
}
|
}
|
||||||
|
|
||||||
// While ramping out at sample end, hold position so the mixer keeps emitting the
|
// While ramping out at sample end, hold position so the mixer keeps emitting the
|
||||||
@@ -1895,7 +1948,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// When the sustain bit is set, key-off escapes the loop: the sample plays past
|
// When the sustain bit is set, key-off escapes the loop: the sample plays past
|
||||||
// loopEnd until it ends naturally (loopMode 0 semantics).
|
// loopEnd until it ends naturally (loopMode 0 semantics).
|
||||||
val effectiveLoopMode =
|
val effectiveLoopMode =
|
||||||
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
|
if (voice.activeSampleLoopSustain && voice.keyOff) 0 else (voice.activeLoopMode and 3)
|
||||||
when (effectiveLoopMode) {
|
when (effectiveLoopMode) {
|
||||||
0 -> if (voice.samplePos >= sampleLen) {
|
0 -> if (voice.samplePos >= sampleLen) {
|
||||||
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
|
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
|
||||||
@@ -1977,16 +2030,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
* unconditionally on inst-column rows, regardless of porta). Sets
|
* unconditionally on inst-column rows, regardless of porta). Sets
|
||||||
* noteVolume only — channelVolume (IT chan->global_volume) survives.
|
* noteVolume only — channelVolume (IT chan->global_volume) survives.
|
||||||
*/
|
*/
|
||||||
private fun rowVolumeFromDefault(inst: TaudInst): Int {
|
private fun rowVolumeFromDefault(inst: TaudInst, patch: TaudInstPatch? = null): Int {
|
||||||
val dnv = inst.defaultNoteVolume
|
// Patch overrides the base inst's DNV unless the sentinel (0 = no override).
|
||||||
|
val dnv = patch?.defaultNoteVolume?.takeIf { it != 0 } ?: inst.defaultNoteVolume
|
||||||
return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255
|
return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
|
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
|
||||||
if (instId != 0) voice.instrumentId = instId
|
if (instId != 0) voice.instrumentId = instId
|
||||||
val inst = instruments[voice.instrumentId]
|
val inst = instruments[voice.instrumentId]
|
||||||
|
// Resolve the Ixmp patch (if any) for this trigger. Volume axis uses the
|
||||||
|
// pre-patch seed so the rectangle test is well-defined; the patch's own
|
||||||
|
// DNV is then layered onto the final voice.noteVolume below.
|
||||||
|
val seedVolForLookup = when {
|
||||||
|
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||||
|
instId != 0 -> rowVolumeFromDefault(inst, null)
|
||||||
|
else -> voice.noteVolume.coerceIn(0, 0x3F)
|
||||||
|
}
|
||||||
|
val patch = inst.resolvePatch(noteVal, seedVolForLookup)
|
||||||
|
applyActiveSample(voice, inst, patch)
|
||||||
voice.tonePortaTarget = -1 // fresh note trigger cancels any running porta
|
voice.tonePortaTarget = -1 // fresh note trigger cancels any running porta
|
||||||
voice.samplePos = inst.samplePlayStart.toDouble()
|
voice.samplePos = voice.activeSamplePlayStart.toDouble()
|
||||||
voice.forward = true
|
voice.forward = true
|
||||||
voice.active = true
|
voice.active = true
|
||||||
voice.keyOff = false
|
voice.keyOff = false
|
||||||
@@ -2042,8 +2106,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
if (instId != 0) {
|
if (instId != 0) {
|
||||||
// Default pan: applied unless the pattern row has already overridden channelPan.
|
// Default pan: applied unless the pattern row has already overridden channelPan.
|
||||||
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
|
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
|
||||||
|
// An Ixmp patch's defaultPan (when non-sentinel, i.e. != 0xFF) takes precedence over
|
||||||
|
// the base instrument's defaultPan.
|
||||||
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
|
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
|
||||||
voice.channelPan = inst.defaultPan
|
val patchPan = patch?.defaultPan?.takeIf { it != 0xFF }
|
||||||
|
voice.channelPan = patchPan ?: inst.defaultPan
|
||||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||||
}
|
}
|
||||||
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
|
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
|
||||||
@@ -2066,7 +2133,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
voice.basePitch = noteVal
|
voice.basePitch = noteVal
|
||||||
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
|
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
|
||||||
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
|
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
|
||||||
voice.playbackRate = computePlaybackRate(inst, noteVal)
|
voice.playbackRate = computePlaybackRate(voice, noteVal)
|
||||||
// Fresh trigger seeds noteVolume from the per-instrument "default note volume"
|
// Fresh trigger seeds noteVolume from the per-instrument "default note volume"
|
||||||
// (byte 196) when the row carried an instrument byte but no explicit V column —
|
// (byte 196) when the row carried an instrument byte but no explicit V column —
|
||||||
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
|
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
|
||||||
@@ -2078,9 +2145,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// chan->global_volume across sample changes, so M / N writes persist.
|
// chan->global_volume across sample changes, so M / N writes persist.
|
||||||
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
|
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
|
||||||
// mixer applies independently of this seed.
|
// mixer applies independently of this seed.
|
||||||
|
// When an Ixmp patch overrides DNV (non-sentinel), the patch wins via rowVolumeFromDefault.
|
||||||
voice.noteVolume = when {
|
voice.noteVolume = when {
|
||||||
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||||
instId != 0 -> rowVolumeFromDefault(inst)
|
instId != 0 -> rowVolumeFromDefault(inst, patch)
|
||||||
else -> voice.noteVolume
|
else -> voice.noteVolume
|
||||||
}
|
}
|
||||||
voice.rowVolume = voice.noteVolume
|
voice.rowVolume = voice.noteVolume
|
||||||
@@ -2126,14 +2194,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
private fun applyDuplicateCheck(ts: TrackerState, channel: Int, newInstId: Int, newNote: Int) {
|
private fun applyDuplicateCheck(ts: TrackerState, channel: Int, newInstId: Int, newNote: Int) {
|
||||||
if (newInstId == 0) return
|
if (newInstId == 0) return
|
||||||
val newInst = instruments[newInstId]
|
val newInst = instruments[newInstId]
|
||||||
|
// For DCT=2 (sample match) we compare canonical sample identity. With Ixmp, the
|
||||||
|
// new note's effective sample is the patch's (or the base inst's if no patch).
|
||||||
|
// Volume axis defaults to full (0x3F) at this resolution point — the actual
|
||||||
|
// trigger volume isn't known yet and the IT DCT model is volume-agnostic anyway.
|
||||||
|
val newPatch = newInst.resolvePatch(newNote, 0x3F)
|
||||||
|
val newSmpPtr = newPatch?.samplePtr ?: newInst.samplePtr
|
||||||
|
val newSmpLen = newPatch?.sampleLength ?: newInst.sampleLength
|
||||||
|
|
||||||
fun isDuplicate(v: Voice): Boolean {
|
fun isDuplicate(v: Voice): Boolean {
|
||||||
val existInst = instruments[v.instrumentId]
|
val existInst = instruments[v.instrumentId]
|
||||||
return when (existInst.duplicateCheckType) {
|
return when (existInst.duplicateCheckType) {
|
||||||
1 -> v.noteVal == newNote && v.instrumentId == newInstId
|
1 -> v.noteVal == newNote && v.instrumentId == newInstId
|
||||||
2 -> v.instrumentId == newInstId &&
|
2 -> v.instrumentId == newInstId &&
|
||||||
existInst.samplePtr == newInst.samplePtr &&
|
v.activeSamplePtr == newSmpPtr &&
|
||||||
existInst.sampleLength == newInst.sampleLength
|
v.activeSampleLength == newSmpLen
|
||||||
3 -> v.instrumentId == newInstId
|
3 -> v.instrumentId == newInstId
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -2254,6 +2329,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
v.bitcrusherHeld = src.bitcrusherHeld
|
v.bitcrusherHeld = src.bitcrusherHeld
|
||||||
v.overdriveAmp = src.overdriveAmp
|
v.overdriveAmp = src.overdriveAmp
|
||||||
v.sourceChannel = channel
|
v.sourceChannel = channel
|
||||||
|
// Active-sample snapshot must follow the foreground voice so the ghost's per-tick
|
||||||
|
// playback (samplingRate, loop bounds, auto-vibrato) keeps using the patch the
|
||||||
|
// foreground had bound — not the base instrument it would otherwise re-derive.
|
||||||
|
v.activeSamplePtr = src.activeSamplePtr
|
||||||
|
v.activeSampleLength = src.activeSampleLength
|
||||||
|
v.activeSamplePlayStart = src.activeSamplePlayStart
|
||||||
|
v.activeSampleLoopStart = src.activeSampleLoopStart
|
||||||
|
v.activeSampleLoopEnd = src.activeSampleLoopEnd
|
||||||
|
v.activeSamplingRate = src.activeSamplingRate
|
||||||
|
v.activeSampleDetune = src.activeSampleDetune
|
||||||
|
v.activeLoopMode = src.activeLoopMode
|
||||||
|
v.activeVibratoSpeed = src.activeVibratoSpeed
|
||||||
|
v.activeVibratoSweep = src.activeVibratoSweep
|
||||||
|
v.activeVibratoDepth = src.activeVibratoDepth
|
||||||
|
v.activeVibratoRate = src.activeVibratoRate
|
||||||
|
v.activeVibratoWaveform = src.activeVibratoWaveform
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2433,7 +2524,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
0x0000 -> {
|
0x0000 -> {
|
||||||
if (row.instrment != 0) {
|
if (row.instrment != 0) {
|
||||||
voice.instrumentId = row.instrment
|
voice.instrumentId = row.instrment
|
||||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
// Re-resolve the patch on the new instrument against the voice's
|
||||||
|
// current note so multi-sample IT/XM instruments pick up the right
|
||||||
|
// sample (and per-patch DNV) even on a continue row. samplePos is
|
||||||
|
// preserved — Schism csf_instrument_change reloads sample geometry
|
||||||
|
// but does not retrigger.
|
||||||
|
val newInst = instruments[voice.instrumentId]
|
||||||
|
val newPatch = newInst.resolvePatch(voice.noteVal, voice.noteVolume)
|
||||||
|
applyActiveSample(voice, newInst, newPatch)
|
||||||
|
val seedVol = rowVolumeFromDefault(newInst, newPatch)
|
||||||
voice.noteVolume = seedVol
|
voice.noteVolume = seedVol
|
||||||
voice.rowVolume = seedVol
|
voice.rowVolume = seedVol
|
||||||
voice.keyOff = false
|
voice.keyOff = false
|
||||||
@@ -2470,7 +2569,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// and the bump persisted through the following vibrato rows).
|
// and the bump persisted through the following vibrato rows).
|
||||||
if (row.instrment != 0) {
|
if (row.instrment != 0) {
|
||||||
voice.instrumentId = row.instrment
|
voice.instrumentId = row.instrment
|
||||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
// Porta + inst-byte: re-resolve the patch on the new instrument
|
||||||
|
// against the voice's current note (Schism evaluates the keyboard
|
||||||
|
// table at csf_instrument_change time; the porta target row.note
|
||||||
|
// is only the slide destination, not the sample selector).
|
||||||
|
val newInst = instruments[voice.instrumentId]
|
||||||
|
val newPatch = newInst.resolvePatch(voice.noteVal, voice.noteVolume)
|
||||||
|
applyActiveSample(voice, newInst, newPatch)
|
||||||
|
val seedVol = rowVolumeFromDefault(newInst, newPatch)
|
||||||
voice.noteVolume = seedVol
|
voice.noteVolume = seedVol
|
||||||
voice.rowVolume = seedVol
|
voice.rowVolume = seedVol
|
||||||
voice.keyOff = false
|
voice.keyOff = false
|
||||||
@@ -2609,7 +2715,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
voice.basePitch = voice.noteVal
|
voice.basePitch = voice.noteVal
|
||||||
voice.amigaPeriod = -1.0 // reseed on next per-tick slide
|
voice.amigaPeriod = -1.0 // reseed on next per-tick slide
|
||||||
voice.linearFreq = -1.0
|
voice.linearFreq = -1.0
|
||||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
|
||||||
} else {
|
} else {
|
||||||
voice.slideMode = 1; voice.slideArg = -arg
|
voice.slideMode = 1; voice.slideArg = -arg
|
||||||
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
|
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
|
||||||
@@ -2628,7 +2734,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
voice.basePitch = voice.noteVal
|
voice.basePitch = voice.noteVal
|
||||||
voice.amigaPeriod = -1.0
|
voice.amigaPeriod = -1.0
|
||||||
voice.linearFreq = -1.0
|
voice.linearFreq = -1.0
|
||||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
|
||||||
} else {
|
} else {
|
||||||
voice.slideMode = 2; voice.slideArg = arg
|
voice.slideMode = 2; voice.slideArg = arg
|
||||||
voice.amigaPeriod = -1.0
|
voice.amigaPeriod = -1.0
|
||||||
@@ -2741,12 +2847,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EffectOp.OP_O -> {
|
EffectOp.OP_O -> {
|
||||||
|
// Sample-offset O: clamps into the active sample's loop region when an O$xx
|
||||||
|
// value lands past loopEnd. Reads from the patch-aware active-sample view.
|
||||||
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
|
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
|
||||||
val inst = instruments[voice.instrumentId]
|
|
||||||
var off = arg
|
var off = arg
|
||||||
if ((inst.loopMode and 3) != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
|
if ((voice.activeLoopMode and 3) != 0 &&
|
||||||
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
|
voice.activeSampleLoopEnd > voice.activeSampleLoopStart &&
|
||||||
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen)
|
off > voice.activeSampleLoopEnd) {
|
||||||
|
val loopLen = (voice.activeSampleLoopEnd - voice.activeSampleLoopStart).coerceAtLeast(1)
|
||||||
|
off = voice.activeSampleLoopStart + ((off - voice.activeSampleLoopStart) % loopLen)
|
||||||
}
|
}
|
||||||
voice.samplePos = off.toDouble()
|
voice.samplePos = off.toDouble()
|
||||||
}
|
}
|
||||||
@@ -2837,7 +2946,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
voice.basePitch = voice.noteVal
|
voice.basePitch = voice.noteVal
|
||||||
voice.amigaPeriod = -1.0
|
voice.amigaPeriod = -1.0
|
||||||
voice.linearFreq = -1.0
|
voice.linearFreq = -1.0
|
||||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
|
||||||
}
|
}
|
||||||
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
|
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
|
||||||
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
|
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
|
||||||
@@ -2905,7 +3014,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
for (vi in 0 until ts.voices.size) {
|
for (vi in 0 until ts.voices.size) {
|
||||||
val voice = ts.voices[vi]
|
val voice = ts.voices[vi]
|
||||||
if (!voice.active && voice.noteDelayTick < 0) continue
|
if (!voice.active && voice.noteDelayTick < 0) continue
|
||||||
val inst = instruments[voice.instrumentId]
|
var inst = instruments[voice.instrumentId]
|
||||||
|
|
||||||
// Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume
|
// Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume
|
||||||
// alone — IT's note cut stops the sample, it doesn't reset chan->global_volume.
|
// alone — IT's note cut stops the sample, it doesn't reset chan->global_volume.
|
||||||
@@ -2921,6 +3030,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
maybeSpawnBackgroundForNNA(ts, voice, vi)
|
maybeSpawnBackgroundForNNA(ts, voice, vi)
|
||||||
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
|
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
|
||||||
voice.noteDelayTick = -1
|
voice.noteDelayTick = -1
|
||||||
|
// triggerNote may have swapped in a new instrument; re-bind so the rest of this
|
||||||
|
// tick's per-voice work (playbackRate at L3090, envelope/fadeout/auto-vibrato)
|
||||||
|
// uses the instrument that just fired, not the one the voice held on entry. On a
|
||||||
|
// never-triggered voice the stale binding is instruments[0] (samplingRate 0),
|
||||||
|
// which would zero playbackRate and freeze the sample — the "first note on a
|
||||||
|
// fresh channel via S$Dx is silent" bug.
|
||||||
|
inst = instruments[voice.instrumentId]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!voice.active) {
|
if (!voice.active) {
|
||||||
@@ -3059,7 +3175,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
if (voice.retrigCounter >= voice.retrigInterval) {
|
if (voice.retrigCounter >= voice.retrigInterval) {
|
||||||
voice.retrigCounter = 0
|
voice.retrigCounter = 0
|
||||||
val retrigInst = instruments[voice.instrumentId]
|
val retrigInst = instruments[voice.instrumentId]
|
||||||
voice.samplePos = retrigInst.samplePlayStart.toDouble()
|
// Use the voice's active sample's playStart (patch-aware) — without this
|
||||||
|
// a Q retrigger on a multi-sample instrument would jump to the base sample
|
||||||
|
// even though the voice is bound to a patch.
|
||||||
|
voice.samplePos = voice.activeSamplePlayStart.toDouble()
|
||||||
voice.keyOff = false
|
voice.keyOff = false
|
||||||
voice.envIndex = 0; voice.envTimeSec = 0.0
|
voice.envIndex = 0; voice.envTimeSec = 0.0
|
||||||
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
|
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
|
||||||
@@ -3087,7 +3206,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
else 0
|
else 0
|
||||||
|
|
||||||
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
|
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
|
||||||
voice.playbackRate = computePlaybackRate(inst, finalPitch)
|
voice.playbackRate = computePlaybackRate(voice, finalPitch)
|
||||||
|
|
||||||
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
|
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
|
||||||
// Schism filters.c:80-86 computes `cutoff_used = chan->cutoff * (flt_modifier+256)/256`
|
// Schism filters.c:80-86 computes `cutoff_used = chan->cutoff * (flt_modifier+256)/256`
|
||||||
@@ -3191,7 +3310,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
|
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
|
||||||
else 0
|
else 0
|
||||||
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
|
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
|
||||||
bg.playbackRate = computePlaybackRate(inst, finalPitch)
|
bg.playbackRate = computePlaybackRate(bg, finalPitch)
|
||||||
// Filter-mode pf envelope: same scaling rule as foreground.
|
// Filter-mode pf envelope: same scaling rule as foreground.
|
||||||
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
|
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
|
||||||
val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254
|
val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254
|
||||||
@@ -3683,6 +3802,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
var autoVibPhase = 0 // 8-bit phase counter
|
var autoVibPhase = 0 // 8-bit phase counter
|
||||||
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
|
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
|
||||||
|
|
||||||
|
// Active-sample view — snapshot of either the base instrument's sample-scope
|
||||||
|
// fields or, when an Ixmp patch covers (noteVal, rowVolume) at trigger time,
|
||||||
|
// the matching TaudInstPatch overlay. Per-tick and per-row code reads from
|
||||||
|
// these instead of `inst.*` so multi-sample (IT keyboard table) instruments
|
||||||
|
// play the correct sample for the triggered note. Snapshotted by triggerNote
|
||||||
|
// and the equivalent paths (Q retrigger, NNA ghosting).
|
||||||
|
var activeSamplePtr = 0
|
||||||
|
var activeSampleLength = 0
|
||||||
|
var activeSamplePlayStart = 0
|
||||||
|
var activeSampleLoopStart = 0
|
||||||
|
var activeSampleLoopEnd = 0
|
||||||
|
var activeSamplingRate = 0
|
||||||
|
var activeSampleDetune = 0 // signed 4096-TET
|
||||||
|
var activeLoopMode = 0 // bits 0-1 = direction, bit 2 = sustain (matches inst byte 14)
|
||||||
|
var activeVibratoSpeed = 0
|
||||||
|
var activeVibratoSweep = 0
|
||||||
|
var activeVibratoDepth = 0
|
||||||
|
var activeVibratoRate = 0
|
||||||
|
var activeVibratoWaveform = 0 // bits 0-2 only
|
||||||
|
val activeSampleLoopSustain: Boolean get() = (activeLoopMode and 0x04) != 0
|
||||||
|
|
||||||
// NES 2A03 DMC counter for INTERP_NES_DPCM (interpolation mode 5).
|
// NES 2A03 DMC counter for INTERP_NES_DPCM (interpolation mode 5).
|
||||||
// 7-bit unsigned (0..127), slews ±2 per output sample as the sigma-delta
|
// 7-bit unsigned (0..127), slews ±2 per output sample as the sigma-delta
|
||||||
// bitstream is generated on the fly. Seeded to mid-rail (63) on every
|
// bitstream is generated on the fly. Seeded to mid-rail (63) on every
|
||||||
@@ -4094,6 +4234,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Clear funk-repeat (S$Fx) state only — per-voice run-state plus the per-instrument
|
||||||
|
* loop-inversion masks — without touching tempo / volume / position. taut calls this on
|
||||||
|
* every fresh play-from-start so accumulated inversions and a stale funkSpeed don't bleed
|
||||||
|
* from a prior session into the replay; full resetParams would also clobber bpm / tickRate /
|
||||||
|
* volume, which a replay must preserve. Masks still persist across a natural song loop. */
|
||||||
|
fun resetFunkState() {
|
||||||
|
trackerState?.voices?.forEach {
|
||||||
|
it.funkSpeed = 0
|
||||||
|
it.funkAccumulator = 0
|
||||||
|
it.funkWritePos = 0
|
||||||
|
}
|
||||||
|
parent.instruments.forEach { it.funkMask = null }
|
||||||
|
}
|
||||||
|
|
||||||
fun purgeQueue() {
|
fun purgeQueue() {
|
||||||
pcmQueue.clear()
|
pcmQueue.clear()
|
||||||
if (isPcmMode) {
|
if (isPcmMode) {
|
||||||
@@ -4152,6 +4306,41 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
|
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Ixmp "extra sample" patch — overlays sample-scope state on a base instrument
|
||||||
|
* for a (noteVal, rowVolume) rectangle. See terranmon.txt "Ixmp. Instrument extra
|
||||||
|
* samples" for the on-wire layout. Envelopes, fadeout, NNA / DCT / DCA, filter,
|
||||||
|
* pitch-pan, IGV and other instrument-scope fields stay on the base TaudInst —
|
||||||
|
* only the fields below override.
|
||||||
|
*
|
||||||
|
* Sentinels: defaultPan == 0xFF, defaultNoteVolume == 0, vibratoWaveform == 0xFF
|
||||||
|
* all mean "inherit the base instrument's value". samplingRate == 0 would silence
|
||||||
|
* the patch (same semantics as base inst), so converters must always supply it.
|
||||||
|
*/
|
||||||
|
data class TaudInstPatch(
|
||||||
|
val pitchStart: Int,
|
||||||
|
val pitchEnd: Int,
|
||||||
|
val volumeStart: Int,
|
||||||
|
val volumeEnd: Int,
|
||||||
|
val samplePtr: Int,
|
||||||
|
val sampleLength: Int,
|
||||||
|
val playStart: Int,
|
||||||
|
val loopStart: Int,
|
||||||
|
val loopEnd: Int,
|
||||||
|
val samplingRate: Int,
|
||||||
|
val sampleDetune: Int, // signed 4096-TET
|
||||||
|
val loopMode: Int, // matches base inst byte 14 (bits 0-1 = mode, bit 2 = sustain)
|
||||||
|
val defaultPan: Int, // 0..255; 0xFF = no override
|
||||||
|
val defaultNoteVolume: Int, // 0..255 IT-scaled; 0 = no override
|
||||||
|
val vibratoSpeed: Int,
|
||||||
|
val vibratoSweep: Int,
|
||||||
|
val vibratoDepth: Int,
|
||||||
|
val vibratoRate: Int,
|
||||||
|
val vibratoWaveform: Int // 0..7; 0xFF = no override
|
||||||
|
) {
|
||||||
|
val sampleLoopSustain: Boolean get() = (loopMode and 0x04) != 0
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 256-byte instrument record (terranmon.txt:2001+).
|
* 256-byte instrument record (terranmon.txt:2001+).
|
||||||
*
|
*
|
||||||
@@ -4276,12 +4465,31 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
|||||||
// Byte 196 is the new "default note volume" field — see triggerNote.
|
// Byte 196 is the new "default note volume" field — see triggerNote.
|
||||||
private val reserved = ByteArray(59)
|
private val reserved = ByteArray(59)
|
||||||
|
|
||||||
|
// Optional Ixmp "extra sample" patches — non-null when an Ixmp block was uploaded
|
||||||
|
// for this instrument. Patches are scanned in order at trigger time; first hit on
|
||||||
|
// (noteVal, rowVolume) wins (overlapping rectangles are INVALID per spec).
|
||||||
|
var extraPatches: Array<TaudInstPatch>? = null
|
||||||
|
|
||||||
|
/** Walk [extraPatches] and return the first patch whose pitch+volume rectangle
|
||||||
|
* contains the given trigger. Returns null when no patches are bound or none match. */
|
||||||
|
fun resolvePatch(noteVal: Int, rowVolume: Int): TaudInstPatch? {
|
||||||
|
val patches = extraPatches ?: return null
|
||||||
|
for (p in patches) {
|
||||||
|
if (noteVal in p.pitchStart..p.pitchEnd &&
|
||||||
|
rowVolume in p.volumeStart..p.volumeEnd) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
|
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
|
||||||
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
|
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
|
||||||
// Mask is sized for the loop length at allocation time; if the loop bounds change
|
// Mask is sized for the loop length at allocation time; if the loop bounds change
|
||||||
// (e.g. a new song reuses this instrument slot with different sample data) the old
|
// (e.g. a new song reuses this instrument slot with different sample data) the old
|
||||||
// mask is stale and must be discarded — otherwise indexing past its end crashes the
|
// mask is stale and must be discarded — otherwise indexing past its end crashes the
|
||||||
// render thread with ArrayIndexOutOfBoundsException.
|
// render thread with ArrayIndexOutOfBoundsException.
|
||||||
|
// Note: with Ixmp patches active the mask still indexes the BASE instrument's loop
|
||||||
|
// region, not the active patch's. Funk repeat (S$Fx) is a PT2 effect and doesn't
|
||||||
|
// coexist with multi-sample IT/XM instruments in practice.
|
||||||
var funkMask: ByteArray? = null
|
var funkMask: ByteArray? = null
|
||||||
fun toggleFunkBit(loopOffset: Int) {
|
fun toggleFunkBit(loopOffset: Int) {
|
||||||
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
|
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
|
||||||
|
|||||||
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.
|
||||||
@@ -16,6 +16,11 @@ Limits:
|
|||||||
- Multi-sample instruments use the sample selected by the *current
|
- Multi-sample instruments use the sample selected by the *current
|
||||||
note's* keymap entry; the converter materialises one Taud
|
note's* keymap entry; the converter materialises one Taud
|
||||||
instrument slot per (XM instrument, sample-in-instrument) pair.
|
instrument slot per (XM instrument, sample-in-instrument) pair.
|
||||||
|
(Note: it2taud uses the alternate Ixmp project-data extension
|
||||||
|
instead — one Taud instrument per IT instrument, plus an Ixmp
|
||||||
|
patch list for the keyboard mapping. XM could be retrofitted the
|
||||||
|
same way to conserve Taud instrument slots; deferred until any
|
||||||
|
real XM file actually hits the 255-slot cap.)
|
||||||
|
|
||||||
Pattern length policy:
|
Pattern length policy:
|
||||||
- XM patterns ≤ 64 rows → 1 Taud cue with the LEN ($02xx)
|
- XM patterns ≤ 64 rows → 1 Taud cue with the LEN ($02xx)
|
||||||
|
|||||||
Reference in New Issue
Block a user