mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 06:14:04 +09:00
Compare commits
63 Commits
db3ffdedb6
...
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 | ||
|
|
f863f6230d | ||
|
|
d8ac08162c | ||
|
|
e24870ce07 | ||
|
|
10e577699f | ||
|
|
01cc5c90ee | ||
|
|
051177f7f7 | ||
|
|
5f873fa2d1 | ||
|
|
a7db53e81c | ||
|
|
8d473c223c | ||
|
|
5a25d394b9 | ||
|
|
15587a0d76 | ||
|
|
a716807b36 | ||
|
|
b103e3c690 | ||
|
|
7edc3e32b1 | ||
|
|
6db6a2e7ed | ||
|
|
0d564d5f82 | ||
|
|
6d20d346f5 | ||
|
|
de82435f6e | ||
|
|
054295fdab | ||
|
|
26303c63af | ||
|
|
2ff471a066 | ||
|
|
dfcc0c7729 | ||
|
|
4e7fe82690 | ||
|
|
13eaf1b999 | ||
|
|
6623ff62bc | ||
|
|
3c43aa8aa6 | ||
|
|
848ee491d1 | ||
|
|
eddd65fa13 | ||
|
|
1e2814af87 | ||
|
|
61a721d628 | ||
|
|
9723c33dfc | ||
|
|
065e586cd6 | ||
|
|
83d9cde0bd | ||
|
|
0b82d4b32c | ||
|
|
277693989b |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -62,11 +62,18 @@ tsvmman.pdf
|
|||||||
*.ilg
|
*.ilg
|
||||||
*.ind
|
*.ind
|
||||||
|
|
||||||
|
assets/disk0/tvdos/bin/tautfont.png
|
||||||
|
|
||||||
|
video_encoder/*
|
||||||
|
|
||||||
|
.idea/vcs.xml
|
||||||
|
|
||||||
|
# in-dev stuffs
|
||||||
assets/disk0/home/basic/*
|
assets/disk0/home/basic/*
|
||||||
assets/disk0/movtestimg/*.jpg
|
assets/disk0/movtestimg/*.jpg
|
||||||
assets/disk0/*.mov
|
assets/disk0/*.mov
|
||||||
assets/diskMediabin/*
|
assets/diskMediabin/*
|
||||||
|
assets/disk0/hopper/*
|
||||||
|
|
||||||
video_encoder/*
|
# TVDOS runtime caches (regenerated on the VM; never commit)
|
||||||
|
assets/disk0/tvdos/cache/
|
||||||
assets/disk0/tvdos/bin/tautfont.png
|
|
||||||
|
|||||||
127
CLAUDE.md
127
CLAUDE.md
@@ -116,6 +116,16 @@ Use the build scripts in `buildapp/`:
|
|||||||
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
||||||
- TVDOS filesystem uses custom format with specialised drivers
|
- TVDOS filesystem uses custom format with specialised drivers
|
||||||
|
|
||||||
|
### TSVM JavaScript Source Encoding
|
||||||
|
|
||||||
|
**Do not normalise `\uXXXX` or `\xXX` escapes in .js / .mjs files that run inside
|
||||||
|
TSVM.** TSVM's character set is not Unicode, and the JS string literal parser
|
||||||
|
behaves differently for raw bytes vs. escape sequences. Both forms appear in
|
||||||
|
existing code intentionally — leave each one as-is. When writing new content,
|
||||||
|
prefer raw UTF-8 characters in string literals (e.g. write the character `ù`
|
||||||
|
directly, rather than a `\uXXXX`-style escape) unless you are matching a
|
||||||
|
pattern already established in the surrounding code.
|
||||||
|
|
||||||
## Videotron2K
|
## Videotron2K
|
||||||
|
|
||||||
The Videotron2K is a specialised video display controller with:
|
The Videotron2K is a specialised video display controller with:
|
||||||
@@ -155,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
|
||||||
@@ -418,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
|
||||||
@@ -1,24 +1,181 @@
|
|||||||
graphics.setBackground(2,1,3);
|
graphics.setBackground(2,1,3)
|
||||||
graphics.resetPalette();
|
graphics.resetPalette()
|
||||||
|
const GL = require("gl")
|
||||||
|
const win = require("wintex")
|
||||||
|
const keysym = require("keysym")
|
||||||
|
|
||||||
function captureUserInput() {
|
function captureUserInput() {
|
||||||
sys.poke(-40, 1);
|
sys.poke(-40, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeyPushed(keyOrder) {
|
function getKeyPushed(keyOrder) {
|
||||||
return sys.peek(-41 - keyOrder);
|
return sys.peek(-41 - keyOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
let _fsh = {};
|
function readMousePos() {
|
||||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="));
|
let lx = sys.peek(-33) & 0xFF
|
||||||
_fsh.scrdim = con.getmaxyx();
|
let hx = sys.peek(-34) & 0xFF
|
||||||
_fsh.scrwidth = _fsh.scrdim[1];
|
let ly = sys.peek(-35) & 0xFF
|
||||||
_fsh.scrheight = _fsh.scrdim[0];
|
let hy = sys.peek(-36) & 0xFF
|
||||||
_fsh.brandName = "f\xb3Sh";
|
return [(hx << 8) | lx, (hy << 8) | ly]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMouseButtons() {
|
||||||
|
return sys.peek(-37) & 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if any of the eight key event buffer slots holds keycode `kc`.
|
||||||
|
function isKeyDown(kc) {
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if ((sys.peek(-41 - i) & 0xFF) === kc) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let _fsh = {}
|
||||||
|
|
||||||
|
// Config file path
|
||||||
|
_fsh.CONFIG_PATH = "A:/home/config/fshrc"
|
||||||
|
|
||||||
|
// Widget row caps (must match the loop bounds in draw())
|
||||||
|
_fsh.TODO_MAX_ROWS = 13 // todoWidget draws i = 0..12
|
||||||
|
_fsh.QA_MAX_ROWS = 22 // quickAccessWidget draws i = 0..21
|
||||||
|
_fsh.TODO_TEXT_WIDTH = 24 // visible characters per todo row
|
||||||
|
_fsh.QA_LABEL_WIDTH = 24 // visible characters per QA label
|
||||||
|
_fsh.QA_CMD_WIDTH = 60 // command path field width in dialog
|
||||||
|
|
||||||
|
// Highlight foreground for keyboard focus on widget lists. The background
|
||||||
|
// stays transparent (255) so the wallpaper continues to show through.
|
||||||
|
_fsh.HL_FG = 230
|
||||||
|
_fsh.HL_BG = 255
|
||||||
|
|
||||||
|
// Default Quick Access entries when fshrc is missing or empty
|
||||||
|
_fsh.DEFAULT_QA = [
|
||||||
|
["Files", "/tvdos/bin/zsh.js"],
|
||||||
|
["Editor", "/tvdos/bin/edit.js"],
|
||||||
|
["BASIC", "/tbas/basic.js"],
|
||||||
|
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Mouse button bits (MMIO[36] layout per IOSpace.kt)
|
||||||
|
_fsh.MB_LEFT = 1
|
||||||
|
_fsh.MB_RIGHT = 2
|
||||||
|
|
||||||
|
// Current focus: null or {widgetId: string, index: number}.
|
||||||
|
// Index uses the same convention as hitTest: 0..length-1 are entries,
|
||||||
|
// `length` is the "+ Click to add" row.
|
||||||
|
_fsh.focus = null
|
||||||
|
|
||||||
|
// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}.
|
||||||
|
// Returns null for both arrays when input is empty/whitespace.
|
||||||
|
_fsh.parseConfig = function(text) {
|
||||||
|
let todos = []
|
||||||
|
let qa = []
|
||||||
|
let section = null
|
||||||
|
if (!text) return {todos: todos, qa: qa}
|
||||||
|
let lines = text.split("\n")
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i]
|
||||||
|
// strip trailing \r if any
|
||||||
|
if (line.length && line.charCodeAt(line.length - 1) === 13) {
|
||||||
|
line = line.substring(0, line.length - 1)
|
||||||
|
}
|
||||||
|
if (line.length === 0) continue
|
||||||
|
if (line.charAt(0) === "[") {
|
||||||
|
let close = line.indexOf("]")
|
||||||
|
if (close > 0) {
|
||||||
|
let name = line.substring(1, close).trim().toUpperCase()
|
||||||
|
if (name === "TODO" || name === "QUICK_ACCESS") section = name
|
||||||
|
else section = null // unknown section: ignore until next header
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (section === "TODO") {
|
||||||
|
if (line.length < 2) continue
|
||||||
|
let marker = line.charAt(0)
|
||||||
|
if ((marker === "+" || marker === "-") && line.charAt(1) === " ") {
|
||||||
|
todos.push([line.substring(2), marker === "+"])
|
||||||
|
}
|
||||||
|
} else if (section === "QUICK_ACCESS") {
|
||||||
|
let comma = line.indexOf(",")
|
||||||
|
if (comma <= 0) continue // need a non-empty label
|
||||||
|
let label = line.substring(0, comma)
|
||||||
|
let cmd = line.substring(comma + 1)
|
||||||
|
qa.push([label, cmd])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {todos: todos, qa: qa}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build fshrc text from in-memory model. Inverse of parseConfig.
|
||||||
|
_fsh.serializeConfig = function(todos, qa) {
|
||||||
|
let out = "[TODO]\n"
|
||||||
|
for (let i = 0; i < todos.length; i++) {
|
||||||
|
let t = todos[i]
|
||||||
|
out += (t[1] ? "+ " : "- ") + t[0] + "\n"
|
||||||
|
}
|
||||||
|
out += "\n[QUICK_ACCESS]\n"
|
||||||
|
for (let i = 0; i < qa.length; i++) {
|
||||||
|
out += qa[i][0] + "," + qa[i][1] + "\n"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries.
|
||||||
|
// Falls back to defaults on missing/empty/malformed file.
|
||||||
|
_fsh.loadConfig = function() {
|
||||||
|
let f = files.open(_fsh.CONFIG_PATH)
|
||||||
|
let parsed = {todos: [], qa: []}
|
||||||
|
if (f.exists) {
|
||||||
|
try {
|
||||||
|
parsed = _fsh.parseConfig(f.sread())
|
||||||
|
} catch (e) {
|
||||||
|
serial.printerr("fsh.loadConfig: parse failed: " + e)
|
||||||
|
parsed = {todos: [], qa: []}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
todoWidget.todoList = parsed.todos
|
||||||
|
quickAccessWidget.entries = (parsed.qa.length > 0)
|
||||||
|
? parsed.qa
|
||||||
|
: _fsh.DEFAULT_QA.slice() // copy so saves don't mutate the constant
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the current in-memory todos + QA entries to fshrc.
|
||||||
|
_fsh.saveConfig = function() {
|
||||||
|
try {
|
||||||
|
let f = files.open(_fsh.CONFIG_PATH)
|
||||||
|
if (!f.exists) f.mkFile()
|
||||||
|
f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries))
|
||||||
|
} catch (e) {
|
||||||
|
serial.printerr("fsh.saveConfig: write failed: " + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Map (mouse char x, mouse char y) to a row index for a widget drawn at
|
||||||
|
// (xoff, yoff) with `length` existing entries and `maxRows` total rows.
|
||||||
|
// Returns null / {kind:"add"} / {kind:"item", index: i}.
|
||||||
|
_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) {
|
||||||
|
// Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1).
|
||||||
|
// Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth.
|
||||||
|
// Allow clicks anywhere on the row's char cells (icon + text region).
|
||||||
|
let relY = charY - yoff - 2
|
||||||
|
if (relY < 0 || relY >= maxRows) return null
|
||||||
|
if (charX < xoff || charX > xoff + 1 + textWidth) return null
|
||||||
|
if (relY < length) return {kind: "item", index: relY}
|
||||||
|
if (relY === length) return {kind: "add"}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="))
|
||||||
|
_fsh.scrdim = con.getmaxyx()
|
||||||
|
_fsh.scrwidth = _fsh.scrdim[1]
|
||||||
|
_fsh.scrheight = _fsh.scrdim[0]
|
||||||
|
_fsh.brandName = "f\xb3Sh"
|
||||||
_fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob(
|
_fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob(
|
||||||
"H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA="
|
"H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA="
|
||||||
)));
|
)))
|
||||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"];
|
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"]
|
||||||
|
|
||||||
_fsh.drawWallpaper = function() {
|
_fsh.drawWallpaper = function() {
|
||||||
let wp = files.open("A:/home/wall.bytes")
|
let wp = files.open("A:/home/wall.bytes")
|
||||||
@@ -28,85 +185,85 @@ _fsh.drawWallpaper = function() {
|
|||||||
wp.pread(b, 250880, 0)
|
wp.pread(b, 250880, 0)
|
||||||
dma.ramToFrame(b, 0, 250880)
|
dma.ramToFrame(b, 0, 250880)
|
||||||
sys.free(b)
|
sys.free(b)
|
||||||
};
|
}
|
||||||
|
|
||||||
_fsh.drawTitlebar = function(titletext) {
|
_fsh.drawTitlebar = function(titletext) {
|
||||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14);
|
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14)
|
||||||
if (titletext === undefined || titletext.length == 0) {
|
if (titletext === undefined || titletext.length == 0) {
|
||||||
con.move(1,1);
|
con.move(1,1)
|
||||||
print(" ".repeat(_fsh.scrwidth));
|
print(" ".repeat(_fsh.scrwidth))
|
||||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0);
|
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
con.color_pair(240, 255);
|
con.color_pair(240, 255)
|
||||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14);
|
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14)
|
||||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2);
|
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2)
|
||||||
print(titletext);
|
print(titletext)
|
||||||
}
|
}
|
||||||
con.color_pair(254, 255);
|
con.color_pair(254, 255)
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
_fsh.Widget = function(id, w, h) {
|
_fsh.Widget = function(id, w, h) {
|
||||||
this.identifier = id;
|
this.identifier = id
|
||||||
this.width = w;
|
this.width = w
|
||||||
this.height = h;
|
this.height = h
|
||||||
|
|
||||||
if (!this.identifier) {
|
if (!this.identifier) {
|
||||||
this.identifier = "";
|
this.identifier = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
//this.update = function() {};
|
//this.update = function() {}
|
||||||
/**
|
/**
|
||||||
* Params charXoff and charYoff are ZERO-BASED!
|
* Params charXoff and charYoff are ZERO-BASED!
|
||||||
*/
|
*/
|
||||||
this.draw = function(charXoff, charYoff) {};
|
this.draw = function(charXoff, charYoff) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
_fsh.widgets = {}
|
_fsh.widgets = {}
|
||||||
_fsh.registerNewWidget = function(widget) {
|
_fsh.registerNewWidget = function(widget) {
|
||||||
_fsh.widgets[widget.identifier] = widget;
|
_fsh.widgets[widget.identifier] = widget
|
||||||
}
|
}
|
||||||
|
|
||||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2);
|
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2)
|
||||||
clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob(
|
clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob(
|
||||||
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
||||||
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
||||||
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
||||||
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
||||||
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
||||||
))));
|
))))
|
||||||
|
|
||||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"));
|
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"))
|
||||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"];
|
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"]
|
||||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "];
|
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "]
|
||||||
clockWidget.draw = function(charXoff, charYoff) {
|
clockWidget.draw = function(charXoff, charYoff) {
|
||||||
con.color_pair(254, 255);
|
con.color_pair(254, 255)
|
||||||
let xoff = charXoff * 7;
|
let xoff = charXoff * 7
|
||||||
let yoff = charYoff * 14 + 3;
|
let yoff = charYoff * 14 + 3
|
||||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0);
|
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0)
|
||||||
let mins = timeInMinutes % 60;
|
let mins = timeInMinutes % 60
|
||||||
let hours = ((timeInMinutes / 60)|0) % 24;
|
let hours = ((timeInMinutes / 60)|0) % 24
|
||||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120;
|
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120
|
||||||
let visualDay = (ordinalDay % 30) + 1;
|
let visualDay = (ordinalDay % 30) + 1
|
||||||
let months = ((timeInMinutes / (60*24*30))|0) % 4;
|
let months = ((timeInMinutes / (60*24*30))|0) % 4
|
||||||
let dayName = ordinalDay % 7; // 0 for Mondag
|
let dayName = ordinalDay % 7 // 0 for Mondag
|
||||||
if (ordinalDay == 119) dayName = 7; // Verddag
|
if (ordinalDay == 119) dayName = 7 // Verddag
|
||||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125;
|
let years = ((timeInMinutes / (60*24*30*120))|0) + 125
|
||||||
// draw timepiece
|
// draw timepiece
|
||||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1);
|
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1)
|
||||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1);
|
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1)
|
||||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1);
|
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1)
|
||||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1);
|
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1)
|
||||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1);
|
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1)
|
||||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1);
|
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1)
|
||||||
// print month and date
|
// print month and date
|
||||||
con.move(1 + charYoff, 17 + charXoff);
|
con.move(1 + charYoff, 17 + charXoff)
|
||||||
print(clockWidget.monthNames[months]+" "+visualDay);
|
print(clockWidget.monthNames[months]+" "+visualDay)
|
||||||
// print year and dayname
|
// print year and dayname
|
||||||
con.move(2 + charYoff, 17 + charXoff);
|
con.move(2 + charYoff, 17 + charXoff)
|
||||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName]);
|
print("\xE7"+years+" "+clockWidget.dayNames[dayName])
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6)
|
let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6)
|
||||||
@@ -171,70 +328,284 @@ calendarWidget.draw = function(charXoff, charYoff) {
|
|||||||
let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10)
|
let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10)
|
||||||
todoWidget.todoList = [["Hello, world!", true]]
|
todoWidget.todoList = [["Hello, world!", true]]
|
||||||
todoWidget.draw = function(charXoff, charYoff) {
|
todoWidget.draw = function(charXoff, charYoff) {
|
||||||
|
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier)
|
||||||
|
? _fsh.focus.index : -1
|
||||||
|
|
||||||
con.color_pair(254, 255)
|
con.color_pair(254, 255)
|
||||||
let xoff = charXoff * 7
|
let xoff = charXoff * 7
|
||||||
let yoff = charYoff * 14 + 3
|
let yoff = charYoff * 14 + 3
|
||||||
|
|
||||||
con.move(charYoff, charXoff)
|
con.move(charYoff, charXoff)
|
||||||
print("========== TODO ==========")
|
print('\u00CD'.repeat(10)+" TODO "+'\u00CD'.repeat(10))
|
||||||
|
|
||||||
for (let i = 0; i <= 12; i++) {
|
for (let i = 0; i <= 12; i++) {
|
||||||
let list = todoWidget.todoList[i] || ["Click to add", null]
|
let list = todoWidget.todoList[i] || ["Click to add"+" ".repeat(_fsh.TODO_TEXT_WIDTH - 12), null]
|
||||||
|
let isFocused = (i === focusIndex)
|
||||||
|
|
||||||
if (list[1] === null) con.color_pair(249, 255)
|
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||||
|
else if (list[1] === null) con.color_pair(249, 255)
|
||||||
else con.color_pair(254, 255)
|
else con.color_pair(254, 255)
|
||||||
|
|
||||||
con.move(charYoff + i + 2, charXoff)
|
con.move(charYoff + i + 2, charXoff)
|
||||||
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
||||||
|
|
||||||
if (i > todoWidget.todoList.length) {
|
if (i > todoWidget.todoList.length) {
|
||||||
|
// Filler row \u2014 keep underscores but don't highlight (can't focus here)
|
||||||
|
con.color_pair(254, 255)
|
||||||
for (let k = 0; k < 24; k++) {
|
for (let k = 0; k < 24; k++) {
|
||||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
con.move(charYoff + i + 2, charXoff + 2)
|
con.move(charYoff + i + 2, charXoff + 2)
|
||||||
print(`${list[0]}`)
|
// Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row
|
||||||
|
let text = `${list[0]}`
|
||||||
|
if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH)
|
||||||
|
if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length)
|
||||||
|
print(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20)
|
let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20)
|
||||||
quickAccessWidget.entries = [
|
quickAccessWidget.entries = [ // TODO read from /home/config/fshrc
|
||||||
["Files", "/tvdos/bin/explorer.js"],
|
["Files", "/tvdos/bin/zfm.js"],
|
||||||
["Editor", "/tvdos/bin/edit.js"],
|
["Editor", "/tvdos/bin/edit.js"],
|
||||||
["BASIC", "/tbas/basic.js"],
|
["BASIC", "/tbas/basic.js"],
|
||||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
["DOS Shell", "/tvdos/bin/command.js -fancy"]
|
||||||
]
|
]
|
||||||
quickAccessWidget.draw = function(charXoff, charYoff) {
|
quickAccessWidget.draw = function(charXoff, charYoff) {
|
||||||
|
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier)
|
||||||
|
? _fsh.focus.index : -1
|
||||||
|
|
||||||
con.color_pair(254, 255)
|
con.color_pair(254, 255)
|
||||||
let xoff = charXoff * 7
|
let xoff = charXoff * 7
|
||||||
let yoff = charYoff * 14 + 3
|
let yoff = charYoff * 14 + 3
|
||||||
|
|
||||||
con.move(charYoff, charXoff)
|
con.move(charYoff, charXoff)
|
||||||
print("====== QUICK ACCESS ======")
|
print('\u00CD'.repeat(6)+" QUICK ACCESS "+'\u00CD'.repeat(6))
|
||||||
|
|
||||||
for (let i = 0; i <= 21; i++) {
|
for (let i = 0; i <= 21; i++) {
|
||||||
let list = quickAccessWidget.entries[i] || ["Click to add", null]
|
let list = quickAccessWidget.entries[i] || ["Click to add"+" ".repeat(_fsh.QA_LABEL_WIDTH - 12), null]
|
||||||
|
let isFocused = (i === focusIndex)
|
||||||
|
|
||||||
if (list[1] === null) con.color_pair(249, 255)
|
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||||
|
else if (list[1] === null) con.color_pair(249, 255)
|
||||||
else con.color_pair(254, 255)
|
else con.color_pair(254, 255)
|
||||||
|
|
||||||
con.move(charYoff + i + 2, charXoff)
|
con.move(charYoff + i + 2, charXoff)
|
||||||
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
||||||
|
|
||||||
if (i > quickAccessWidget.entries.length) {
|
if (i > quickAccessWidget.entries.length) {
|
||||||
|
con.color_pair(254, 255)
|
||||||
for (let k = 0; k < 24; k++) {
|
for (let k = 0; k < 24; k++) {
|
||||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
con.move(charYoff + i + 2, charXoff + 2)
|
con.move(charYoff + i + 2, charXoff + 2)
|
||||||
print(`${list[0]}`)
|
let text = `${list[0]}`
|
||||||
|
if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH)
|
||||||
|
if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length)
|
||||||
|
print(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
todoWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||||
|
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||||
|
_fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||||
|
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||||
|
_fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Re-render the whole shell. Use after a dialog closes (which clobbered
|
||||||
|
// the underlying char cells) or after execApp returns.
|
||||||
|
_fsh.redrawAll = function() {
|
||||||
|
con.color_pair(254, 255)
|
||||||
|
con.clear()
|
||||||
|
graphics.clearPixels(255)
|
||||||
|
graphics.clearPixels2(255)
|
||||||
|
graphics.setFramebufferScroll(0, 0)
|
||||||
|
_fsh.drawWallpaper()
|
||||||
|
_fsh.drawTitlebar()
|
||||||
|
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||||
|
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||||
|
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||||
|
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.openAddTodoDialog = function() {
|
||||||
|
let res = win.showDialog({
|
||||||
|
title: "New Todo",
|
||||||
|
fields: [{label: "Text:", initial: "", width: _fsh.TODO_TEXT_WIDTH}],
|
||||||
|
allowDelete: false
|
||||||
|
})
|
||||||
|
_fsh.redrawAll()
|
||||||
|
if (res.action !== "ok") return
|
||||||
|
let text = res.values[0].trim()
|
||||||
|
if (text.length === 0) return
|
||||||
|
if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return
|
||||||
|
todoWidget.todoList.push([text, false])
|
||||||
|
_fsh.saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.openEditTodoDialog = function(index) {
|
||||||
|
let entry = todoWidget.todoList[index]
|
||||||
|
if (!entry) return
|
||||||
|
let res = win.showDialog({
|
||||||
|
title: "Edit Todo",
|
||||||
|
fields: [{label: "Text:", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}],
|
||||||
|
allowDelete: true
|
||||||
|
})
|
||||||
|
_fsh.redrawAll()
|
||||||
|
if (res.action === "cancel") return
|
||||||
|
if (res.action === "delete") {
|
||||||
|
todoWidget.todoList.splice(index, 1)
|
||||||
|
_fsh.saveConfig()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let text = res.values[0].trim()
|
||||||
|
if (text.length === 0) return
|
||||||
|
todoWidget.todoList[index] = [text, entry[1]]
|
||||||
|
_fsh.saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.openAddQaDialog = function() {
|
||||||
|
let res = win.showDialog({
|
||||||
|
title: "New Quick Access",
|
||||||
|
fields: [
|
||||||
|
{label: "Label:", initial: "", width: _fsh.QA_LABEL_WIDTH},
|
||||||
|
{label: "Command:", initial: "", width: _fsh.QA_CMD_WIDTH}
|
||||||
|
],
|
||||||
|
allowDelete: false
|
||||||
|
})
|
||||||
|
_fsh.redrawAll()
|
||||||
|
if (res.action !== "ok") return
|
||||||
|
let label = res.values[0].trim()
|
||||||
|
let cmd = res.values[1].trim()
|
||||||
|
if (label.length === 0 || cmd.length === 0) return
|
||||||
|
if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return
|
||||||
|
quickAccessWidget.entries.push([label, cmd])
|
||||||
|
_fsh.saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.openEditQaDialog = function(index) {
|
||||||
|
let entry = quickAccessWidget.entries[index]
|
||||||
|
if (!entry) return
|
||||||
|
let res = win.showDialog({
|
||||||
|
title: "Edit Quick Access",
|
||||||
|
fields: [
|
||||||
|
{label: "Label:", initial: entry[0], width: _fsh.QA_LABEL_WIDTH},
|
||||||
|
{label: "Command:", initial: entry[1], width: _fsh.QA_CMD_WIDTH}
|
||||||
|
],
|
||||||
|
allowDelete: true
|
||||||
|
})
|
||||||
|
_fsh.redrawAll()
|
||||||
|
if (res.action === "cancel") return
|
||||||
|
if (res.action === "delete") {
|
||||||
|
quickAccessWidget.entries.splice(index, 1)
|
||||||
|
_fsh.saveConfig()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let label = res.values[0].trim()
|
||||||
|
let cmd = res.values[1].trim()
|
||||||
|
if (label.length === 0 || cmd.length === 0) return
|
||||||
|
quickAccessWidget.entries[index] = [label, cmd]
|
||||||
|
_fsh.saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.toggleTodoDone = function(index) {
|
||||||
|
let entry = todoWidget.todoList[index]
|
||||||
|
if (!entry) return
|
||||||
|
entry[1] = !entry[1]
|
||||||
|
_fsh.saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch a Quick Access entry. cmd is the verbatim string the user typed.
|
||||||
|
// We split on first space to derive a program path + args; if the path
|
||||||
|
// has no leading "/", we treat it as relative to the current drive.
|
||||||
|
_fsh.launchEntry = function(label, cmd) {
|
||||||
|
let firstSpace = cmd.indexOf(" ")
|
||||||
|
let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd
|
||||||
|
let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : ""
|
||||||
|
let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath
|
||||||
|
|
||||||
|
try {
|
||||||
|
let f = files.open(fullPath)
|
||||||
|
if (!f.exists) {
|
||||||
|
serial.printerr("fsh.launchEntry: not found: " + fullPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let code = f.sread()
|
||||||
|
let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : [])
|
||||||
|
|
||||||
|
// erase all pixels and draw wallpaper
|
||||||
|
con.reset_graphics()
|
||||||
|
con.clear()
|
||||||
|
graphics.clearPixels(255)
|
||||||
|
graphics.clearPixels2(255)
|
||||||
|
_fsh.drawWallpaper()
|
||||||
|
con.curs_set(1)
|
||||||
|
|
||||||
|
execApp(code, tokens)
|
||||||
|
} catch (e) {
|
||||||
|
serial.printerr("fsh.launchEntry: " + label + " failed: " + e)
|
||||||
|
}
|
||||||
|
con.curs_set(0)
|
||||||
|
graphics.setBackground(2,1,3)
|
||||||
|
graphics.resetPalette()
|
||||||
|
// Apps (e.g. zfm) may switch to graphics mode 0; restore mode 3 so the
|
||||||
|
// clock widget on framebuffer 2 is composited again.
|
||||||
|
graphics.setGraphicsMode(3)
|
||||||
|
_fsh.redrawAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout map: widget positions hard-coded to match the draw calls below.
|
||||||
|
_fsh.layouts = {
|
||||||
|
"com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null},
|
||||||
|
"com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which widget (if any) was hit by (charX, charY). Returns
|
||||||
|
// {widgetId, hit} or null.
|
||||||
|
_fsh.findHit = function(charX, charY) {
|
||||||
|
let ids = ["com.fsh.todo_list", "com.fsh.quick_access"]
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
let id = ids[i]
|
||||||
|
let layout = _fsh.layouts[id]
|
||||||
|
let widget = _fsh.widgets[id]
|
||||||
|
let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff)
|
||||||
|
if (hit) return {widgetId: id, hit: hit}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.dispatchLeft = function(widgetId, hit) {
|
||||||
|
if (hit.kind === "add") {
|
||||||
|
if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog()
|
||||||
|
else _fsh.openAddQaDialog()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// hit.kind === "item"
|
||||||
|
if (widgetId === "com.fsh.todo_list") {
|
||||||
|
_fsh.toggleTodoDone(hit.index)
|
||||||
|
} else {
|
||||||
|
let entry = quickAccessWidget.entries[hit.index]
|
||||||
|
if (entry) _fsh.launchEntry(entry[0], entry[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_fsh.dispatchRight = function(widgetId, hit) {
|
||||||
|
if (hit.kind !== "item") return
|
||||||
|
if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index)
|
||||||
|
else _fsh.openEditQaDialog(hit.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// change graphics mode and check if it's supported
|
// change graphics mode and check if it's supported
|
||||||
graphics.setGraphicsMode(3)
|
graphics.setGraphicsMode(3)
|
||||||
@@ -260,29 +631,130 @@ _fsh.drawWallpaper()
|
|||||||
_fsh.drawTitlebar()
|
_fsh.drawTitlebar()
|
||||||
|
|
||||||
|
|
||||||
// TEST
|
// Load persisted state before the first draw
|
||||||
con.move(2,1);
|
_fsh.loadConfig();
|
||||||
print("fSh is very much in-dev! Hit backspace to exit")
|
|
||||||
|
// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*,
|
||||||
|
// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js
|
||||||
|
// already uses 67 for Backspace (libGDX DEL); follow the same scheme here.
|
||||||
|
const KEY_ESC = keysym.ESCAPE
|
||||||
|
const KEY_ENTER = keysym.ENTER
|
||||||
|
const KEY_UP = keysym.UP
|
||||||
|
const KEY_DOWN = keysym.DOWN
|
||||||
|
const KEY_LEFT = keysym.LEFT
|
||||||
|
const KEY_RIGHT = keysym.RIGHT
|
||||||
|
const KEY_LSHIFT = keysym.SHIFT_LEFT
|
||||||
|
const KEY_RSHIFT = keysym.SHIFT_RIGHT
|
||||||
|
|
||||||
|
let prevButtons = 0
|
||||||
|
let prevMouseCharX = -1
|
||||||
|
let prevMouseCharY = -1
|
||||||
|
let keyLatch = {} // {keycode: true} while the key is held — debounces "just pressed"
|
||||||
|
|
||||||
// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
captureUserInput();
|
captureUserInput()
|
||||||
if (getKeyPushed(0) == 67) break;
|
|
||||||
|
|
||||||
_fsh.widgets["com.fsh.clock"].draw(25, 3);
|
// -- keyboard --
|
||||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8);
|
if (isKeyDown(KEY_ESC)) break;
|
||||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17);
|
|
||||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8);
|
|
||||||
|
|
||||||
sys.spin();sys.spin()
|
let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT)
|
||||||
|
let enterPressed = false
|
||||||
|
|
||||||
|
// Edge-detect each navigation key
|
||||||
|
function edge(kc) {
|
||||||
|
let down = isKeyDown(kc)
|
||||||
|
let was = !!keyLatch[kc]
|
||||||
|
keyLatch[kc] = down
|
||||||
|
return down && !was
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edge(KEY_ENTER)) enterPressed = true;
|
||||||
|
let navUp = edge(KEY_UP)
|
||||||
|
let navDown = edge(KEY_DOWN)
|
||||||
|
let navLeft = edge(KEY_LEFT)
|
||||||
|
let navRight = edge(KEY_RIGHT)
|
||||||
|
|
||||||
|
// -- mouse --
|
||||||
|
// MMIO returns VM-screen pixel coords (origin at the top-left of the framebuffer).
|
||||||
|
// Widget xoff/yoff are passed straight into con.move(y, x), which is 1-indexed, so
|
||||||
|
// we offset by +1 here. Without this the click registers one cell up-and-left from
|
||||||
|
// where the user's pointer is, because pixel 0 = con.move(1, 1).
|
||||||
|
let pos = readMousePos()
|
||||||
|
let charX = (pos[0] / 7 | 0) + 1
|
||||||
|
let charY = (pos[1] / 14 | 0) + 1
|
||||||
|
let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY)
|
||||||
|
prevMouseCharX = charX
|
||||||
|
prevMouseCharY = charY
|
||||||
|
|
||||||
|
let buttons = readMouseButtons()
|
||||||
|
let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0)
|
||||||
|
let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0)
|
||||||
|
prevButtons = buttons
|
||||||
|
|
||||||
|
// -- focus update --
|
||||||
|
if (navUp || navDown || navLeft || navRight) {
|
||||||
|
if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0}
|
||||||
|
if (navUp || navDown) {
|
||||||
|
let layout = _fsh.layouts[_fsh.focus.widgetId]
|
||||||
|
let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||||
|
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||||
|
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||||
|
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||||
|
let maxIdx = Math.min(length, maxRows - 1)
|
||||||
|
let next = _fsh.focus.index + (navDown ? 1 : -1)
|
||||||
|
if (next < 0) next = 0
|
||||||
|
if (next > maxIdx) next = maxIdx
|
||||||
|
_fsh.focus.index = next
|
||||||
|
} else {
|
||||||
|
// Left/right switches widget
|
||||||
|
let other = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||||
|
? "com.fsh.quick_access" : "com.fsh.todo_list"
|
||||||
|
let otherLength = (other === "com.fsh.todo_list")
|
||||||
|
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||||
|
let otherMaxRows = (other === "com.fsh.todo_list")
|
||||||
|
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||||
|
let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1)
|
||||||
|
_fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)}
|
||||||
|
}
|
||||||
|
} else if (mouseMoved) {
|
||||||
|
let h = _fsh.findHit(charX, charY)
|
||||||
|
_fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add"
|
||||||
|
? ((h.widgetId === "com.fsh.todo_list")
|
||||||
|
? todoWidget.todoList.length
|
||||||
|
: quickAccessWidget.entries.length)
|
||||||
|
: h.hit.index} : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- mouse click dispatch --
|
||||||
|
if (leftEdge) {
|
||||||
|
let h = _fsh.findHit(charX, charY)
|
||||||
|
if (h) _fsh.dispatchLeft(h.widgetId, h.hit)
|
||||||
|
} else if (rightEdge) {
|
||||||
|
let h = _fsh.findHit(charX, charY)
|
||||||
|
if (h) _fsh.dispatchRight(h.widgetId, h.hit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- keyboard dispatch (synthesise click at focus) --
|
||||||
|
if (enterPressed && _fsh.focus) {
|
||||||
|
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||||
|
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||||
|
let hit = (_fsh.focus.index < length)
|
||||||
|
? {kind: "item", index: _fsh.focus.index}
|
||||||
|
: (_fsh.focus.index === length ? {kind: "add"} : null)
|
||||||
|
if (hit) {
|
||||||
|
if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit)
|
||||||
|
else _fsh.dispatchLeft(_fsh.focus.widgetId, hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- redraw --
|
||||||
|
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||||
|
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||||
|
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||||
|
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||||
|
|
||||||
|
sys.spin(); sys.spin()
|
||||||
}
|
}
|
||||||
|
|
||||||
con.move(3,1);
|
con.reset_graphics()
|
||||||
con.color_pair(201,255);
|
con.clear()
|
||||||
print("cya!");
|
|
||||||
|
|
||||||
let konsht = 3412341241;
|
|
||||||
println(konsht);
|
|
||||||
|
|
||||||
let pppp = graphics.getCursorYX();
|
|
||||||
println(pppp.toString());
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* Hopper is a package manager for TSVM
|
|
||||||
* Created by CuriousTorvald on 2026-04-16
|
|
||||||
*/
|
|
||||||
|
|
||||||
println("Hopper - Package manager for TSVM")
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
let url="http:localhost/testnet/test.txt"
|
/*let url="https:raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per"
|
||||||
|
|
||||||
let file = files.open("B:\\"+url)
|
let file = files.open("B:\\"+url)
|
||||||
|
|
||||||
if (!file.exists) {
|
if (!file.exists) {
|
||||||
printerrln("No such URL: "+url)
|
printerrln("No such URL: "+url)
|
||||||
return 1
|
return 1
|
||||||
}
|
}*/
|
||||||
|
|
||||||
let text = file.sread()
|
let net = require("A:/tvdos/include/net.mjs")
|
||||||
|
let text = net.fetchText("https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per")
|
||||||
|
if (text === null) { printerrln("No such URL"); return 1 }
|
||||||
println(text)
|
println(text)
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ class PmemFSfile {
|
|||||||
// string representation (preferable)
|
// string representation (preferable)
|
||||||
if (typeof bytes === 'string' || bytes instanceof String) {
|
if (typeof bytes === 'string' || bytes instanceof String) {
|
||||||
this.data = bytes
|
this.data = bytes
|
||||||
|
this.length = bytes.length
|
||||||
}
|
}
|
||||||
// Javascript array OR JVM byte[]
|
// Javascript array OR JVM byte[]
|
||||||
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
||||||
this.bdata = bytes[i]
|
this.bdata = bytes
|
||||||
|
this.length = bytes.length
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw Error("Invalid type for directory")
|
throw Error("Invalid type for directory")
|
||||||
@@ -76,10 +78,10 @@ class PmemFSfile {
|
|||||||
|
|
||||||
dataAsBytes() {
|
dataAsBytes() {
|
||||||
if (this.bdata !== undefined) return this.bdata
|
if (this.bdata !== undefined) return this.bdata
|
||||||
this.bdata = new Int8Array(this.data.length)
|
this.bdata = new Uint8Array(this.data.length)
|
||||||
for (let i = 0; i < this.data.length; i++) {
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
let p = this.data.charCodeAt(i)
|
let p = this.data.charCodeAt(i)
|
||||||
this.bdata[i] = (p > 127) ? p - 255 : p
|
this.bdata[i] = p
|
||||||
}
|
}
|
||||||
return this.bdata
|
return this.bdata
|
||||||
}
|
}
|
||||||
@@ -164,16 +166,16 @@ class TVDOSFileDescriptor {
|
|||||||
|
|
||||||
constructor(path0, driverID) {
|
constructor(path0, driverID) {
|
||||||
if (path0.startsWith("$")) {
|
if (path0.startsWith("$")) {
|
||||||
let path1 = path0.substring(3)
|
let path1 = path0.replaceAll("/", "\\").substring(3)
|
||||||
let slashPos = path1.indexOf("/")
|
let slashPos = path1.indexOf("\\")
|
||||||
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
||||||
|
|
||||||
if (!files.reservedNames.includes(devName)) {
|
if (!files.reservedNames.includes(devName)) {
|
||||||
throw Error(`${devName} is not a valid device file`)
|
throw Error(`${devName} is not a valid device file`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this._driveLetter = undefined
|
this._driveLetter = '$'
|
||||||
this._path = path0
|
this._path = '\\' + path1
|
||||||
this._driverID = `DEV${devName}`
|
this._driverID = `DEV${devName}`
|
||||||
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
||||||
}
|
}
|
||||||
@@ -939,8 +941,9 @@ _TVDOS.DRV.FS.DEVTMP.bread = (fd) => {
|
|||||||
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
||||||
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
||||||
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
||||||
for (let i = 0; i < count - (offset || 0); i++) {
|
let off = offset || 0
|
||||||
sys.poke(ptr + i, String.charCodeAt(i + (offset || 0)))
|
for (let i = 0; i < count; i++) {
|
||||||
|
sys.poke(ptr + i, str.charCodeAt(off + i))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,6 +991,7 @@ _TVDOS.DRV.FS.DEVTMP.remove = (fd) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
_TVDOS.DRV.FS.DEVTMP.exists = (fd) => (_TVDOS.TMPFS[fd.path] !== undefined)
|
_TVDOS.DRV.FS.DEVTMP.exists = (fd) => (_TVDOS.TMPFS[fd.path] !== undefined)
|
||||||
|
_TVDOS.DRV.FS.DEVTMP.getFileLen = (fd) => (_TVDOS.TMPFS[fd.path].length)
|
||||||
|
|
||||||
Object.freeze(_TVDOS.DRV.FS.DEVTMP)
|
Object.freeze(_TVDOS.DRV.FS.DEVTMP)
|
||||||
|
|
||||||
@@ -1110,13 +1114,18 @@ inputwork.repeatCount = 0;
|
|||||||
* where:
|
* where:
|
||||||
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
|
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
|
||||||
* "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down)
|
* "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down)
|
||||||
* "mouse_down", pos-x, pos-y, 1 // yes there's only one mouse button :p
|
* "mouse_down", pos-x, pos-y, <button mask: 1=left, 2=right, 4=middle>, keycode0..keycode7
|
||||||
* "mouse_up", pos-x, pos-y, 0
|
* "mouse_up", pos-x, pos-y, <button mask of the released button>, keycode0..keycode7
|
||||||
* "mouse_move", pos-x, pos-y, <button down?>, oldpos-x, oldpos-y
|
* "mouse_move", pos-x, pos-y, <currently-held button mask>, oldpos-x, oldpos-y, keycode0..keycode7
|
||||||
|
* "mouse_wheel", pos-x, pos-y, <-1 for wheel up, +1 for wheel down>, keycode0..keycode7
|
||||||
|
*
|
||||||
|
* Button mask values come from MMIO[36] bits 0..2 (terranmon.txt:52-58). The wheel
|
||||||
|
* bits (6, 7) latch in hardware and clear on read, so a one-shot detent fires once.
|
||||||
|
* Every mouse event carries the currently-held key buffer (same shape as key_down)
|
||||||
|
* so handlers can detect modifiers like Shift+wheel via `event.includes(<keysym>)`.
|
||||||
*/
|
*/
|
||||||
input.withEvent = function(callback) {
|
input.withEvent = function(callback) {
|
||||||
|
|
||||||
// TODO mouse event
|
|
||||||
function arrayEq(a,b) {
|
function arrayEq(a,b) {
|
||||||
for (let i = 0; i < a.length; ++i) {
|
for (let i = 0; i < a.length; ++i) {
|
||||||
if (a[i] !== b[i]) return false;
|
if (a[i] !== b[i]) return false;
|
||||||
@@ -1137,7 +1146,33 @@ input.withEvent = function(callback) {
|
|||||||
|
|
||||||
sys.poke(-40, 255);
|
sys.poke(-40, 255);
|
||||||
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 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 mouse = [sys.peek(-33) | (sys.peek(-34) << 8), sys.peek(-35) | (sys.peek(-36) << 8), sys.peek(-37)];
|
let mx = (sys.peek(-33) & 0xFF) | ((sys.peek(-34) & 0xFF) << 8);
|
||||||
|
let my = (sys.peek(-35) & 0xFF) | ((sys.peek(-36) & 0xFF) << 8);
|
||||||
|
let mb = sys.peek(-37) & 0xFF; // bits 0..2 = L/R/M held, bit 6 = wheel up, bit 7 = wheel down
|
||||||
|
let mouse = [mx, my, mb];
|
||||||
|
|
||||||
|
// --- mouse dispatch ---
|
||||||
|
let oldMouse = inputwork.oldMouse;
|
||||||
|
let hasOld = oldMouse && oldMouse.length === 3;
|
||||||
|
let oldBtns = hasOld ? (oldMouse[2] & 0x07) : 0;
|
||||||
|
let curBtns = mb & 0x07;
|
||||||
|
let wheelUp = (mb & 0x40) !== 0;
|
||||||
|
let wheelDn = (mb & 0x80) !== 0;
|
||||||
|
|
||||||
|
if (wheelUp) callback(["mouse_wheel", mx, my, -1].concat(keys));
|
||||||
|
if (wheelDn) callback(["mouse_wheel", mx, my, 1].concat(keys));
|
||||||
|
|
||||||
|
let pressed = curBtns & ~oldBtns;
|
||||||
|
let released = oldBtns & ~curBtns;
|
||||||
|
for (let b = 1; b <= 4; b <<= 1) {
|
||||||
|
if (pressed & b) callback(["mouse_down", mx, my, b].concat(keys));
|
||||||
|
if (released & b) callback(["mouse_up", mx, my, b].concat(keys));
|
||||||
|
}
|
||||||
|
if (hasOld && (mx !== oldMouse[0] || my !== oldMouse[1])) {
|
||||||
|
callback(["mouse_move", mx, my, curBtns, oldMouse[0], oldMouse[1]].concat(keys));
|
||||||
|
}
|
||||||
|
// --- end mouse dispatch ---
|
||||||
|
|
||||||
let keyChanged = !arrayEq(keys, inputwork.oldKeys)
|
let keyChanged = !arrayEq(keys, inputwork.oldKeys)
|
||||||
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
||||||
|
|
||||||
@@ -1436,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,56 +88,31 @@ function printmotd() {
|
|||||||
let motd = motdFile.sread().trim()
|
let motd = motdFile.sread().trim()
|
||||||
let width = con.getmaxyx()[1]
|
let width = con.getmaxyx()[1]
|
||||||
|
|
||||||
|
let ts = require("typesetter")
|
||||||
|
|
||||||
if (goFancy) {
|
if (goFancy) {
|
||||||
let margin = 4
|
let margin = 4
|
||||||
let internalWidth = width - 2*margin
|
let internalWidth = width - 2*margin
|
||||||
|
let textWidth = internalWidth - 2 // one space of padding inside each ribbon edge
|
||||||
|
|
||||||
con.color_pair(255,253) // white text, transparent back (initial ribbon)
|
let lines = ts.typeset(motd, textWidth)
|
||||||
|
lines.forEach(line => {
|
||||||
let [cy, cx] = con.getyx()
|
let [cy, _cx] = con.getyx()
|
||||||
|
con.color_pair(255,253) // ribbon edge: white text, transparent back
|
||||||
con.mvaddch(cy, 4, 16);con.curs_right();print(' ')
|
con.mvaddch(cy, margin, 16); con.curs_right()
|
||||||
|
print(' ')
|
||||||
const PCX_INIT = margin - 2
|
con.color_pair(240,253) // body: black text, white back
|
||||||
let tcnt = 0
|
print(line)
|
||||||
let pcx = PCX_INIT
|
con.color_pair(255,253)
|
||||||
con.color_pair(240,253) // black text, white back (first line of text)
|
print(' ')
|
||||||
while (tcnt <= motd.length) {
|
con.addch(17); println()
|
||||||
let char = motd.charAt(tcnt)
|
})
|
||||||
|
|
||||||
if (char != '\n') {
|
|
||||||
// prevent the line starting from ' '
|
|
||||||
if (pcx != PCX_INIT || char != ' ') {
|
|
||||||
print(motd.charAt(tcnt))
|
|
||||||
}
|
|
||||||
pcx += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('\n' == char || pcx % internalWidth == 0 && pcx != 0 || tcnt == motd.length) {
|
|
||||||
// current line ending
|
|
||||||
let [_, ncx] = con.getyx()
|
|
||||||
for (let k = 0; k < width - margin - ncx + 1; k++) print(' ')
|
|
||||||
con.color_pair(255,253) // white text, transparent back
|
|
||||||
con.addch(17);println()
|
|
||||||
|
|
||||||
if (tcnt == motd.length) break
|
|
||||||
|
|
||||||
// next line header
|
|
||||||
let [ncy, __] = con.getyx()
|
|
||||||
con.color_pair(255,253) // white text, transparent back
|
|
||||||
con.mvaddch(ncy, 4, 16);con.curs_right();print(' ');con.color_pair(240,253) // black text, white back (subsequent lines of the text)
|
|
||||||
pcx = PCX_INIT
|
|
||||||
}
|
|
||||||
|
|
||||||
tcnt += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
con.reset_graphics()
|
con.reset_graphics()
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
println()
|
println()
|
||||||
println(motd)
|
let lines = ts.typeset(motd, width)
|
||||||
|
lines.forEach(line => println(line))
|
||||||
}
|
}
|
||||||
|
|
||||||
println()
|
println()
|
||||||
@@ -203,6 +189,19 @@ shell.replaceVarCall = function(value) {
|
|||||||
shell.getPwd = function() { return shell_pwd; }
|
shell.getPwd = function() { return shell_pwd; }
|
||||||
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
||||||
shell.getCurrentDrive = function() { return CURRENT_DRIVE; }
|
shell.getCurrentDrive = function() { return CURRENT_DRIVE; }
|
||||||
|
shell.runningScriptPaths = []
|
||||||
|
shell.getFilePath = function() {
|
||||||
|
return shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||||
|
}
|
||||||
|
shell.getFileDir = function() {
|
||||||
|
let p = shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||||
|
if (p === undefined) return undefined
|
||||||
|
let lastSlash = Math.max(p.lastIndexOf('\\'), p.lastIndexOf('/'))
|
||||||
|
if (lastSlash < 0) return p
|
||||||
|
// root of a drive (e.g. "A:\foo.js" -> "A:\")
|
||||||
|
if (lastSlash === 2 && p[1] === ':') return p.substring(0, 3)
|
||||||
|
return p.substring(0, lastSlash)
|
||||||
|
}
|
||||||
// example input: echo "the string" > subdir\test.txt
|
// example input: echo "the string" > subdir\test.txt
|
||||||
shell.parse = function(input) {
|
shell.parse = function(input) {
|
||||||
let tokens = []
|
let tokens = []
|
||||||
@@ -632,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
|
||||||
@@ -648,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); },
|
||||||
@@ -812,6 +830,8 @@ shell.execute = function(line, nameOverride) {
|
|||||||
let programCode = searchFile.sread()
|
let programCode = searchFile.sread()
|
||||||
let extension = searchFile.extension.toUpperCase()
|
let extension = searchFile.extension.toUpperCase()
|
||||||
|
|
||||||
|
shell.runningScriptPaths.push(searchFile.fullPath)
|
||||||
|
try {
|
||||||
if ("BAT" == extension) {
|
if ("BAT" == extension) {
|
||||||
// parse and run as batch file
|
// parse and run as batch file
|
||||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||||
@@ -904,6 +924,9 @@ shell.execute = function(line, nameOverride) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
shell.runningScriptPaths.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,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
|
||||||
@@ -1019,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)
|
||||||
@@ -1051,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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
assets/disk0/tvdos/bin/hop.alias
Normal file
1
assets/disk0/tvdos/bin/hop.alias
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hopper $0
|
||||||
1089
assets/disk0/tvdos/bin/hopper.js
Normal file
1089
assets/disk0/tvdos/bin/hopper.js
Normal file
File diff suppressed because it is too large
Load Diff
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,10 @@ Uint16 Encoding
|
|||||||
10 00 : UTF-8
|
10 00 : UTF-8
|
||||||
10 01 : UTF-16BE
|
10 01 : UTF-16BE
|
||||||
10 02 : UTF-16LE
|
10 02 : UTF-16LE
|
||||||
Byte[5] Padding
|
Byte Flags
|
||||||
|
0b 0000 000r
|
||||||
|
r: path is relative
|
||||||
|
Bytes[4] Reserved
|
||||||
|
|
||||||
# FileBlocks
|
# FileBlocks
|
||||||
Uint8 File type (only 1 is used)
|
Uint8 File type (only 1 is used)
|
||||||
@@ -28,27 +31,36 @@ instead of compressing individual files)
|
|||||||
|
|
||||||
function printUsage() {
|
function printUsage() {
|
||||||
println(`Collects files under a directory into a single archive.
|
println(`Collects files under a directory into a single archive.
|
||||||
Usage: lfs [-c/-x/-t] dest.lfs path\\to\\source
|
Usage: lfs [-c/-x/-t] [-r] dest.lfs path\\to\\source
|
||||||
To collect a directory into myarchive.lfs:
|
To collect a directory into myarchive.lfs:
|
||||||
lfs -c myarchive.lfs path\\to\\directory
|
lfs -c myarchive.lfs path\\to\\directory
|
||||||
|
To collect a directory into myarchive.lfs, using relative path:
|
||||||
|
lfs -c -r myarchive.lfs path\\to\\directory
|
||||||
To extract an archive to path\\to\\my\\files:
|
To extract an archive to path\\to\\my\\files:
|
||||||
lfs -x myarchive.lfs path\\to\\my\\files
|
lfs -x myarchive.lfs path\\to\\my\\files
|
||||||
To list the collected files:
|
To list the collected files:
|
||||||
lfs -t myarchive.lfs`)
|
lfs -t myarchive.lfs`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let option = exec_args[1]
|
let option = undefined
|
||||||
const lfsPath = exec_args[2]
|
let useRelative = false
|
||||||
const dirPath = exec_args[3]
|
const positional = []
|
||||||
|
for (let i = 1; i < exec_args.length; i++) {
|
||||||
|
const a = exec_args[i]
|
||||||
|
if (a === undefined) continue
|
||||||
|
const au = a.toUpperCase()
|
||||||
|
if (au === "-C" || au === "-X" || au === "-T") option = au
|
||||||
|
else if (au === "-R") useRelative = true
|
||||||
|
else positional.push(a)
|
||||||
|
}
|
||||||
|
const lfsPath = positional[0]
|
||||||
|
const dirPath = positional[1]
|
||||||
|
|
||||||
|
if (option === undefined || lfsPath === undefined || (option != "-T" && dirPath === undefined)) {
|
||||||
if (option === undefined || lfsPath === undefined || option.toUpperCase() != "-T" && dirPath === undefined) {
|
|
||||||
printUsage()
|
printUsage()
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
option = option.toUpperCase()
|
|
||||||
|
|
||||||
|
|
||||||
function recurseDir(file, action) {
|
function recurseDir(file, action) {
|
||||||
if (!file.isDirectory) {
|
if (!file.isDirectory) {
|
||||||
@@ -76,13 +88,14 @@ if ("-C" == option) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
let out = "TVDOSLFS\x01\x00\x00\x00\x00\x00\x00\x00"
|
const flagsByte = useRelative ? 0x01 : 0x00
|
||||||
|
let out = "TVDOSLFS\x01\x00\x00" + String.fromCharCode(flagsByte) + "\x00\x00\x00\x00"
|
||||||
const rootDirPathLen = rootDir.fullPath.length
|
const rootDirPathLen = rootDir.fullPath.length
|
||||||
|
|
||||||
recurseDir(rootDir, file=>{
|
recurseDir(rootDir, file=>{
|
||||||
let f = files.open(file.fullPath)
|
let f = files.open(file.fullPath)
|
||||||
let flen = f.size
|
let flen = f.size
|
||||||
let fname = file.fullPath.substring(rootDirPathLen + 1)
|
let fname = useRelative ? file.fullPath.substring(rootDirPathLen + 1) : file.fullPath
|
||||||
let plen = fname.length
|
let plen = fname.length
|
||||||
|
|
||||||
out += "\x01" + String.fromCharCode(
|
out += "\x01" + String.fromCharCode(
|
||||||
@@ -116,6 +129,8 @@ else if ("-T" == option || "-X" == option) {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const archiveRelative = (bytes.charCodeAt(11) & 0x01) !== 0
|
||||||
|
|
||||||
if ("-X" == option && !rootDir.exists) {
|
if ("-X" == option && !rootDir.exists) {
|
||||||
rootDir.mkDir()
|
rootDir.mkDir()
|
||||||
}
|
}
|
||||||
@@ -132,9 +147,12 @@ else if ("-T" == option || "-X" == option) {
|
|||||||
|
|
||||||
if ("-X" == option) {
|
if ("-X" == option) {
|
||||||
let filebytes = bytes.substring(curs, curs + filelen)
|
let filebytes = bytes.substring(curs, curs + filelen)
|
||||||
let outfile = files.open(`${rootDir.fullPath}\\${path}`)
|
// Fully qualified paths (e.g. "A:\foo\bar.txt") get their drive prefix
|
||||||
|
// stripped so the archive contents re-root under the destination dir.
|
||||||
|
let subPath = archiveRelative ? path : path.replace(/^[A-Za-z]:[\\\/]?/, "")
|
||||||
|
let outfile = files.open(`${rootDir.fullPath}\\${subPath}`)
|
||||||
|
|
||||||
mkDirs(files.open(`${rootDir.driveLetter}:${files.open(`${rootDir.fullPath}\\${path}`).parentPath}`))
|
mkDirs(files.open(`${outfile.driveLetter}:${outfile.parentPath}`))
|
||||||
outfile.mkFile()
|
outfile.mkFile()
|
||||||
outfile.swrite(filebytes)
|
outfile.swrite(filebytes)
|
||||||
}
|
}
|
||||||
|
|||||||
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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,209 +1,126 @@
|
|||||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
// playmp2 — MPEG-1/2 Audio Layer II player with the shared playgui visualiser.
|
||||||
|
// Usage: playmp2 <file.mp2> [-i]
|
||||||
|
|
||||||
|
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||||
if (!SND_BASE_ADDR) return 10
|
if (!SND_BASE_ADDR) return 10
|
||||||
|
|
||||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||||
const MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
const MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
||||||
|
|
||||||
const pcm = require("pcm")
|
const pcm = require("pcm")
|
||||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||||
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
function printdbg(s) { if (0) serial.println(s) }
|
||||||
|
|
||||||
|
|
||||||
class SequentialFileBuffer {
|
class SequentialFileBuffer {
|
||||||
|
|
||||||
constructor(path, offset, length) {
|
constructor(path, offset, length) {
|
||||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||||
|
|
||||||
this.path = path
|
this.path = path
|
||||||
this.file = files.open(path)
|
this.file = files.open(path)
|
||||||
|
|
||||||
this.offset = offset || 0
|
this.offset = offset || 0
|
||||||
this.originalOffset = offset
|
this.originalOffset = offset
|
||||||
this.length = length || this.file.size
|
this.length = length || this.file.size
|
||||||
|
|
||||||
this.seq = require("seqread")
|
this.seq = require("seqread")
|
||||||
this.seq.prepare(path)
|
this.seq.prepare(path)
|
||||||
}
|
}
|
||||||
|
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||||
readBytes(size, ptr) {
|
get fileHeader() { return this.seq.fileHeader }
|
||||||
return this.seq.readBytes(size, ptr)
|
|
||||||
}
|
|
||||||
|
|
||||||
readStr(n) {
|
|
||||||
let ptr = this.seq.readBytes(n)
|
|
||||||
let s = ''
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
if (i >= this.length) break
|
|
||||||
s += String.fromCharCode(sys.peek(ptr + i))
|
|
||||||
}
|
|
||||||
sys.free(ptr)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
unread(diff) {
|
|
||||||
let newSkipLen = this.seq.getReadCount() - diff
|
|
||||||
this.seq.prepare(this.path)
|
|
||||||
this.seq.skip(newSkipLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
rewind() {
|
|
||||||
this.seq.prepare(this.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
seek(p) {
|
|
||||||
this.seq.prepare(this.path)
|
|
||||||
this.seq.skip(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
get byteLength() {
|
|
||||||
return this.length
|
|
||||||
}
|
|
||||||
|
|
||||||
get fileHeader() {
|
|
||||||
return this.seq.fileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
/*get remaining() {
|
|
||||||
return this.length - this.getReadCount()
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
|
const FILE_SIZE = filebuf.length
|
||||||
|
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||||
|
|
||||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
|
||||||
const FILE_SIZE = filebuf.length// - 100
|
|
||||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
|
||||||
const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
||||||
const MEDIA_CHANNEL_MODE = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
const MEDIA_CHANNEL = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||||
|
|
||||||
|
// mediaDecodedBin sits at MMIO offset 64 in the audio peripheral and holds
|
||||||
|
// 2304 bytes (1152 stereo u8 samples per MP2 frame). Peripheral memory grows
|
||||||
|
// toward 0 so the canonical pointer is SND_BASE_ADDR - 64.
|
||||||
|
//
|
||||||
|
// IMPORTANT: single-byte sys.peek on this address hits AudioAdapter.peek()
|
||||||
|
// which maps the lower offsets to sampleBin, not mediaDecodedBin (the
|
||||||
|
// MMIO/Memory-Space split — see CLAUDE.md). To get the decoded PCM into the
|
||||||
|
// visualiser, we sys.memcpy mediaDecodedBin → a RAM scratch buffer; memcpy
|
||||||
|
// uses VM.getDev internally which DOES route the MMIO read correctly.
|
||||||
|
//
|
||||||
|
// VM.getDev's range check on mediaDecodedBin (relPtrInDev) is half-open and
|
||||||
|
// won't let us copy the full 2304 bytes — we copy 2302 (one stereo sample
|
||||||
|
// short of the frame, invisible at visualiser resolution).
|
||||||
|
const MP2_DECODED_ADDR = SND_BASE_ADDR - 64
|
||||||
|
const MP2_VIS_COPY_BYTES = 2302
|
||||||
|
const MP2_VIS_SAMPLE_COUNT = MP2_VIS_COPY_BYTES >> 1 // 1151
|
||||||
|
const mp2VisScratch = interactive ? sys.malloc(MP2_VIS_COPY_BYTES) : 0
|
||||||
|
|
||||||
let bytes_left = FILE_SIZE
|
let bytes_left = FILE_SIZE
|
||||||
let decodedLength = 0
|
let decodedLength = 0
|
||||||
|
|
||||||
|
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||||
|
|
||||||
//serial.println(`Frame size: ${FRAME_SIZE}`)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
|
// when all four are busy.
|
||||||
con.curs_set(0)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
audio.resetParams(PLAYHEAD)
|
||||||
if (interactive) {
|
audio.purgeQueue(PLAYHEAD)
|
||||||
let [cy, cx] = con.getyx()
|
audio.setPcmMode(PLAYHEAD)
|
||||||
// file name
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
con.mvaddch(cy, 1)
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
print(filebuf.file.name)
|
audio.play(PLAYHEAD)
|
||||||
con.prnch(0xC6);con.prnch(0xCD)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print("Hold Bksp to Exit")
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
|
||||||
|
|
||||||
// L R pillar
|
|
||||||
con.prnch(0xBA)
|
|
||||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
|
||||||
|
|
||||||
// media info
|
|
||||||
let mediaInfoStr = `MP2 ${MEDIA_CHANNEL_MODE} ${MEDIA_BITRATE}kbps`
|
|
||||||
con.move(cy+2,1)
|
|
||||||
con.prnch(0xC8)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print(mediaInfoStr)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
|
||||||
|
|
||||||
con.move(cy+1, 2)
|
|
||||||
}
|
|
||||||
let [cy, cx] = con.getyx()
|
|
||||||
let paintWidth = CONSOLE_WIDTH - 20
|
|
||||||
function bytesToSec(i) {
|
|
||||||
// using fixed value: FRAME_SIZE(216) bytes for 36 ms on sampling rate 32000 Hz
|
|
||||||
return i / (FRAME_SIZE * 1000 / bufRealTimeLen)
|
|
||||||
}
|
|
||||||
function secToReadable(n) {
|
|
||||||
let mins = ''+((n/60)|0)
|
|
||||||
let secs = ''+(n % 60)
|
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
|
||||||
}
|
|
||||||
function printPlayBar(currently) {
|
|
||||||
if (interactive) {
|
|
||||||
let currently = decodedLength
|
|
||||||
let total = FILE_SIZE
|
|
||||||
|
|
||||||
let currentlySec = Math.round(bytesToSec(currently))
|
|
||||||
let totalSec = Math.round(bytesToSec(total))
|
|
||||||
|
|
||||||
con.move(cy, 3)
|
|
||||||
print(' '.repeat(15))
|
|
||||||
con.move(cy, 3)
|
|
||||||
|
|
||||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
|
||||||
|
|
||||||
con.move(cy, 17)
|
|
||||||
print(' ')
|
|
||||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
|
||||||
print(progressbar)
|
|
||||||
|
|
||||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
audio.resetParams(0)
|
|
||||||
audio.purgeQueue(0)
|
|
||||||
audio.setPcmMode(0)
|
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
|
||||||
audio.setMasterVolume(0, 255)
|
|
||||||
audio.play(0)
|
|
||||||
|
|
||||||
|
|
||||||
//let mp2context = audio.mp2Init()
|
|
||||||
audio.mp2Init()
|
audio.mp2Init()
|
||||||
|
|
||||||
// decode frame
|
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||||
let t1 = sys.nanoTime()
|
|
||||||
let bufRealTimeLen = 36
|
if (interactive) {
|
||||||
|
const tag = "MP2"
|
||||||
|
const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps`
|
||||||
|
gui.audioInit({ title, tag })
|
||||||
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
try {
|
try {
|
||||||
while (bytes_left > 0 && !stopPlay) {
|
while (bytes_left > 0 && !stopPlay) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
if (interactive) {
|
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
|
|
||||||
|
|
||||||
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
||||||
audio.mp2Decode()
|
audio.mp2Decode()
|
||||||
|
|
||||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
// After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin
|
||||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
// (MMIO). Bounce them through RAM so single-byte peek in the
|
||||||
printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`)
|
// visualiser pipeline can reach them — see MP2_DECODED_ADDR notes.
|
||||||
|
if (interactive) {
|
||||||
|
sys.memcpy(MP2_DECODED_ADDR, mp2VisScratch, MP2_VIS_COPY_BYTES)
|
||||||
|
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.getPosition(PLAYHEAD) >= QUEUE_MAX) {
|
||||||
|
while (audio.getPosition(PLAYHEAD) >= (QUEUE_MAX >>> 1)) {
|
||||||
|
if (interactive) gui.audioRender()
|
||||||
sys.sleep(bufRealTimeLen)
|
sys.sleep(bufRealTimeLen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio.mp2UploadDecoded(0)
|
audio.mp2UploadDecoded(0)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||||
|
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
sys.sleep(10)
|
sys.sleep(10)
|
||||||
|
|
||||||
|
bytes_left -= FRAME_SIZE
|
||||||
|
|
||||||
bytes_left -= FRAME_SIZE
|
|
||||||
decodedLength += FRAME_SIZE
|
decodedLength += FRAME_SIZE
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (interactive) {
|
||||||
|
if (mp2VisScratch) sys.free(mp2VisScratch)
|
||||||
|
gui.audioClose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
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()
|
||||||
|
|||||||
@@ -1,196 +1,85 @@
|
|||||||
// usage: playpcm audiofile.pcm [/i]
|
// playpcm — raw PCMu8 stereo player with the shared playgui visualiser.
|
||||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
// Usage: playpcm <file.pcm> [-i]
|
||||||
let filename = fileeeee.fullPath
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
|
||||||
|
|
||||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
const pcm = require("pcm")
|
const filePath = fileHandle.fullPath
|
||||||
const FILE_SIZE = files.open(filename).size
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function printComments() {
|
|
||||||
for (const [key, value] of Object.entries(comments)) {
|
|
||||||
printdbg(`${key}: ${value}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function GCD(a, b) {
|
|
||||||
a = Math.abs(a)
|
|
||||||
b = Math.abs(b)
|
|
||||||
if (b > a) {var temp = a; a = b; b = temp}
|
|
||||||
while (true) {
|
|
||||||
if (b == 0) return a
|
|
||||||
a %= b
|
|
||||||
if (a == 0) return b
|
|
||||||
b %= a
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function LCM(a, b) {
|
|
||||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//println("Reading...")
|
|
||||||
//serial.println("!!! READING")
|
|
||||||
|
|
||||||
|
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||||
|
const pcm = require("pcm")
|
||||||
const seqread = require("seqread")
|
const seqread = require("seqread")
|
||||||
seqread.prepare(filename)
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const FILE_SIZE = files.open(filePath).size
|
||||||
|
|
||||||
let BLOCK_SIZE = 4096
|
let BLOCK_SIZE = 4096
|
||||||
let INFILE_BLOCK_SIZE = BLOCK_SIZE
|
const INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||||
const QUEUE_MAX = 8 // according to the spec
|
const QUEUE_MAX = 8
|
||||||
|
|
||||||
let nChannels = 2
|
const samplingRate = pcm.HW_SAMPLING_RATE
|
||||||
let samplingRate = pcm.HW_SAMPLING_RATE;
|
const byterate = 2 * samplingRate
|
||||||
let blockSize = 2;
|
|
||||||
let bitsPerSample = 8;
|
|
||||||
let byterate = 2*samplingRate;
|
|
||||||
let comments = {};
|
|
||||||
let readPtr = undefined
|
|
||||||
let decodePtr = undefined
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) { return i / byterate }
|
||||||
return i / byterate
|
|
||||||
}
|
seqread.prepare(filePath)
|
||||||
function secToReadable(n) {
|
|
||||||
let mins = ''+((n/60)|0)
|
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||||
let secs = ''+(n % 60)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
|
// when all four are busy.
|
||||||
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
|
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||||
|
tag: "PCM"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
con.curs_set(0)
|
|
||||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
|
||||||
if (interactive) {
|
|
||||||
let [cy, cx] = con.getyx()
|
|
||||||
// file name
|
|
||||||
con.mvaddch(cy, 1)
|
|
||||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
|
||||||
print(fileeeee.name)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print("Hold Bksp to Exit")
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
|
||||||
|
|
||||||
// L R pillar
|
|
||||||
con.prnch(0xBA)
|
|
||||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
|
||||||
|
|
||||||
// media info
|
|
||||||
let mediaInfoStr = `Raw PCM 512kbps`
|
|
||||||
con.move(cy+2,1)
|
|
||||||
con.prnch(0xC8)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print(mediaInfoStr)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
|
||||||
|
|
||||||
con.move(cy+1, 2)
|
|
||||||
}
|
|
||||||
let [cy, cx] = con.getyx()
|
|
||||||
let paintWidth = CONSOLE_WIDTH - 20
|
|
||||||
// read chunks loop
|
|
||||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
|
||||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
|
||||||
|
|
||||||
|
|
||||||
audio.resetParams(0)
|
|
||||||
audio.purgeQueue(0)
|
|
||||||
audio.setPcmMode(0)
|
|
||||||
audio.setMasterVolume(0, 255)
|
|
||||||
|
|
||||||
let readLength = 1
|
|
||||||
|
|
||||||
function printPlayBar() {
|
|
||||||
if (interactive) {
|
|
||||||
let currently = seqread.getReadCount()
|
|
||||||
let total = FILE_SIZE
|
|
||||||
|
|
||||||
let currentlySec = Math.round(bytesToSec(currently))
|
|
||||||
let totalSec = Math.round(bytesToSec(total))
|
|
||||||
|
|
||||||
con.move(cy, 3)
|
|
||||||
print(' '.repeat(15))
|
|
||||||
con.move(cy, 3)
|
|
||||||
|
|
||||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
|
||||||
|
|
||||||
con.move(cy, 17)
|
|
||||||
print(' ')
|
|
||||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
|
||||||
print(progressbar)
|
|
||||||
|
|
||||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
|
let readLength = 1
|
||||||
try {
|
try {
|
||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||||
if (interactive) {
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const queueSize = audio.getPosition(PLAYHEAD)
|
||||||
|
if (queueSize <= 1) {
|
||||||
|
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||||
|
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||||
|
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||||
|
if (readLength <= 0) break
|
||||||
|
|
||||||
let queueSize = audio.getPosition(0)
|
seqread.readBytes(readLength, readPtr)
|
||||||
if (queueSize <= 1) {
|
|
||||||
|
|
||||||
printPlayBar()
|
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||||
|
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||||
|
|
||||||
// upload four samples for lag-safely
|
audio.putPcmDataByPtr(PLAYHEAD, readPtr, readLength, 0)
|
||||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
if (repeat > 1) sys.sleep(10)
|
||||||
if (readLength <= 0) {
|
|
||||||
printdbg(`readLength = ${readLength}`)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`)
|
|
||||||
|
|
||||||
seqread.readBytes(readLength, readPtr)
|
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
|
||||||
audio.setSampleUploadLength(0, readLength)
|
|
||||||
audio.startSampleUpload(0)
|
|
||||||
|
|
||||||
|
|
||||||
if (repeat > 1) sys.sleep(10)
|
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.play(0)
|
if (interactive) {
|
||||||
|
const cur = seqread.getReadCount()
|
||||||
|
gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
|
sys.sleep(10)
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
|
||||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`)
|
|
||||||
|
|
||||||
|
|
||||||
sys.sleep(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
//audio.stop(0)
|
|
||||||
if (readPtr !== undefined) sys.free(readPtr)
|
if (readPtr !== undefined) sys.free(readPtr)
|
||||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
if (interactive) gui.audioClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|
||||||
|
|||||||
@@ -1,114 +1,66 @@
|
|||||||
|
// playtad — TAD (TSVM Advanced Audio) player with the shared playgui visualiser.
|
||||||
|
// Usage: playtad <file.tad> [-i | -d]
|
||||||
|
// -i Interactive mode (visualiser + progress bar; hold Backspace to exit)
|
||||||
|
// -d Dump mode (print the first three chunks to serial for debugging)
|
||||||
|
|
||||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||||
const SND_MEM_ADDR = audio.getMemAddr()
|
const SND_MEM_ADDR = audio.getMemAddr()
|
||||||
// tadInputBin lives at audio-local offset 917504 and tadDecodedBin at 983040
|
// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via
|
||||||
// (post-bef85f6 memory map; the old 262144 offset now hits the enlarged sampleBin).
|
// negative pointers — peripheral memory grows toward 0.
|
||||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504 // TAD input buffer (matches TAV packet 0x24)
|
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504
|
||||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040 // TAD decoded buffer
|
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040
|
||||||
|
|
||||||
if (!SND_BASE_ADDR) return 10
|
if (!SND_BASE_ADDR) return 10
|
||||||
|
|
||||||
// Check for help flag or missing arguments
|
if (!exec_args[1] || exec_args[1] === "-h" || exec_args[1] === "--help") {
|
||||||
if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") {
|
serial.println("Usage: playtad <file.tad> [-i | -d]")
|
||||||
serial.println("Usage: playtad <file.tad> [-i | -d] [quality]")
|
serial.println(" -i Interactive mode (visualiser + progress bar)")
|
||||||
serial.println(" -i Interactive mode (progress bar, press Backspace to exit)")
|
serial.println(" -d Dump first three chunks for debugging")
|
||||||
serial.println(" -d Dump mode (show first 3 chunks with payload hex and decoded samples)")
|
|
||||||
serial.println("")
|
|
||||||
serial.println("Examples:")
|
|
||||||
serial.println(" playtad audio.tad -i # Play with progress bar")
|
|
||||||
serial.println(" playtad audio.tad -d # Dump first 3 chunks for debugging")
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const pcm = require("pcm")
|
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d"
|
||||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d"
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
|
||||||
|
|
||||||
|
|
||||||
class SequentialFileBuffer {
|
class SequentialFileBuffer {
|
||||||
|
constructor(path) {
|
||||||
constructor(path, offset, length) {
|
|
||||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||||
|
|
||||||
this.path = path
|
this.path = path
|
||||||
this.file = files.open(path)
|
this.file = files.open(path)
|
||||||
|
this.length = this.file.size
|
||||||
this.offset = offset || 0
|
|
||||||
this.originalOffset = offset
|
|
||||||
this.length = length || this.file.size
|
|
||||||
|
|
||||||
this.seq = require("seqread")
|
this.seq = require("seqread")
|
||||||
this.seq.prepare(path)
|
this.seq.prepare(path)
|
||||||
}
|
}
|
||||||
|
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||||
readBytes(size, ptr) {
|
|
||||||
return this.seq.readBytes(size, ptr)
|
|
||||||
}
|
|
||||||
|
|
||||||
readByte() {
|
readByte() {
|
||||||
let ptr = this.seq.readBytes(1)
|
const ptr = this.seq.readBytes(1)
|
||||||
let val = sys.peek(ptr)
|
const val = sys.peek(ptr)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readShort() {
|
readShort() {
|
||||||
let ptr = this.seq.readBytes(2)
|
const ptr = this.seq.readBytes(2)
|
||||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readInt() {
|
readInt() {
|
||||||
let ptr = this.seq.readBytes(4)
|
const ptr = this.seq.readBytes(4)
|
||||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readStr(n) {
|
|
||||||
let ptr = this.seq.readBytes(n)
|
|
||||||
let s = ''
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
if (i >= this.length) break
|
|
||||||
s += String.fromCharCode(sys.peek(ptr + i))
|
|
||||||
}
|
|
||||||
sys.free(ptr)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
unread(diff) {
|
unread(diff) {
|
||||||
let newSkipLen = this.seq.getReadCount() - diff
|
const newSkipLen = this.seq.getReadCount() - diff
|
||||||
this.seq.prepare(this.path)
|
this.seq.prepare(this.path)
|
||||||
this.seq.skip(newSkipLen)
|
this.seq.skip(newSkipLen)
|
||||||
}
|
}
|
||||||
|
rewind() { this.seq.prepare(this.path) }
|
||||||
rewind() {
|
getReadCount() { return this.seq.getReadCount() }
|
||||||
this.seq.prepare(this.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
seek(p) {
|
|
||||||
this.seq.prepare(this.path)
|
|
||||||
this.seq.skip(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
get byteLength() {
|
|
||||||
return this.length
|
|
||||||
}
|
|
||||||
|
|
||||||
get fileHeader() {
|
|
||||||
return this.seq.fileHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
getReadCount() {
|
|
||||||
return this.seq.getReadCount()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
// Read TAD chunk header to determine format
|
|
||||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
|
||||||
const FILE_SIZE = filebuf.length
|
const FILE_SIZE = filebuf.length
|
||||||
|
|
||||||
if (FILE_SIZE < 7) {
|
if (FILE_SIZE < 7) {
|
||||||
@@ -116,12 +68,12 @@ if (FILE_SIZE < 7) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read first chunk header (standalone TAD format: no TAV wrapper)
|
// Peek the first chunk header so we know the chunk size for the rough bytes-
|
||||||
let firstSampleCount = filebuf.readShort()
|
// to-seconds conversion shown in the progress bar.
|
||||||
let firstMaxIndex = filebuf.readByte()
|
const firstSampleCount = filebuf.readShort()
|
||||||
let firstPayloadSize = filebuf.readInt()
|
const firstMaxIndex = filebuf.readByte()
|
||||||
|
const firstPayloadSize = filebuf.readInt()
|
||||||
|
|
||||||
// Validate first chunk
|
|
||||||
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
||||||
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
||||||
return 1
|
return 1
|
||||||
@@ -135,148 +87,72 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewind to start
|
|
||||||
filebuf.rewind()
|
filebuf.rewind()
|
||||||
|
|
||||||
// Calculate approximate frame info
|
const AVG_CHUNK_SIZE = 7 + firstPayloadSize
|
||||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload
|
const SAMPLE_RATE = 32000
|
||||||
const SAMPLE_RATE = 32000
|
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000)
|
||||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk
|
|
||||||
|
|
||||||
if (dumpCoeffs) {
|
if (dumpCoeffs) {
|
||||||
serial.println(`TAD Coefficient Dump Mode`)
|
serial.println(`TAD Coefficient Dump Mode`)
|
||||||
serial.println(`File: ${filebuf.file.name}`)
|
serial.println(`File: ${filebuf.file.name}`)
|
||||||
serial.println(`First chunk header:`)
|
serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`)
|
||||||
serial.println(` Sample Count: ${firstSampleCount}`)
|
|
||||||
serial.println(` Max Index: ${firstMaxIndex}`)
|
|
||||||
serial.println(` Payload Size: ${firstPayloadSize} bytes`)
|
|
||||||
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
||||||
serial.println(``)
|
serial.println(``)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bytes_left = FILE_SIZE
|
||||||
let bytes_left = FILE_SIZE
|
|
||||||
let decodedLength = 0
|
let decodedLength = 0
|
||||||
let chunkNumber = 0
|
let chunkNumber = 0
|
||||||
|
|
||||||
|
|
||||||
con.curs_set(0)
|
|
||||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
|
||||||
if (interactive) {
|
|
||||||
let [cy, cx] = con.getyx()
|
|
||||||
// file name
|
|
||||||
con.mvaddch(cy, 1)
|
|
||||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
|
||||||
print(filebuf.file.name)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print("Hold Bksp to Exit")
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
|
||||||
|
|
||||||
// L R pillar
|
|
||||||
con.prnch(0xBA)
|
|
||||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
|
||||||
|
|
||||||
// media info
|
|
||||||
let mediaInfoStr = `TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`
|
|
||||||
con.move(cy+2,1)
|
|
||||||
con.prnch(0xC8)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print(mediaInfoStr)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
|
||||||
|
|
||||||
con.move(cy+1, 2)
|
|
||||||
}
|
|
||||||
let [cy, cx] = con.getyx()
|
|
||||||
let paintWidth = CONSOLE_WIDTH - 20
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) {
|
||||||
// Approximate: use first chunk's ratio
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
function secToReadable(n) {
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
let mins = ''+((n/60)|0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
let secs = ''+(n % 60)
|
// when all four are busy.
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
|
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||||
|
tag: "TAD"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function printPlayBar() {
|
|
||||||
if (interactive) {
|
|
||||||
let currently = decodedLength
|
|
||||||
let total = FILE_SIZE
|
|
||||||
|
|
||||||
let currentlySec = bytesToSec(currently)
|
|
||||||
let totalSec = bytesToSec(total)
|
|
||||||
|
|
||||||
con.move(cy, 3)
|
|
||||||
print(' '.repeat(15))
|
|
||||||
con.move(cy, 3)
|
|
||||||
|
|
||||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
|
||||||
|
|
||||||
con.move(cy, 17)
|
|
||||||
print(' ')
|
|
||||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
|
||||||
print(progressbar)
|
|
||||||
|
|
||||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
audio.resetParams(0)
|
|
||||||
audio.purgeQueue(0)
|
|
||||||
audio.setPcmMode(0)
|
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
|
||||||
audio.setMasterVolume(0, 255)
|
|
||||||
audio.play(0)
|
|
||||||
|
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (bytes_left > 0 && !stopPlay) {
|
while (bytes_left > 0 && !stopPlay) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
if (interactive) {
|
const sampleCount = filebuf.readShort()
|
||||||
sys.poke(-40, 1)
|
const maxIndex = filebuf.readByte()
|
||||||
if (sys.peek(-41) == 67) { // Backspace key
|
const payloadSize = filebuf.readInt()
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
|
|
||||||
// Read TAD chunk header (standalone TAD format)
|
|
||||||
// Format: [sample_count][max_index][payload_size][payload]
|
|
||||||
let sampleCount = filebuf.readShort()
|
|
||||||
let maxIndex = filebuf.readByte()
|
|
||||||
let payloadSize = filebuf.readInt()
|
|
||||||
|
|
||||||
// Validate every chunk (not just first one)
|
|
||||||
if (sampleCount < 0 || sampleCount > 65536) {
|
if (sampleCount < 0 || sampleCount > 65536) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (maxIndex < 0 || maxIndex > 255) {
|
if (maxIndex < 0 || maxIndex > 255) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (payloadSize < 1 || payloadSize > 65536) {
|
if (payloadSize < 1 || payloadSize > 65536) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (payloadSize + 7 > bytes_left) {
|
if (payloadSize + 7 > bytes_left) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
if (dumpCoeffs && chunkNumber < 3) {
|
||||||
@@ -284,80 +160,59 @@ try {
|
|||||||
serial.println(` Sample Count: ${sampleCount}`)
|
serial.println(` Sample Count: ${sampleCount}`)
|
||||||
serial.println(` Max Index: ${maxIndex}`)
|
serial.println(` Max Index: ${maxIndex}`)
|
||||||
serial.println(` Payload Size: ${payloadSize} bytes`)
|
serial.println(` Payload Size: ${payloadSize} bytes`)
|
||||||
serial.println(` Bytes remaining in file: ${bytes_left}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewind 7 bytes to re-read the header along with payload
|
// Read entire chunk (header + payload) into TAD input buffer.
|
||||||
// This allows reading the complete chunk (header + payload) in one call
|
|
||||||
filebuf.unread(7)
|
filebuf.unread(7)
|
||||||
|
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||||
|
|
||||||
// Read entire chunk (header + payload) to TAD input buffer
|
|
||||||
// This matches TAV's approach for packet 0x24
|
|
||||||
let totalChunkSize = 7 + payloadSize
|
|
||||||
filebuf.readBytes(totalChunkSize, TAD_INPUT_ADDR)
|
|
||||||
|
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
|
||||||
// Dump first 32 bytes of compressed payload (skip 7-byte header)
|
|
||||||
serial.print(` Compressed data (first 32 bytes): `)
|
|
||||||
for (let i = 0; i < Math.min(32, payloadSize); i++) {
|
|
||||||
let b = sys.peek(TAD_INPUT_ADDR + 7 + i)
|
|
||||||
serial.print(`${(b & 0xFF).toString(16).padStart(2, '0')} `)
|
|
||||||
}
|
|
||||||
serial.println('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode TAD chunk
|
|
||||||
audio.tadDecode()
|
audio.tadDecode()
|
||||||
|
audio.tadUploadDecoded(PLAYHEAD, sampleCount)
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
// After upload tadDecodedBin still holds the chunk until the next
|
||||||
// After decoding, the decoded PCMu8 samples are in tadDecodedBin
|
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||||
serial.println(` Decoded ${sampleCount} samples`)
|
// during the playback wait below.
|
||||||
|
|
||||||
// Dump first 16 decoded samples (PCMu8 stereo interleaved)
|
|
||||||
serial.print(` Decoded (first 16 L samples): `)
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2) & 0xFF} `)
|
|
||||||
}
|
|
||||||
serial.println('')
|
|
||||||
serial.print(` Decoded (first 16 R samples): `)
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2 + 1) & 0xFF} `)
|
|
||||||
}
|
|
||||||
serial.println('')
|
|
||||||
serial.println('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload decoded audio to queue
|
|
||||||
audio.tadUploadDecoded(0, sampleCount)
|
|
||||||
|
|
||||||
if (!dumpCoeffs) {
|
if (!dumpCoeffs) {
|
||||||
// Sleep for the duration of the audio chunk to pace playback
|
// TAD chunks are typically 1 s long, so feeding the visualiser
|
||||||
// This prevents uploading everything at once
|
// once would freeze it for ~1 s. Walk the chunk in 2048-sample
|
||||||
sys.sleep(bufRealTimeLen)
|
// slices (~64 ms each at 32 kHz) so the wavescope and XY-scope
|
||||||
|
// stay in step with what the audio engine is actually playing.
|
||||||
|
const chunkMs = Math.floor((sampleCount / SAMPLE_RATE) * 1000)
|
||||||
|
const TAD_VIS_SLICE = 2048
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||||
|
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||||
|
let sliceOff = 0
|
||||||
|
while (sliceOff < sampleCount && !stopPlay) {
|
||||||
|
if (gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
const sliceN = Math.min(TAD_VIS_SLICE, sampleCount - sliceOff)
|
||||||
|
// tadDecodedBin is negative-addressed: sample i sits at
|
||||||
|
// TAD_DECODED_ADDR - i*2. audioFeedPcm flips the read
|
||||||
|
// direction for negative ptrs internally.
|
||||||
|
gui.audioFeedPcm(TAD_DECODED_ADDR - sliceOff * 2, sliceN)
|
||||||
|
gui.audioRender()
|
||||||
|
sys.sleep(Math.floor((sliceN / SAMPLE_RATE) * 1000))
|
||||||
|
sliceOff += sliceN
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sys.sleep(chunkMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunk size = header (7 bytes) + payload
|
const chunkSize = 7 + payloadSize
|
||||||
let chunkSize = 7 + payloadSize
|
bytes_left -= chunkSize
|
||||||
bytes_left -= chunkSize
|
|
||||||
decodedLength += chunkSize
|
decodedLength += chunkSize
|
||||||
chunkNumber++
|
chunkNumber++
|
||||||
|
|
||||||
// Limit coefficient dump to first 3 chunks
|
|
||||||
if (dumpCoeffs && chunkNumber >= 3) {
|
if (dumpCoeffs && chunkNumber >= 3) {
|
||||||
serial.println(`... (remaining chunks omitted)`)
|
serial.println(`... (remaining chunks omitted)`)
|
||||||
// Keep playing but don't dump more
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (interactive) gui.audioClose()
|
||||||
if (interactive) {
|
|
||||||
con.move(cy + 3, 1)
|
|
||||||
con.curs_set(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
1232
assets/disk0/tvdos/bin/playtaud.js
Normal file
1232
assets/disk0/tvdos/bin/playtaud.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,329 +1,193 @@
|
|||||||
// usage: playwav audiofile.wav [/i]
|
// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser.
|
||||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
// Usage: playwav <file.wav> [-i]
|
||||||
let filename = fileeeee.fullPath
|
|
||||||
|
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
|
const filePath = fileHandle.fullPath
|
||||||
|
|
||||||
|
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||||
|
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||||
|
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||||
|
|
||||||
|
const seqread = require("seqread")
|
||||||
|
const pcm = require("pcm")
|
||||||
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
function printdbg(s) { if (0) serial.println(s) }
|
||||||
|
|
||||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
|
||||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
|
||||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
|
||||||
const seqread = require("seqread")
|
|
||||||
const pcm = require("pcm")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function printComments() {
|
|
||||||
for (const [key, value] of Object.entries(comments)) {
|
|
||||||
printdbg(`Wave Comment ${key}: ${value}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function GCD(a, b) {
|
function GCD(a, b) {
|
||||||
a = Math.abs(a)
|
a = Math.abs(a); b = Math.abs(b)
|
||||||
b = Math.abs(b)
|
if (b > a) { const t = a; a = b; b = t }
|
||||||
if (b > a) {var temp = a; a = b; b = temp}
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (b == 0) return a
|
if (b === 0) return a
|
||||||
a %= b
|
a %= b
|
||||||
if (a == 0) return b
|
if (a === 0) return b
|
||||||
b %= a
|
b %= a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) }
|
||||||
|
|
||||||
function LCM(a, b) {
|
seqread.prepare(filePath)
|
||||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
if (seqread.readFourCC() !== "RIFF") throw Error("File not RIFF")
|
||||||
}
|
const FILE_SIZE = seqread.readInt()
|
||||||
|
if (seqread.readFourCC() !== "WAVE") throw Error("File is RIFF but not WAVE")
|
||||||
|
|
||||||
|
|
||||||
//println("Reading...")
|
|
||||||
//serial.println("!!! READING")
|
|
||||||
|
|
||||||
seqread.prepare(filename)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// decode header
|
|
||||||
if (seqread.readFourCC() != "RIFF") {
|
|
||||||
throw Error("File not RIFF")
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILE_SIZE = seqread.readInt() // size from "WAVEfmt"
|
|
||||||
|
|
||||||
if (seqread.readFourCC() != "WAVE") {
|
|
||||||
throw Error("File is RIFF but not WAVE")
|
|
||||||
}
|
|
||||||
|
|
||||||
let BLOCK_SIZE = 0
|
let BLOCK_SIZE = 0
|
||||||
let INFILE_BLOCK_SIZE = 0
|
let INFILE_BLOCK_SIZE = 0
|
||||||
const QUEUE_MAX = 8 // according to the spec
|
const QUEUE_MAX = 8
|
||||||
|
|
||||||
let pcmType;
|
let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate
|
||||||
let nChannels;
|
let adpcmSamplesPerBlock
|
||||||
let samplingRate;
|
let readPtr, decodePtr
|
||||||
let blockSize;
|
const comments = {}
|
||||||
let bitsPerSample;
|
|
||||||
let byterate;
|
|
||||||
let comments = {};
|
|
||||||
let adpcmSamplesPerBlock;
|
|
||||||
let readPtr = undefined
|
|
||||||
let decodePtr = undefined
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) {
|
||||||
if (adpcmSamplesPerBlock) {
|
if (adpcmSamplesPerBlock) {
|
||||||
let newByteRate = samplingRate
|
const generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||||
let generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
return generatedSamples / samplingRate
|
||||||
return generatedSamples / newByteRate
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return i / byterate
|
|
||||||
}
|
}
|
||||||
|
return i / byterate
|
||||||
}
|
}
|
||||||
function secToReadable(n) {
|
|
||||||
let mins = ''+((n/60)|0)
|
|
||||||
let secs = ''+(n % 60)
|
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
|
||||||
}
|
|
||||||
function checkIfPlayable() {
|
function checkIfPlayable() {
|
||||||
if (pcmType != 1 && pcmType != 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
if (pcmType !== 1 && pcmType !== 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||||
if (nChannels < 1 || nChannels > 2) return `Audio not mono/stereo but instead has ${nChannels} channels`
|
if (nChannels < 1 || nChannels > 2) return `Audio not mono/stereo but instead has ${nChannels} channels`
|
||||||
if (pcmType != 1 && samplingRate != pcm.HW_SAMPLING_RATE) return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
if (pcmType !== 1 && samplingRate !== pcm.HW_SAMPLING_RATE)
|
||||||
|
return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||||
return "playable!"
|
return "playable!"
|
||||||
}
|
}
|
||||||
// @return decoded sample length (not count!)
|
|
||||||
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
||||||
// LPCM
|
if (pcmType === 1)
|
||||||
if (1 == pcmType)
|
|
||||||
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
||||||
else if (2 == pcmType)
|
if (pcmType === 2)
|
||||||
return pcm.decodeMS_ADPCM(inPtr, outPtr, inputLen, { nChannels })
|
return pcm.decodeMS_ADPCM(inPtr, outPtr, inputLen, { nChannels })
|
||||||
else
|
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
|
|
||||||
|
|
||||||
con.curs_set(0)
|
|
||||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
|
||||||
function printPlayerShell() {
|
|
||||||
if (interactive) {
|
|
||||||
let [cy, cx] = con.getyx()
|
|
||||||
// file name
|
|
||||||
con.mvaddch(cy, 1)
|
|
||||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
|
||||||
print(fileeeee.name)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print("Hold Bksp to Exit")
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
|
||||||
|
|
||||||
// L R pillar
|
|
||||||
con.prnch(0xBA)
|
|
||||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
|
||||||
|
|
||||||
// media info
|
|
||||||
let mediaInfoStr = `WAV ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType == 2 ? 2 : 1)}kbps`
|
|
||||||
con.move(cy+2,1)
|
|
||||||
con.prnch(0xC8)
|
|
||||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
|
||||||
con.prnch(0xB5)
|
|
||||||
print(mediaInfoStr)
|
|
||||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
|
||||||
|
|
||||||
con.move(cy+1, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let [cy, cx] = con.getyx(); cy++
|
|
||||||
let paintWidth = CONSOLE_WIDTH - 20
|
|
||||||
function printPlayBar(startOffset) {
|
|
||||||
if (interactive) {
|
|
||||||
let currently = seqread.getReadCount() - startOffset
|
|
||||||
let total = FILE_SIZE - startOffset - 8
|
|
||||||
|
|
||||||
let currentlySec = Math.round(bytesToSec(currently))
|
|
||||||
let totalSec = Math.round(bytesToSec(total))
|
|
||||||
|
|
||||||
con.move(cy, 3)
|
|
||||||
print(' '.repeat(15))
|
|
||||||
con.move(cy, 3)
|
|
||||||
|
|
||||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
|
||||||
|
|
||||||
con.move(cy, 17)
|
|
||||||
print(' ')
|
|
||||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
|
||||||
print(progressbar)
|
|
||||||
|
|
||||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
// read chunks loop
|
|
||||||
try {
|
|
||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
|
||||||
let chunkName = seqread.readFourCC()
|
|
||||||
let chunkSize = seqread.readInt()
|
|
||||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
|
||||||
|
|
||||||
// here be lotsa if-else
|
try {
|
||||||
if ("fmt " == chunkName) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||||
pcmType = seqread.readShort()
|
const chunkName = seqread.readFourCC()
|
||||||
nChannels = seqread.readShort()
|
const chunkSize = seqread.readInt()
|
||||||
samplingRate = seqread.readInt()
|
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||||
byterate = seqread.readInt()
|
|
||||||
blockSize = seqread.readShort()
|
if (chunkName === "fmt ") {
|
||||||
bitsPerSample = seqread.readShort()
|
pcmType = seqread.readShort()
|
||||||
if (pcmType != 2) {
|
nChannels = seqread.readShort()
|
||||||
seqread.skip(chunkSize - 16)
|
samplingRate = seqread.readInt()
|
||||||
|
byterate = seqread.readInt()
|
||||||
|
blockSize = seqread.readShort()
|
||||||
|
bitsPerSample = seqread.readShort()
|
||||||
|
if (pcmType !== 2) {
|
||||||
|
seqread.skip(chunkSize - 16)
|
||||||
|
} else {
|
||||||
|
seqread.skip(2)
|
||||||
|
adpcmSamplesPerBlock = seqread.readShort()
|
||||||
|
seqread.skip(chunkSize - (16 + 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pcmType === 1) {
|
||||||
|
const incr = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||||
|
while (BLOCK_SIZE < 4096) BLOCK_SIZE += incr
|
||||||
|
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8
|
||||||
|
} else if (pcmType === 2) {
|
||||||
|
BLOCK_SIZE = blockSize
|
||||||
|
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
const tag = "WAV"
|
||||||
|
const title = fileHandle.name +
|
||||||
|
` ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType === 2 ? 2 : 1)}kbps`
|
||||||
|
gui.audioInit({ title, tag })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (chunkName === "LIST") {
|
||||||
|
const startOffset = seqread.getReadCount()
|
||||||
|
const subChunkName = seqread.readFourCC()
|
||||||
|
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||||
|
if (subChunkName === "INFO") {
|
||||||
|
let key = seqread.readFourCC()
|
||||||
|
let valueLen = seqread.readInt()
|
||||||
|
while (key.charCodeAt(0) === 0) {
|
||||||
|
const kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||||
|
const klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||||
|
key = String.fromCharCode.apply(null, kbytes)
|
||||||
|
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||||
|
}
|
||||||
|
comments[key] = seqread.readString(valueLen)
|
||||||
|
} else {
|
||||||
|
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (chunkName === "data") {
|
||||||
|
const startOffset = seqread.getReadCount()
|
||||||
|
const reason = checkIfPlayable()
|
||||||
|
if (reason !== "playable!") throw Error("WAVE not playable: " + reason)
|
||||||
|
|
||||||
|
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||||
|
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||||
|
|
||||||
|
// Occupy the first idle playhead rather than always grabbing #0, so
|
||||||
|
// playback doesn't cut off audio already running on another playhead.
|
||||||
|
// Falls back to #0 when all four are busy.
|
||||||
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
|
let readLength = 1
|
||||||
|
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
|
if (audio.getPosition(PLAYHEAD) <= 1) {
|
||||||
|
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||||
|
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||||
|
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||||
|
if (readLength <= 0) break
|
||||||
|
|
||||||
|
seqread.readBytes(readLength, readPtr)
|
||||||
|
const decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||||
|
|
||||||
|
// Hand the decoded PCMu8 stereo block to the visualiser
|
||||||
|
// before queueing — the buffer is reused next iteration.
|
||||||
|
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||||
|
|
||||||
|
audio.putPcmDataByPtr(PLAYHEAD, decodePtr, decodedSampleLength, 0)
|
||||||
|
audio.setSampleUploadLength(PLAYHEAD, decodedSampleLength)
|
||||||
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
|
sys.spin()
|
||||||
|
}
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
const cur = seqread.getReadCount() - startOffset
|
||||||
|
const tot = FILE_SIZE - startOffset - 8
|
||||||
|
gui.audioSetProgress(cur / tot, bytesToSec(cur), bytesToSec(tot))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
|
sys.sleep(10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
seqread.skip(2)
|
seqread.skip(chunkSize)
|
||||||
adpcmSamplesPerBlock = seqread.readShort()
|
|
||||||
seqread.skip(chunkSize - (16 + 4))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// define BLOCK_SIZE as integer multiple of blockSize, for LPCM
|
sys.spin()
|
||||||
// ADPCM will be decoded per-block basis
|
|
||||||
if (1 == pcmType) {
|
|
||||||
// get GCD of given values; this wll make resampling headache-free
|
|
||||||
let blockSizeIncrement = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
|
||||||
|
|
||||||
while (BLOCK_SIZE < 4096) {
|
|
||||||
BLOCK_SIZE += blockSizeIncrement // for rate 44100, BLOCK_SIZE will be 4116
|
|
||||||
}
|
|
||||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8 // for rate 44100, INFILE_BLOCK_SIZE will be 8232
|
|
||||||
}
|
|
||||||
else if (2 == pcmType) {
|
|
||||||
BLOCK_SIZE = blockSize
|
|
||||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
printdbg(`Format: ${pcmType}, Channels: ${nChannels}, Rate: ${samplingRate}, BitDepth: ${bitsPerSample}`)
|
|
||||||
printdbg(`BLOCK_SIZE=${BLOCK_SIZE}, INFILE_BLOCK_SIZE=${INFILE_BLOCK_SIZE}`)
|
|
||||||
printPlayerShell()
|
|
||||||
}
|
}
|
||||||
else if ("LIST" == chunkName) {
|
} catch (e) {
|
||||||
let startOffset = seqread.getReadCount()
|
|
||||||
let subChunkName = seqread.readFourCC()
|
|
||||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
|
||||||
if ("INFO" == subChunkName) {
|
|
||||||
let key = seqread.readFourCC()
|
|
||||||
let valueLen = seqread.readInt()
|
|
||||||
|
|
||||||
// f-you WAVE encoders with nonstandard behaviours
|
|
||||||
// related: https://stackoverflow.com/questions/49537639/riff-icmt-tag-size-doesnt-seem-to-match-data
|
|
||||||
while (0 == key.charCodeAt(0)) {
|
|
||||||
printdbg(`Previous key had more zero bytes padded than its marked length, skipping one byte...`)
|
|
||||||
|
|
||||||
let kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
|
||||||
let klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
|
||||||
|
|
||||||
key = String.fromCharCode.apply(null, kbytes)
|
|
||||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
|
||||||
}
|
|
||||||
|
|
||||||
printdbg(`Reading LIST INFO ${key}[${[0,1,2,3].map((i)=>"0x"+key.charCodeAt(i).toString(16).padStart(2,'0'))}] (${valueLen} bytes): `)
|
|
||||||
|
|
||||||
|
|
||||||
let value = seqread.readString(valueLen)
|
|
||||||
printdbg(" |"+value)
|
|
||||||
comments[key] = value
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
printdbg(`LIST skip subchunk ${subChunkName} (${startOffset + chunkSize - seqread.getReadCount()} bytes)`)
|
|
||||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
printComments()
|
|
||||||
}
|
|
||||||
else if ("data" == chunkName) {
|
|
||||||
let startOffset = seqread.getReadCount()
|
|
||||||
|
|
||||||
printdbg(`WAVE size: ${chunkSize}, startOffset=${startOffset}`)
|
|
||||||
// check if the format is actually playable
|
|
||||||
let unplayableReason = checkIfPlayable()
|
|
||||||
if (unplayableReason != "playable!") throw Error("WAVE not playable: "+unplayableReason)
|
|
||||||
|
|
||||||
if (pcmType == 2)
|
|
||||||
readPtr = sys.malloc(BLOCK_SIZE)
|
|
||||||
else
|
|
||||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
|
||||||
|
|
||||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
|
||||||
|
|
||||||
audio.resetParams(0)
|
|
||||||
audio.purgeQueue(0)
|
|
||||||
audio.setPcmMode(0)
|
|
||||||
audio.setMasterVolume(0, 255)
|
|
||||||
|
|
||||||
let readLength = 1
|
|
||||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
|
||||||
if (interactive) {
|
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printPlayBar(startOffset)
|
|
||||||
|
|
||||||
let queueSize = audio.getPosition(0)
|
|
||||||
if (queueSize <= 1) {
|
|
||||||
|
|
||||||
|
|
||||||
// upload four samples for lag-safely
|
|
||||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
|
||||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
|
||||||
|
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
|
||||||
if (readLength <= 0) {
|
|
||||||
printdbg(`readLength = ${readLength}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE + 8}; readLength: ${readLength}`)
|
|
||||||
|
|
||||||
seqread.readBytes(readLength, readPtr)
|
|
||||||
|
|
||||||
let decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
|
||||||
printdbg(` decodedSampleLength: ${decodedSampleLength}`)
|
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
|
||||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
|
||||||
audio.startSampleUpload(0)
|
|
||||||
|
|
||||||
sys.spin()
|
|
||||||
}
|
|
||||||
|
|
||||||
audio.play(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
|
||||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()}; startOffset + chunkSize = ${startOffset + chunkSize}`)
|
|
||||||
|
|
||||||
|
|
||||||
sys.sleep(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
seqread.skip(chunkSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
|
||||||
printdbg(`remainingBytes2 = ${remainingBytes}`)
|
|
||||||
sys.spin()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (readPtr !== undefined) sys.free(readPtr)
|
||||||
//audio.stop(0)
|
|
||||||
if (readPtr !== undefined) sys.free(readPtr)
|
|
||||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||||
|
if (interactive) gui.audioClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
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
@@ -1,11 +1,14 @@
|
|||||||
if (!_G.TAUT) _G.TAUT = {};
|
if (!_G.TAUT) _G.TAUT = {};
|
||||||
let help = {}
|
let help = {}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -90,7 +93,7 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
|||||||
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggles timeline view mode. W-most detailed, R-most abridged</O>
|
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggles timeline view mode. W-most detailed, R-most abridged</O>
|
||||||
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
||||||
&bul;<b>m</b> : <O>toggles muting of the selected voice</O>
|
&bul;<b>m</b> : <O>toggles muting of the selected voice</O>
|
||||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>changes tick rate of playhead</O>
|
&bul;<b>[</b>&mdot;<b>]</b> : <O>changes tick rate of playhead</O>
|
||||||
|
|
||||||
<b> EDIT MODE</b>
|
<b> EDIT MODE</b>
|
||||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||||
@@ -143,259 +146,12 @@ 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 SCRW = con.getmaxyx()[1]
|
const HRULE = '<s>' + '\u00B3'.repeat(_G.TAUT.HELPMSG_WIDTH) + '</s>\n'
|
||||||
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
|
|
||||||
|
|
||||||
// Display-command palette. taut.js's popup uses (HELP_COL_TEXT on background) as the
|
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
||||||
// default colour pair, so embedded `\x1B[38;5;Nm` codes switch foreground only.
|
// The shared typesetter module owns the palette and the markup expander.
|
||||||
const HELP_COL_TEXT = 239 // popup body default (== colWHITE)
|
function typeset(text) {
|
||||||
const HELP_COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH)
|
||||||
const HELP_COL_BRAND = 211 // first half of "Microtone"
|
|
||||||
const HELP_COL_BRAND_DIM = 239 // second half of "Microtone"
|
|
||||||
|
|
||||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
|
||||||
const ESC_DEFAULT = fgEsc(HELP_COL_TEXT)
|
|
||||||
const ESC_EMPH = fgEsc(HELP_COL_EMPH)
|
|
||||||
const MICROTONE = `${fgEsc(HELP_COL_BRAND)}Micro${fgEsc(HELP_COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
|
||||||
|
|
||||||
// Replace &xxx; entities with their final printable representations.
|
|
||||||
function expandEntities(s) {
|
|
||||||
return s
|
|
||||||
.replaceAll('µtone;', MICROTONE)
|
|
||||||
.replaceAll('&bul;', '\u00F9')
|
|
||||||
.replaceAll('&ddot;', '\u008419u')
|
|
||||||
.replaceAll('&mdot;', '\u00FA')
|
|
||||||
.replaceAll('&updn;', '\u008418u')
|
|
||||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
|
||||||
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
|
||||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
|
||||||
.replaceAll(' ', '\u007F')
|
|
||||||
.replaceAll('­', '')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('&demisharp;', '\u0080\u0081')
|
|
||||||
.replaceAll('♯', '\u0082\u0083')
|
|
||||||
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
|
|
||||||
.replaceAll('&doublesharp;', '\u0086\u0087')
|
|
||||||
.replaceAll('&triplesharp;', '\u0088\u0089')
|
|
||||||
.replaceAll('&quadsharp;', '\u008A\u008B')
|
|
||||||
.replaceAll('&demiflat;', '\u008C\u008D')
|
|
||||||
.replaceAll('♭', '\u008E\u008F')
|
|
||||||
.replaceAll('&sesquiflat;', '\u0090\u0091')
|
|
||||||
.replaceAll('&doubleflat;', '\u0092\u0093')
|
|
||||||
.replaceAll('&tripleflat;', '\u0094\u0095')
|
|
||||||
.replaceAll('&quadflat;', '\u0096\u0097')
|
|
||||||
.replaceAll('&accuptick;', '\u009A')
|
|
||||||
.replaceAll('&accdntick;', '\u009B')
|
|
||||||
.replaceAll('&accupup;', '\u009C')
|
|
||||||
.replaceAll('&accdndn;', '\u009D')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
|
||||||
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
|
|
||||||
// {type:'sp'} - a single soft space (eligible for break/expansion)
|
|
||||||
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
|
|
||||||
//
|
|
||||||
// Width accounting:
|
|
||||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
|
||||||
// - TSVM unicode escapes (`..u`) : 1 visible char
|
|
||||||
// - non-breaking space ( ) : 1 visible char (consumed as part of a word)
|
|
||||||
// - soft hyphen () : dropped (not implemented as a break point)
|
|
||||||
// - everything else : 1 visible char
|
|
||||||
function tokenise(line) {
|
|
||||||
const tokens = []
|
|
||||||
let buf = ''
|
|
||||||
let bufW = 0
|
|
||||||
let i = 0
|
|
||||||
|
|
||||||
const flushWord = () => {
|
|
||||||
if (buf.length > 0) {
|
|
||||||
tokens.push({type: 'word', text: buf, w: bufW})
|
|
||||||
buf = ''
|
|
||||||
bufW = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (i < line.length) {
|
|
||||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
|
||||||
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
|
||||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
|
||||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
|
||||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
|
||||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
|
||||||
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
|
|
||||||
|
|
||||||
const c = line[i]
|
|
||||||
const cc = line.charCodeAt(i)
|
|
||||||
|
|
||||||
if (cc === 0x1B) {
|
|
||||||
// pre-existing ANSI escape - copy verbatim, zero visible width
|
|
||||||
const m = line.indexOf('m', i)
|
|
||||||
const end = (m < 0) ? line.length : m + 1
|
|
||||||
buf += line.slice(i, end)
|
|
||||||
i = end
|
|
||||||
}
|
|
||||||
else if (cc === 0x84) {
|
|
||||||
// TSVM <digits>u escape - copy verbatim, one visible char
|
|
||||||
const u = line.indexOf('u', i)
|
|
||||||
const end = (u < 0) ? line.length : u + 1
|
|
||||||
buf += line.slice(i, end)
|
|
||||||
bufW += 1
|
|
||||||
i = end
|
|
||||||
}
|
|
||||||
else if (c === ' ') {
|
|
||||||
flushWord()
|
|
||||||
tokens.push({type: 'sp'})
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
else if (cc === 0x00AD) {
|
|
||||||
// soft hyphen: drop (no break-point handling for now)
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
buf += c
|
|
||||||
bufW += 1
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushWord()
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build wrapped lines from a token stream then format each one according to alignment.
|
|
||||||
// Returns an array of strings, each exactly `width` visible chars wide (padded with
|
|
||||||
// trailing spaces) so the caller can blit them without further math.
|
|
||||||
function wrapAndAlign(tokens, width, alignment) {
|
|
||||||
const lines = [] // each: {tokens, indent, contentW}
|
|
||||||
let curTokens = []
|
|
||||||
let curW = 0
|
|
||||||
let curIndent = 0
|
|
||||||
let nextIndent = 0 // indent the *next* flushed line should use
|
|
||||||
|
|
||||||
const flushLine = () => {
|
|
||||||
// strip trailing soft spaces
|
|
||||||
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
|
|
||||||
curTokens.pop()
|
|
||||||
curW -= 1
|
|
||||||
}
|
|
||||||
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
|
|
||||||
curTokens = []
|
|
||||||
curW = 0
|
|
||||||
curIndent = nextIndent
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tok of tokens) {
|
|
||||||
if (tok.type === 'anchor') {
|
|
||||||
// anchor opens at the current visible column (accounting for indent)
|
|
||||||
if (tok.open) nextIndent = curIndent + curW
|
|
||||||
else nextIndent = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tok.type === 'sp') {
|
|
||||||
// ignore leading soft spaces on a fresh line
|
|
||||||
if (curW === 0) continue
|
|
||||||
// hard wrap if the line is already at the right edge
|
|
||||||
if (curIndent + curW + 1 > width) { flushLine(); continue }
|
|
||||||
curTokens.push(tok)
|
|
||||||
curW += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// word
|
|
||||||
const tw = tok.w
|
|
||||||
if (curIndent + curW + tw > width) {
|
|
||||||
flushLine()
|
|
||||||
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
|
|
||||||
if (curIndent + tw > width) {
|
|
||||||
curTokens.push(tok)
|
|
||||||
curW += tw
|
|
||||||
flushLine()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
curTokens.push(tok)
|
|
||||||
curW += tw
|
|
||||||
}
|
|
||||||
|
|
||||||
if (curTokens.length > 0 || lines.length === 0) flushLine()
|
|
||||||
|
|
||||||
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLine(line, totalWidth, alignment, isLast) {
|
|
||||||
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
|
|
||||||
|
|
||||||
const indent = ' '.repeat(line.indent)
|
|
||||||
const remaining = totalWidth - line.indent - line.contentW
|
|
||||||
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
|
|
||||||
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
|
|
||||||
|
|
||||||
if (alignment === 'c') {
|
|
||||||
const left = remaining >> 1
|
|
||||||
return indent + pad(left) + flatText() + pad(remaining - left)
|
|
||||||
}
|
|
||||||
if (alignment === 'r') return indent + pad(remaining) + flatText()
|
|
||||||
if (alignment === 'l') return indent + flatText() + pad(remaining)
|
|
||||||
|
|
||||||
// justified: only expand spaces when there's slack and we're not on the
|
|
||||||
// last (or single) wrapped line
|
|
||||||
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
|
|
||||||
|
|
||||||
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
|
|
||||||
if (spaceCount === 0) return indent + flatText() + pad(remaining)
|
|
||||||
|
|
||||||
const baseExtra = (remaining / spaceCount) | 0
|
|
||||||
let leftover = remaining - baseExtra * spaceCount
|
|
||||||
|
|
||||||
let out = indent
|
|
||||||
for (const tok of line.tokens) {
|
|
||||||
if (tok.type === 'sp') {
|
|
||||||
const extra = baseExtra + (leftover > 0 ? 1 : 0)
|
|
||||||
if (leftover > 0) leftover -= 1
|
|
||||||
out += ' '.repeat(1 + extra)
|
|
||||||
} else {
|
|
||||||
out += tok.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
|
|
||||||
// strip its matching close tag, then tokenise + wrap.
|
|
||||||
function typesetSourceLine(line, width) {
|
|
||||||
if (line.length === 0) return [' '.repeat(width)]
|
|
||||||
|
|
||||||
let alignment = 'j' // justified default
|
|
||||||
const startMatch = line.match(/^<([crl])>/i)
|
|
||||||
if (startMatch) {
|
|
||||||
alignment = startMatch[1].toLowerCase()
|
|
||||||
line = line.slice(startMatch[0].length)
|
|
||||||
const closeRe = new RegExp(`</${alignment}>$`, 'i')
|
|
||||||
line = line.replace(closeRe, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = tokenise(line)
|
|
||||||
return wrapAndAlign(tokens, width, alignment)
|
|
||||||
}
|
|
||||||
|
|
||||||
function typesetText(text, width) {
|
|
||||||
text = expandEntities(text)
|
|
||||||
const out = []
|
|
||||||
for (const srcLine of text.split('\n')) {
|
|
||||||
for (const outLine of typesetSourceLine(srcLine, width)) out.push(outLine)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeset(text, customWidth) {
|
|
||||||
let typesetWidth = customWidth
|
|
||||||
if (typesetWidth === undefined) typesetWidth = _G.TAUT.HELPMSG_WIDTH
|
|
||||||
if (typesetWidth === undefined) {
|
|
||||||
const currentPosX = con.getyx()[1] // 1-indexed
|
|
||||||
typesetWidth = SCRW - currentPosX + 1
|
|
||||||
}
|
|
||||||
return typesetText(text, typesetWidth)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let helpMessages = [ // index: taut.js PANEL_NAMES
|
let helpMessages = [ // index: taut.js PANEL_NAMES
|
||||||
@@ -410,7 +166,7 @@ let helpMessages = [ // index: taut.js PANEL_NAMES
|
|||||||
|
|
||||||
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
||||||
help.typeset = typeset
|
help.typeset = typeset
|
||||||
help.COL_TEXT = HELP_COL_TEXT
|
help.COL_TEXT = ts.COL_TEXT
|
||||||
help.COL_EMPH = HELP_COL_EMPH
|
help.COL_EMPH = ts.COL_EMPH
|
||||||
|
|
||||||
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* TAUT Sample Editor
|
* TAUT Sample Editor (stub)
|
||||||
* Sub-program launched by taut.js when the Samples tab is active.
|
* Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by
|
||||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
* the parent; this program draws rows 4+.
|
||||||
*
|
*
|
||||||
* exec_args[1] = path to .taud file
|
* exec_args:
|
||||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
* [1] = path to .taud file
|
||||||
|
* [2] = parent panel index (where to return)
|
||||||
|
* [3] = sample index to preload (-1 if none)
|
||||||
|
*
|
||||||
|
* Sets _G.TAUT.UI.NEXTPANEL on return to request a panel switch back.
|
||||||
*
|
*
|
||||||
* Created by minjaesong on 2026-04-27
|
* Created by minjaesong on 2026-04-27
|
||||||
|
* Stub editing UI added on 2026-05-26
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const win = require("wintex")
|
const win = require("wintex")
|
||||||
|
|
||||||
const PANEL_COUNT = 7
|
const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES
|
||||||
const MY_PANEL = 3 // VIEW_SAMPLES
|
const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1
|
||||||
|
|
||||||
const [SCRH, SCRW] = con.getmaxyx()
|
const [SCRH, SCRW] = con.getmaxyx()
|
||||||
const PANEL_Y = 4
|
const PANEL_Y = 4
|
||||||
@@ -21,38 +26,122 @@ const PANEL_H = SCRH - PANEL_Y
|
|||||||
const colStatus = 253
|
const colStatus = 253
|
||||||
const colContent = 240
|
const colContent = 240
|
||||||
const colHdr = 230
|
const colHdr = 230
|
||||||
|
const colEmph = 211
|
||||||
|
const colDim = 246
|
||||||
|
const colBack = 255
|
||||||
|
const colSel = 41
|
||||||
|
|
||||||
function drawSampleEditContents(wo) {
|
// Stub editor "fields": pretend toolbar. None of these write anything yet.
|
||||||
|
const TOOLS = [
|
||||||
|
{ key: 'L', label: 'Load .raw / .wav from disk' },
|
||||||
|
{ key: 'S', label: 'Save current sample to disk' },
|
||||||
|
{ key: 'D', label: 'Draw waveform freehand' },
|
||||||
|
{ key: 'X', label: 'Crop / trim selection' },
|
||||||
|
{ key: 'R', label: 'Resample' },
|
||||||
|
{ key: 'V', label: 'Reverse' },
|
||||||
|
{ key: 'N', label: 'Normalise to peak' },
|
||||||
|
{ key: 'F', label: 'Fade in / out' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let toolCursor = 0
|
||||||
|
|
||||||
|
function drawSampleEditFrame() {
|
||||||
for (let y = PANEL_Y; y < SCRH; y++) {
|
for (let y = PANEL_Y; y < SCRH; y++) {
|
||||||
con.move(y, 1)
|
con.move(y, 1)
|
||||||
con.color_pair(colContent, 255)
|
con.color_pair(colContent, colBack)
|
||||||
print(' '.repeat(SCRW))
|
print(' '.repeat(SCRW))
|
||||||
}
|
}
|
||||||
|
// Title
|
||||||
con.move(PANEL_Y + 1, 3)
|
con.move(PANEL_Y + 1, 3)
|
||||||
con.color_pair(colHdr, 255)
|
con.color_pair(colHdr, colBack); print('[ Sample Editor ] ')
|
||||||
print('[ Sample Editor ]')
|
con.color_pair(colEmph, colBack); print('Sample ')
|
||||||
con.move(PANEL_Y + 3, 3)
|
con.color_pair(colStatus, colBack)
|
||||||
con.color_pair(colStatus, 255)
|
if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0'))
|
||||||
print('placeholder — not yet implemented')
|
else print('(none)')
|
||||||
|
|
||||||
|
con.move(PANEL_Y + 2, 3)
|
||||||
|
con.color_pair(colDim, colBack)
|
||||||
|
print('stub editor — actions below are placeholders only.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawToolList() {
|
||||||
|
const x = 5
|
||||||
|
const y0 = PANEL_Y + 4
|
||||||
|
con.move(y0, x)
|
||||||
|
con.color_pair(colHdr, colBack); print('Editing actions')
|
||||||
|
con.move(y0 + 1, x)
|
||||||
|
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||||
|
|
||||||
|
for (let i = 0; i < TOOLS.length; i++) {
|
||||||
|
const y = y0 + 3 + i
|
||||||
|
const t = TOOLS[i]
|
||||||
|
const sel = (i === toolCursor)
|
||||||
|
const back = sel ? colSel : colBack
|
||||||
|
con.move(y, x)
|
||||||
|
con.color_pair(colHdr, back); print(' ' + t.key + ' ')
|
||||||
|
con.color_pair(colStatus, back); print(' ')
|
||||||
|
con.color_pair(sel ? colEmph : colStatus, back)
|
||||||
|
const w = SCRW - x - 6
|
||||||
|
const lbl = t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w)
|
||||||
|
print(lbl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing-area placeholder on the right
|
||||||
|
const dx = 38
|
||||||
|
const dy0 = PANEL_Y + 4
|
||||||
|
const dw = SCRW - dx - 2
|
||||||
|
const dh = SCRH - dy0 - 2
|
||||||
|
con.move(dy0, dx)
|
||||||
|
con.color_pair(colHdr, colBack); print('Waveform editor')
|
||||||
|
con.move(dy0 + 1, dx)
|
||||||
|
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||||
|
|
||||||
|
// Empty drawing rectangle made of dots
|
||||||
|
for (let r = 0; r < dh; r++) {
|
||||||
|
con.move(dy0 + 3 + r, dx)
|
||||||
|
con.color_pair(colDim, colBack)
|
||||||
|
if (r === (dh >>> 1)) print('-'.repeat(dw)) // zero line
|
||||||
|
else print(' '.repeat(dw))
|
||||||
|
}
|
||||||
|
con.move(dy0 + 3 + (dh >>> 1) + 1, dx)
|
||||||
|
con.color_pair(colDim, colBack)
|
||||||
|
print('(drawing surface — not yet implemented)')
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawHints() {
|
function drawHints() {
|
||||||
con.move(SCRH, 1)
|
con.move(SCRH, 1)
|
||||||
con.color_pair(colStatus, 255)
|
con.color_pair(colStatus, colBack)
|
||||||
print(' '.repeat(SCRW - 1))
|
print(' '.repeat(SCRW - 1))
|
||||||
con.move(SCRH, 1)
|
con.move(SCRH, 1)
|
||||||
con.color_pair(colHdr, 255); print('Tab ')
|
con.color_pair(colHdr, colBack); print('28u29u ')
|
||||||
con.color_pair(colStatus, 255); print('Panel')
|
con.color_pair(colStatus, colBack); print('Tool ')
|
||||||
|
con.color_pair(colHdr, colBack); print('Enter ')
|
||||||
|
con.color_pair(colStatus, colBack); print('Apply ')
|
||||||
|
con.color_pair(colHdr, colBack); print('Esc/Tab ')
|
||||||
|
con.color_pair(colStatus, colBack); print('Back to viewer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashAction(idx) {
|
||||||
|
const t = TOOLS[idx]
|
||||||
|
if (!t) return
|
||||||
|
con.move(SCRH - 2, 5)
|
||||||
|
con.color_pair(colEmph, colBack)
|
||||||
|
print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8))
|
||||||
}
|
}
|
||||||
|
|
||||||
function sampleEditInput(wo, event) {
|
function sampleEditInput(wo, event) {
|
||||||
// placeholder — no interaction yet
|
// wintex panel input — wired up but the loop below handles keys directly.
|
||||||
}
|
}
|
||||||
|
|
||||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawSampleEditContents, undefined, ()=>{})
|
function drawAll() {
|
||||||
|
drawSampleEditFrame()
|
||||||
|
drawToolList()
|
||||||
|
drawHints()
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawAll, undefined, ()=>{})
|
||||||
|
|
||||||
panel.drawContents()
|
panel.drawContents()
|
||||||
drawHints()
|
|
||||||
|
|
||||||
let done = false
|
let done = false
|
||||||
while (!done) {
|
while (!done) {
|
||||||
@@ -60,17 +149,32 @@ while (!done) {
|
|||||||
if (event[0] !== 'key_down') return
|
if (event[0] !== 'key_down') return
|
||||||
const keysym = event[1]
|
const keysym = event[1]
|
||||||
const keyJustHit = (1 == event[2])
|
const keyJustHit = (1 == event[2])
|
||||||
const shiftDown = (event.includes(59) || event.includes(60))
|
|
||||||
|
|
||||||
if (!keyJustHit) return
|
if (!keyJustHit) return
|
||||||
|
|
||||||
if (keysym === '<TAB>') {
|
if (keysym === '<ESCAPE>' || keysym === '<TAB>') {
|
||||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
_G.TAUT.UI.NEXTPANEL = PARENT_PANEL
|
||||||
done = true
|
done = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.processInput(event)
|
if (keysym === '<UP>') { if (toolCursor > 0) toolCursor--; drawToolList(); return }
|
||||||
|
if (keysym === '<DOWN>') { if (toolCursor < TOOLS.length-1) toolCursor++; drawToolList(); return }
|
||||||
|
|
||||||
|
if (keysym === '\n') {
|
||||||
|
flashAction(toolCursor)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct key shortcuts
|
||||||
|
for (let i = 0; i < TOOLS.length; i++) {
|
||||||
|
if (keysym === TOOLS[i].key.toLowerCase() || keysym === TOOLS[i].key) {
|
||||||
|
toolCursor = i
|
||||||
|
drawToolList()
|
||||||
|
flashAction(i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "tee",
|
||||||
|
"summary": "Copy a pipe's stream to a file and pass it on",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "touch",
|
||||||
|
"summary": "Update a file's modification time, creating it if absent",
|
||||||
|
"symbols": {
|
||||||
|
"noCreate": { "kind": "option", "short": "-c", "summary": "Do not create the file if it does not exist" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["noCreate"] },
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to touch" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "file" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "writeto",
|
||||||
|
"summary": "Write a pipe's stream to a file",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:getopt
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:getopt;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:getopt.js
|
||||||
|
ProperAuthor:David Pacheco
|
||||||
|
ProperDescription:node.js implementation of POSIX getopt() (and then some)
|
||||||
|
Licence:MIT
|
||||||
|
SystemPackagePath:/tvdos/include/getopt.mjs
|
||||||
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libfs
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libfs;
|
||||||
|
HopperRequires:tvdos 1.*;
|
||||||
|
ProperName:LibFS
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:NodeJS-compatible Filesystem module for TVDOS
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/fs.mjs
|
||||||
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libgl
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libgl;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:LibGL
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:TVDOS Graphics Library
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/gl.mjs
|
||||||
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
|
||||||
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libpcm
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libpcm;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:LibPCM
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:PCM decoder for TSVM
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/pcm.mjs
|
||||||
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libpsg
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libpsg;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:LibPSG
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Programmable sound generator library for TSVM
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/psg.mjs
|
||||||
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libseqread
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libseqread;
|
||||||
|
HopperRequires:tvdos 1.*;
|
||||||
|
ProperName:LibSeqread
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Sequentially read files from disk drive
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/seqread.mjs;/tvdos/include/seqreadtape.mjs
|
||||||
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libtaud
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libtaud;
|
||||||
|
HopperRequires:tvdos 1.*;
|
||||||
|
ProperName:LibTaud
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Helper functions for interaction between Taud format and TSVM Tracker
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/taud.mjs
|
||||||
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libterranbasic
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libterranbasic;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:LibTerranBasic
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Terran BASIC runtime helper for compiled programs
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/tbas.mjs
|
||||||
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:microtone
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:microtone;
|
||||||
|
HopperRequires:tvdos 1.*;wintex 1.*;libtaud 1.*;libgl 1.*
|
||||||
|
ProperName:Microtone
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Microtonal tracker for TSVM
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/bin/microtone.alias;/tvdos/bin/taut*
|
||||||
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Hopper Mirror List
|
||||||
|
#
|
||||||
|
# One mirror per non-empty, non-comment line.
|
||||||
|
# Each entry is the remote URL prefix from which Hopper can fetch
|
||||||
|
# <prefix>mirror_manifest
|
||||||
|
# <prefix>filelist
|
||||||
|
# <prefix><package>.hop.per (one per row of filelist)
|
||||||
|
#
|
||||||
|
# `mirror_manifest` declares HopperMirrorName, HopperMirrorMaintainer
|
||||||
|
# and HopperMirrorRemotePrefix; `filelist` is CSV of
|
||||||
|
# packagename,version,hoppermanifest-filename
|
||||||
|
#
|
||||||
|
# Lines starting with `#` and empty lines are ignored.
|
||||||
|
# A trailing slash on the prefix is optional; Hopper will add one
|
||||||
|
# if missing.
|
||||||
|
|
||||||
|
https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/
|
||||||
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:textedit
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:edit;
|
||||||
|
HopperRequires:tvdos 1.*
|
||||||
|
ProperName:edit.js
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:TVDOS default text editor
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/bin/edit.js
|
||||||
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:tvdos
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:tvdos;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:TVDOS
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:TSVM Disk Operating System
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/TVDOS.SYS;/tvdos/hyve.SYS;/tvdos/HSDPADRV.SYS;/tvdos/bin/command.js;/tvdos/sbin/sysctl.js;/tvdos/include/font.mjs;/tvdos/include/keysym.mjs;/tvdos/include/mload.mjs;/tvdos/include/playgui.mjs;/tvdos/include/typesetter.mjs
|
||||||
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:wintex
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:wintex;
|
||||||
|
HopperRequires:
|
||||||
|
ProperName:WinTex
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:TUI window management and renderer
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/wintex.mjs
|
||||||
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:zfm
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:zfm;
|
||||||
|
HopperRequires:tvdos 1.*;wintex 1.*
|
||||||
|
ProperName:ZFM
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Z File Manager - Dual-panel file manager for TVDOS
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/bin/zfm*
|
||||||
171
assets/disk0/tvdos/include/lfs.mjs
Normal file
171
assets/disk0/tvdos/include/lfs.mjs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* lfs.mjs — programmatic extractor for TVDOS Linear File Strip archives.
|
||||||
|
*
|
||||||
|
* let lfs = require("A:/tvdos/include/lfs.mjs")
|
||||||
|
*
|
||||||
|
* // Pull one entry out:
|
||||||
|
* let fd = lfs.extractOne("A:/path/archive.lfs", "wanted.bin")
|
||||||
|
* // → file descriptor for $:/TMP/<random>/wanted.bin
|
||||||
|
*
|
||||||
|
* // Unpack the whole archive:
|
||||||
|
* let dir = lfs.extractAll("A:/path/archive.lfs")
|
||||||
|
* // → directory descriptor for $:/TMP/<random>/
|
||||||
|
*
|
||||||
|
* Both functions accept an `autoDecompress` boolean (default true). When
|
||||||
|
* a payload's first four bytes match the gzip (1F 8B 08 xx) or zstd
|
||||||
|
* (28 B5 2F FD) magic, the payload is inflated through gzip.decomp()
|
||||||
|
* before being written. The check is done on the payload bytes — the
|
||||||
|
* archived filename is irrelevant.
|
||||||
|
*
|
||||||
|
* Both functions require a relative-path archive (one produced by
|
||||||
|
* `lfs -c -r`); fully qualified archives carry drive letters that would
|
||||||
|
* not make sense rerooted under $:/TMP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TMP_ROOT = "$:/TMP"
|
||||||
|
const HASH_ALPHABET = "YBNDRFG8EJKMCPQXOTLVWIS2A345H769"
|
||||||
|
const HASH_LEN = 32
|
||||||
|
const LFS_HEADER = "TVDOSLFS\x01"
|
||||||
|
const LFS_HEADER_LEN = 16
|
||||||
|
const LFS_FLAG_RELATIVE = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
function _makeHash(n) {
|
||||||
|
let s = ""
|
||||||
|
const m = HASH_ALPHABET.length
|
||||||
|
for (let i = 0; i < n; i++) s += HASH_ALPHABET[Math.floor(Math.random() * m)]
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isCompressed(s) {
|
||||||
|
if (s.length < 4) return false
|
||||||
|
const b0 = s.charCodeAt(0), b1 = s.charCodeAt(1)
|
||||||
|
const b2 = s.charCodeAt(2), b3 = s.charCodeAt(3)
|
||||||
|
if (b0 === 0x1f && b1 === 0x8b && b2 === 0x08) return true // gzip
|
||||||
|
if (b0 === 0x28 && b1 === 0xb5 && b2 === 0x2f && b3 === 0xfd) return true // zstd
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function _decompress(payload) {
|
||||||
|
// gzip.decomp transparently handles both gzip and zstd; returns Java byte[].
|
||||||
|
return btostr(gzip.decomp(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
function _readArchive(lfsPath) {
|
||||||
|
const fd = files.open(lfsPath)
|
||||||
|
if (!fd.exists) throw new Error("LFS archive not found: " + lfsPath)
|
||||||
|
if (fd.isDirectory) throw new Error("LFS archive is a directory: " + lfsPath)
|
||||||
|
|
||||||
|
const bytes = fd.sread()
|
||||||
|
try { fd.close() } catch (_) {}
|
||||||
|
|
||||||
|
if (bytes.substring(0, LFS_HEADER.length) !== LFS_HEADER)
|
||||||
|
throw new Error("Not an LFS archive: " + lfsPath)
|
||||||
|
|
||||||
|
const flags = bytes.charCodeAt(11)
|
||||||
|
if ((flags & LFS_FLAG_RELATIVE) === 0)
|
||||||
|
throw new Error("LFS archive does not use relative paths: " + lfsPath)
|
||||||
|
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function _allocTmpDir() {
|
||||||
|
const path = TMP_ROOT + "/" + _makeHash(HASH_LEN)
|
||||||
|
const dir = files.open(path)
|
||||||
|
dir.mkDir()
|
||||||
|
return { fd: dir, path: path }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normPath(p) {
|
||||||
|
return p.replace(/\//g, "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
function _writeFile(destDirPath, archivePath, payload) {
|
||||||
|
const parts = _normPath(archivePath).split("\\").filter(p => p.length > 0)
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
|
||||||
|
const leaf = parts.pop()
|
||||||
|
let curPath = destDirPath
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
curPath = curPath + "/" + parts[i]
|
||||||
|
const cur = files.open(curPath)
|
||||||
|
if (!cur.exists) cur.mkDir()
|
||||||
|
}
|
||||||
|
const outfile = files.open(curPath + "/" + leaf)
|
||||||
|
if (!outfile.exists) outfile.mkFile()
|
||||||
|
outfile.swrite(payload)
|
||||||
|
return outfile
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function extractOne(lfsPath, filename, autoDecompress) {
|
||||||
|
if (autoDecompress === undefined) autoDecompress = true
|
||||||
|
if (filename === undefined || filename === null || filename === "")
|
||||||
|
throw new Error("filename is required")
|
||||||
|
|
||||||
|
const bytes = _readArchive(lfsPath)
|
||||||
|
const needle = _normPath(filename)
|
||||||
|
|
||||||
|
let curs = LFS_HEADER_LEN
|
||||||
|
while (curs < bytes.length) {
|
||||||
|
const fileType = bytes.charCodeAt(curs)
|
||||||
|
const pathlen = (bytes.charCodeAt(curs+1) << 8) | bytes.charCodeAt(curs+2)
|
||||||
|
curs += 3
|
||||||
|
const path = bytes.substring(curs, curs + pathlen)
|
||||||
|
curs += pathlen
|
||||||
|
const filelen = (bytes.charCodeAt(curs) << 24)
|
||||||
|
| (bytes.charCodeAt(curs+1) << 16)
|
||||||
|
| (bytes.charCodeAt(curs+2) << 8)
|
||||||
|
| bytes.charCodeAt(curs+3)
|
||||||
|
curs += 4
|
||||||
|
|
||||||
|
if (_normPath(path) === needle) {
|
||||||
|
let payload = bytes.substring(curs, curs + filelen)
|
||||||
|
if (autoDecompress && _isCompressed(payload)) payload = _decompress(payload)
|
||||||
|
|
||||||
|
const dest = _allocTmpDir()
|
||||||
|
const leaf = needle.split("\\").pop()
|
||||||
|
const outfile = files.open(dest.path + "/" + leaf)
|
||||||
|
if (!outfile.exists) outfile.mkFile()
|
||||||
|
outfile.swrite(payload)
|
||||||
|
return outfile
|
||||||
|
}
|
||||||
|
|
||||||
|
curs += filelen
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("File not found in archive: " + filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function extractAll(lfsPath, autoDecompress) {
|
||||||
|
if (autoDecompress === undefined) autoDecompress = true
|
||||||
|
|
||||||
|
const bytes = _readArchive(lfsPath)
|
||||||
|
const dest = _allocTmpDir()
|
||||||
|
|
||||||
|
let curs = LFS_HEADER_LEN
|
||||||
|
while (curs < bytes.length) {
|
||||||
|
const fileType = bytes.charCodeAt(curs)
|
||||||
|
const pathlen = (bytes.charCodeAt(curs+1) << 8) | bytes.charCodeAt(curs+2)
|
||||||
|
curs += 3
|
||||||
|
const path = bytes.substring(curs, curs + pathlen)
|
||||||
|
curs += pathlen
|
||||||
|
const filelen = (bytes.charCodeAt(curs) << 24)
|
||||||
|
| (bytes.charCodeAt(curs+1) << 16)
|
||||||
|
| (bytes.charCodeAt(curs+2) << 8)
|
||||||
|
| bytes.charCodeAt(curs+3)
|
||||||
|
curs += 4
|
||||||
|
|
||||||
|
let payload = bytes.substring(curs, curs + filelen)
|
||||||
|
if (autoDecompress && _isCompressed(payload)) payload = _decompress(payload)
|
||||||
|
_writeFile(dest.path, path, payload)
|
||||||
|
|
||||||
|
curs += filelen
|
||||||
|
}
|
||||||
|
|
||||||
|
return dest.fd
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
exports = { extractOne, extractAll }
|
||||||
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 }
|
||||||
123
assets/disk0/tvdos/include/net.mjs
Normal file
123
assets/disk0/tvdos/include/net.mjs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* net.mjs — Internet text-fetch helper for TVDOS
|
||||||
|
*
|
||||||
|
* Wraps the HttpModem peripheral (driven by `_TVDOS.DRV.FS.NET`, see
|
||||||
|
* TVDOS.SYS:1001-1034) behind a small, regular-URL-friendly API. The
|
||||||
|
* helper looks up whichever drive letter the boot probe assigned to the
|
||||||
|
* HTTP modem and translates ordinary URLs (`https://host/path`) into the
|
||||||
|
* scheme-without-double-slash form (`https:host/path`) that the modem
|
||||||
|
* expects on the wire.
|
||||||
|
*
|
||||||
|
* Usage
|
||||||
|
* -----
|
||||||
|
* let net = require("A:/tvdos/include/net.mjs")
|
||||||
|
*
|
||||||
|
* if (!net.isAvailable())
|
||||||
|
* printerrln("No HTTP modem attached")
|
||||||
|
*
|
||||||
|
* let body = net.fetchText("https://example.com/index.html")
|
||||||
|
* if (body === null) printerrln("Fetch failed")
|
||||||
|
* else println(body)
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
let _cachedDrive = null
|
||||||
|
|
||||||
|
/** Scan TVDOS drive table for an HTTP-typed device. Returns the drive
|
||||||
|
* letter (e.g. "B") or null. */
|
||||||
|
function _findHttpDrive() {
|
||||||
|
if (typeof _TVDOS === 'undefined' || !_TVDOS.DRIVEINFO) return null
|
||||||
|
if (_cachedDrive !== null && _TVDOS.DRIVEINFO[_cachedDrive] &&
|
||||||
|
_TVDOS.DRIVEINFO[_cachedDrive].type === 'HTTP')
|
||||||
|
return _cachedDrive
|
||||||
|
|
||||||
|
for (let letter in _TVDOS.DRIVEINFO) {
|
||||||
|
let info = _TVDOS.DRIVEINFO[letter]
|
||||||
|
if (info && info.type === 'HTTP') {
|
||||||
|
_cachedDrive = letter
|
||||||
|
return letter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a regular URL into the form the HTTP modem accepts:
|
||||||
|
* - strip the `//` between scheme and authority
|
||||||
|
* - drop any URL fragment
|
||||||
|
* - assume `https` when no scheme is provided
|
||||||
|
*/
|
||||||
|
function _normaliseUrl(url) {
|
||||||
|
if (typeof url !== 'string')
|
||||||
|
throw new TypeError("url must be a string")
|
||||||
|
let s = url.trim()
|
||||||
|
if (s.length === 0) throw new Error("url is empty")
|
||||||
|
|
||||||
|
// Drop fragment — the modem speaks to the server, # is client-side.
|
||||||
|
let hash = s.indexOf('#')
|
||||||
|
if (hash >= 0) s = s.substring(0, hash)
|
||||||
|
|
||||||
|
// scheme://host/path → scheme:host/path
|
||||||
|
let m = s.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):\/\/(.*)$/)
|
||||||
|
if (m) return m[1].toLowerCase() + ':' + m[2]
|
||||||
|
|
||||||
|
// Already in scheme:host/path form (the modem's native shape)
|
||||||
|
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:[^/]/.test(s)) return s
|
||||||
|
|
||||||
|
// No scheme — default to https
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s))
|
||||||
|
return 'https:' + s.replace(/^\/\//, '')
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let net = {}
|
||||||
|
|
||||||
|
/** Returns the drive letter currently bound to the HTTP modem, or null
|
||||||
|
* when no such device is attached. */
|
||||||
|
net.getHttpDrive = function () {
|
||||||
|
return _findHttpDrive()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff an HTTP modem is reachable through TVDOS. */
|
||||||
|
net.isAvailable = function () {
|
||||||
|
return _findHttpDrive() !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translate a URL into the `<drive>:\<modem-url>` form that
|
||||||
|
* `files.open()` would route through `_TVDOS.DRV.FS.NET`. Useful when
|
||||||
|
* another component wants the descriptor directly. Throws if no HTTP
|
||||||
|
* modem is attached. */
|
||||||
|
net.toModemPath = function (url) {
|
||||||
|
let drive = _findHttpDrive()
|
||||||
|
if (drive === null) throw new Error("No HTTP modem device is attached")
|
||||||
|
return drive + ':\\' + _normaliseUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a TVDOS file descriptor backed by the HTTP modem for the given
|
||||||
|
* URL. The descriptor's sread()/bread() trigger the actual fetch.
|
||||||
|
* Throws if no HTTP modem is attached. */
|
||||||
|
net.open = function (url) {
|
||||||
|
return files.open(net.toModemPath(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the body of `url` as a string. Returns the response text on
|
||||||
|
* success, or null when the modem reports a non-zero status (bad URL,
|
||||||
|
* I/O error, etc.). Throws if no HTTP modem is attached. */
|
||||||
|
net.fetchText = function (url) {
|
||||||
|
let fd = net.open(url)
|
||||||
|
let text = fd.sread()
|
||||||
|
try { fd.close() } catch (_) {}
|
||||||
|
return (text === undefined) ? null : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like fetchText, but throws an Error instead of returning null on
|
||||||
|
* fetch failure. */
|
||||||
|
net.fetchTextOrThrow = function (url) {
|
||||||
|
let body = net.fetchText(url)
|
||||||
|
if (body === null) throw new Error("Failed to fetch URL: " + url)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
exports = net
|
||||||
@@ -281,9 +281,997 @@ function printTopBar(status, moreInfo) {
|
|||||||
con.move(1, 1)
|
con.move(1, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Audio player visualiser ─────────────────────────────────────────────────
|
||||||
|
// Shared by playwav/playmp2/playpcm/playtad. Design follows
|
||||||
|
// `assets/playwav_visualiser_design_2_for_tsvm.md`:
|
||||||
|
// * 3-row ASCII wavescope (mid signal envelope) on rows 3..5
|
||||||
|
// * 22-col progress dashes on the right side of the song-title row
|
||||||
|
// * 24-row XY-scope + wavelet-modulated persistence visualiser on rows 7..30
|
||||||
|
// * stereo energy bar on row 31
|
||||||
|
//
|
||||||
|
// The visualiser fuses two displays the design doc calls complementary:
|
||||||
|
// * XY-scope geometry (rotated 45° so L plots along the `\` diagonal and R
|
||||||
|
// along `/`) gives spatial motion and stereo image.
|
||||||
|
// * Haar wavelet features (transient / noise / sustain energies) steer the
|
||||||
|
// beam's behaviour — transients evaporate it and emit sparks, sustained
|
||||||
|
// content lets trails breathe longer, mid noise jitters the beam.
|
||||||
|
//
|
||||||
|
// The wavelet is therefore a *modulator*, not a renderer. No FFT, no pitch
|
||||||
|
// tracking, no per-frame allocation in the hot loop.
|
||||||
|
|
||||||
|
const AG_COLS = 80
|
||||||
|
const AG_ROWS = 32
|
||||||
|
const AG_COL_INSIDE_L = 2
|
||||||
|
const AG_COL_INSIDE_R = 79
|
||||||
|
const AG_LANE_W = 78
|
||||||
|
|
||||||
|
const AG_ROW_TOP_BORDER = 1
|
||||||
|
const AG_ROW_TITLE = 2
|
||||||
|
const AG_ROW_WAVE_TOP = 3
|
||||||
|
const AG_ROW_WAVE_BOT = 5 // 3-row wavescope
|
||||||
|
const AG_ROW_VIS_SEP = 6
|
||||||
|
const AG_ROW_VIS_TOP = 7
|
||||||
|
const AG_ROW_VIS_BOT = 30 // 24-row wavelet visualiser
|
||||||
|
const AG_ROW_STEREO = 31
|
||||||
|
const AG_ROW_BOT_BORDER = 32
|
||||||
|
|
||||||
|
const AG_VIS_H = AG_ROW_VIS_BOT - AG_ROW_VIS_TOP + 1 // 24
|
||||||
|
const AG_VIS_W = AG_LANE_W // 78
|
||||||
|
|
||||||
|
// Palette (TSVM 256-colour indices)
|
||||||
|
const AG_COL_BG = 0
|
||||||
|
const AG_COL_BORDER = 250
|
||||||
|
const AG_COL_LABEL = 220
|
||||||
|
const AG_COL_DIM = 235
|
||||||
|
const AG_COL_TITLE = 230
|
||||||
|
const AG_COL_VALUE = 254
|
||||||
|
const AG_COL_PROG_ON = 226 // bright yellow (matches Taud)
|
||||||
|
|
||||||
|
// Box-drawing constants (CP437)
|
||||||
|
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_SEP_L = 0xC7, AG_SEP_R = 0xB6
|
||||||
|
|
||||||
|
// Density stairs for visualiser + stereo bar
|
||||||
|
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
|
||||||
|
|
||||||
|
// Electron-beam colour ramp. Index 0 = silent (background), last = freshly
|
||||||
|
// drawn beam. Amber-on-black mimics analog vector-scope CRT phosphor — the
|
||||||
|
// glyph shape carries the spatial information, the colour ramp carries age.
|
||||||
|
const AG_BEAM_PAL = [AG_COL_BG, 94, 130, 166, 220]
|
||||||
|
|
||||||
|
// Five wavelet levels (Haar decomp). These are used only as modulators —
|
||||||
|
// they never get rendered as bars. Indexing:
|
||||||
|
// AG_WL_TRANSIENT — top-octave detail (8 kHz..16 kHz at 32 kHz Fs).
|
||||||
|
// Spikes on percussion attacks, vocal consonants, cymbals.
|
||||||
|
// AG_WL_NOISE — upper-mid detail (4..8 kHz). Drives beam jitter.
|
||||||
|
// AG_WL_BODY — mid detail (2..4 kHz).
|
||||||
|
// AG_WL_TONAL — lower-mid detail (1..2 kHz).
|
||||||
|
// AG_WL_BASS — low detail (0.5..1 kHz). Slows the decay (sustain).
|
||||||
|
const AG_N_BANDS = 5
|
||||||
|
const AG_WL_TRANSIENT = 0
|
||||||
|
const AG_WL_NOISE = 1
|
||||||
|
const AG_WL_BODY = 2
|
||||||
|
const AG_WL_TONAL = 3
|
||||||
|
const AG_WL_BASS = 4
|
||||||
|
|
||||||
|
// Stereo bar colour ramp (5 levels) — uses the tonal blue gradient so the
|
||||||
|
// stereo strip reads as the "ground" beneath the wavelet cloud.
|
||||||
|
const AG_STEREO_COL = [AG_COL_DIM, 17, 33, 75, 117]
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// All state lives in module scope so a player just does:
|
||||||
|
// const gui = require('playgui')
|
||||||
|
// gui.audioInit({...})
|
||||||
|
// while (...) { ...; gui.audioFeedPcm(ptr, n); gui.audioRender(); }
|
||||||
|
// gui.audioClose()
|
||||||
|
//
|
||||||
|
// Multiple concurrent players in one process are not supported — but TVDOS
|
||||||
|
// only runs one foreground command at a time, so that's fine.
|
||||||
|
|
||||||
|
const AG_SNAPSHOT_N = 1024 // power of 2; covers ~32 ms at 32 kHz
|
||||||
|
const ag_snapL = new Float32Array(AG_SNAPSHOT_N)
|
||||||
|
const ag_snapR = new Float32Array(AG_SNAPSHOT_N)
|
||||||
|
|
||||||
|
const AG_WORK_N = AG_SNAPSHOT_N // scratch buffers for Haar pyramid
|
||||||
|
const ag_workMid = new Float32Array(AG_WORK_N)
|
||||||
|
const ag_workTmp = new Float32Array(AG_WORK_N >> 1)
|
||||||
|
const ag_bandEnergy = new Float32Array(AG_N_BANDS)
|
||||||
|
|
||||||
|
// Sub-500 Hz residual — drops out of the wavelet modulator set on purpose,
|
||||||
|
// but we keep its RMS around to drive the bass mark.
|
||||||
|
let ag_bassEnergy = 0
|
||||||
|
|
||||||
|
// Persistence buffer — float intensity per cell, plus the glyph last written
|
||||||
|
// there. Decay shrinks intensity each frame; new beam samples overwrite the
|
||||||
|
// glyph and bump intensity.
|
||||||
|
const ag_persist = new Float32Array(AG_VIS_H * AG_VIS_W)
|
||||||
|
const ag_persistGlyph = new Int16Array(AG_VIS_H * AG_VIS_W)
|
||||||
|
|
||||||
|
// Skip-redraw cache — only emit a cell when its glyph or colour changes.
|
||||||
|
const ag_cellGlyph = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1)
|
||||||
|
const ag_cellFg = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1)
|
||||||
|
const ag_waveGlyph = new Int16Array(AG_LANE_W * 3).fill(-1)
|
||||||
|
const ag_stereoGlyph = new Int16Array(AG_LANE_W).fill(-1)
|
||||||
|
const ag_stereoFg = new Int16Array(AG_LANE_W).fill(-1)
|
||||||
|
let ag_lastBassFg = -1
|
||||||
|
|
||||||
|
// Render rate-limiter — playmp2 spins ~32 Hz, playtad ~1 Hz, playwav ~100 Hz
|
||||||
|
// at decode time. Clamp visual refresh to 20 Hz so each caller can spam
|
||||||
|
// audioRender() without worrying about pacing.
|
||||||
|
let ag_lastRenderNs = 0
|
||||||
|
const AG_RENDER_INTERVAL_NS = 50 * 1000 * 1000 // 50 ms
|
||||||
|
|
||||||
|
// Latest progress fraction so we redraw the bar only when it changes.
|
||||||
|
let ag_lastProgressIdx = -1
|
||||||
|
let ag_lastTimeStr = ''
|
||||||
|
|
||||||
|
// Init params held for re-use during render.
|
||||||
|
let ag_initParams = null
|
||||||
|
|
||||||
|
function ag_color(fg, bg) { con.color_pair(fg, bg) }
|
||||||
|
function ag_mvprn(row, col, ch) { con.mvaddch(row, col, ch) }
|
||||||
|
function ag_mvtext(row, col, s) { con.move(row, col); print(s) }
|
||||||
|
|
||||||
|
function ag_pad(n, w) {
|
||||||
|
let s = '' + n
|
||||||
|
while (s.length < w) s = ' ' + s
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function ag_secToReadable(n) {
|
||||||
|
const mins = ('' + ((n / 60) | 0)).padStart(2, '0')
|
||||||
|
const secs = ('' + (n % 60)).padStart(2, '0')
|
||||||
|
return mins + ':' + secs
|
||||||
|
}
|
||||||
|
|
||||||
|
function ag_drawSeparator(row, label) {
|
||||||
|
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||||
|
ag_mvprn(row, 1, AG_SEP_L)
|
||||||
|
for (let x = 2; x < AG_COLS; x++) ag_mvprn(row, x, AG_BX_H)
|
||||||
|
ag_mvprn(row, AG_COLS, AG_SEP_R)
|
||||||
|
if (label) {
|
||||||
|
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||||
|
ag_mvtext(row, 5, ' ' + label + ' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ag_drawFrame() {
|
||||||
|
// Top border with embedded format tag.
|
||||||
|
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_ROW_TOP_BORDER, 1, AG_BX_TL)
|
||||||
|
for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_TOP_BORDER, x, AG_BX_H)
|
||||||
|
ag_mvprn(AG_ROW_TOP_BORDER, AG_COLS, AG_BX_TR)
|
||||||
|
if (ag_initParams.tag) {
|
||||||
|
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||||
|
ag_mvtext(AG_ROW_TOP_BORDER, 4, ' ' + ag_initParams.tag + ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border with exit hint.
|
||||||
|
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_ROW_BOT_BORDER, 1, AG_BX_BL)
|
||||||
|
for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_BOT_BORDER, x, AG_BX_H)
|
||||||
|
ag_mvprn(AG_ROW_BOT_BORDER, AG_COLS, AG_BX_BR)
|
||||||
|
ag_color(AG_COL_DIM, AG_COL_BG)
|
||||||
|
ag_mvtext(AG_ROW_BOT_BORDER, 4, ' Hold BkSp to exit ')
|
||||||
|
|
||||||
|
// Side bars.
|
||||||
|
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||||
|
for (let r = 2; r < AG_ROWS; r++) {
|
||||||
|
ag_mvprn(r, 1, AG_BX_V)
|
||||||
|
ag_mvprn(r, AG_COLS, AG_BX_V)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner separator over the visualiser canvas. The wavescope strip sits
|
||||||
|
// flush against the title row — no separator there.
|
||||||
|
ag_drawSeparator(AG_ROW_VIS_SEP, 'VISUALS')
|
||||||
|
}
|
||||||
|
|
||||||
|
function ag_clearInside(row) {
|
||||||
|
ag_color(AG_COL_DIM, AG_COL_BG)
|
||||||
|
con.move(row, AG_COL_INSIDE_L)
|
||||||
|
print(' '.repeat(AG_LANE_W))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ag_drawTitle() {
|
||||||
|
ag_clearInside(AG_ROW_TITLE)
|
||||||
|
let title = ag_initParams.title || ''
|
||||||
|
// Reserve 24 cols on the right for time string + progress bar.
|
||||||
|
if (title.length > AG_LANE_W - 26) title = title.substring(0, AG_LANE_W - 29) + '...'
|
||||||
|
ag_color(AG_COL_TITLE, AG_COL_BG)
|
||||||
|
ag_mvtext(AG_ROW_TITLE, AG_COL_INSIDE_L + 1, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress: time string + 22-wide dashes ramp (matches playtaud). Called by
|
||||||
|
// the player via audioSetProgress; redraws only when something changed.
|
||||||
|
function ag_drawProgress(progress, elapsedSec, totalSec) {
|
||||||
|
const barW = 22
|
||||||
|
const bx0 = AG_COL_INSIDE_R - barW
|
||||||
|
const filled = Math.round(progress * barW)
|
||||||
|
|
||||||
|
const timeStr = ag_secToReadable(elapsedSec) + '/' + ag_secToReadable(totalSec)
|
||||||
|
if (timeStr !== ag_lastTimeStr) {
|
||||||
|
ag_lastTimeStr = timeStr
|
||||||
|
ag_color(AG_COL_VALUE, AG_COL_BG)
|
||||||
|
ag_mvtext(AG_ROW_TITLE, bx0 - timeStr.length - 1, timeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled === ag_lastProgressIdx) return
|
||||||
|
ag_lastProgressIdx = filled
|
||||||
|
|
||||||
|
for (let i = 0; i < barW; i++) {
|
||||||
|
const lit = i < filled
|
||||||
|
ag_color(lit ? AG_COL_PROG_ON : AG_COL_DIM, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_ROW_TITLE, bx0 + i, lit ? 0x7C /*│*/ : 0x2E /*.*/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PCM ingestion ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// feedPcm copies the most recent SNAPSHOT_N samples from a PCMu8-stereo-
|
||||||
|
// interleaved buffer into our snapshot. `ptr` can be a positive heap address
|
||||||
|
// (LPCM/ADPCM decoded buffer, raw PCM) or a negative peripheral address (TAD
|
||||||
|
// decoded buffer, MP2 mediaDecodedBin) — TSVM peripheral memory grows toward
|
||||||
|
// 0, so reads use a signed step `vec`.
|
||||||
|
|
||||||
|
function audioFeedPcm(ptr, sampleCount) {
|
||||||
|
if (!sampleCount) return
|
||||||
|
const vec = ptr >= 0 ? 1 : -1
|
||||||
|
const inv128 = 1 / 128
|
||||||
|
|
||||||
|
if (sampleCount >= AG_SNAPSHOT_N) {
|
||||||
|
// Take last AG_SNAPSHOT_N samples — discard the rest.
|
||||||
|
const start = sampleCount - AG_SNAPSHOT_N
|
||||||
|
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||||
|
const off = (start + i) * 2 * vec
|
||||||
|
ag_snapL[i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128
|
||||||
|
ag_snapR[i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Shift snapshot left by `sampleCount` and append all new samples.
|
||||||
|
const shift = sampleCount
|
||||||
|
const keep = AG_SNAPSHOT_N - shift
|
||||||
|
for (let i = 0; i < keep; i++) {
|
||||||
|
ag_snapL[i] = ag_snapL[i + shift]
|
||||||
|
ag_snapR[i] = ag_snapR[i + shift]
|
||||||
|
}
|
||||||
|
for (let i = 0; i < shift; i++) {
|
||||||
|
const off = i * 2 * vec
|
||||||
|
ag_snapL[keep + i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128
|
||||||
|
ag_snapR[keep + i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wavelet analysis ───────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// In-place Haar decomposition. Five levels on 1024 samples gives band
|
||||||
|
// passes (at 32 kHz): [8k..16k], [4k..8k], [2k..4k], [1k..2k], [500..1k].
|
||||||
|
// Sub-500 Hz ends up in the approximation and is intentionally dropped —
|
||||||
|
// otherwise the bass would dominate every track.
|
||||||
|
|
||||||
|
function ag_analyseHaar() {
|
||||||
|
// mid = (L + R) / 2
|
||||||
|
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||||
|
ag_workMid[i] = (ag_snapL[i] + ag_snapR[i]) * 0.5
|
||||||
|
}
|
||||||
|
let len = AG_SNAPSHOT_N
|
||||||
|
const SQ_HALF = 0.70710678 // 1/sqrt(2) keeps L2 norm
|
||||||
|
for (let lv = 0; lv < AG_N_BANDS; lv++) {
|
||||||
|
const half = len >> 1
|
||||||
|
let sumSq = 0
|
||||||
|
for (let i = 0; i < half; i++) {
|
||||||
|
const a = ag_workMid[i * 2]
|
||||||
|
const b = ag_workMid[i * 2 + 1]
|
||||||
|
const lo = (a + b) * SQ_HALF
|
||||||
|
const hi = (a - b) * SQ_HALF
|
||||||
|
ag_workMid[i] = lo
|
||||||
|
ag_workTmp[i] = hi
|
||||||
|
sumSq += hi * hi
|
||||||
|
}
|
||||||
|
// Higher-freq levels naturally have weaker energy in music; scale
|
||||||
|
// each band by an empirical gain so all five read at comparable
|
||||||
|
// brightness on typical material.
|
||||||
|
const gain = 3.0 + lv * 1.5
|
||||||
|
const rms = Math.sqrt(sumSq / half) * gain
|
||||||
|
ag_bandEnergy[lv] = rms > 1 ? 1 : rms
|
||||||
|
len = half
|
||||||
|
}
|
||||||
|
// Residual approximation in ag_workMid[0..len-1] holds the sub-500 Hz
|
||||||
|
// energy that the modulator pipeline intentionally discards. Reuse it
|
||||||
|
// to drive the bass mark.
|
||||||
|
let bassSumSq = 0
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const v = ag_workMid[i]
|
||||||
|
bassSumSq += v * v
|
||||||
|
}
|
||||||
|
const bassRms = Math.sqrt(bassSumSq / len) * 1.8
|
||||||
|
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) ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Peak-detected envelope plotted into a 156×6 pixel buffer (2× cell res),
|
||||||
|
// then converted to ASCII glyphs by the mini-AAlib above. Mid-signal only —
|
||||||
|
// 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() {
|
||||||
|
const N = AG_SNAPSHOT_N
|
||||||
|
const IW = AA_WAVE_IW
|
||||||
|
const IH = AA_WAVE_IH
|
||||||
|
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 e = (((c + 1) * samplesPerCol) | 0)
|
||||||
|
let mn = 1.0, mx = -1.0
|
||||||
|
for (let i = s; i < e; i++) {
|
||||||
|
const v = (ag_snapL[i] + ag_snapR[i]) * 0.5
|
||||||
|
if (v < mn) mn = v
|
||||||
|
if (v > mx) mx = v
|
||||||
|
}
|
||||||
|
// [-1, 1] → [0, IH-1]; +1 sits at the top, -1 at the bottom.
|
||||||
|
let yT = ((1 - mx) * yScale + 0.5) | 0
|
||||||
|
let yB = ((1 - mn) * yScale + 0.5) | 0
|
||||||
|
if (yT < 0) yT = 0; else if (yT > IH - 1) yT = IH - 1
|
||||||
|
if (yB < 0) yB = 0; else if (yB > IH - 1) yB = IH - 1
|
||||||
|
for (let y = yT; y <= yB; y++) img[y * IW + c] = 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
aa_render(img, AA_WAVE_W, AA_WAVE_H, ag_waveTb, ag_waveAttr)
|
||||||
|
|
||||||
|
// Blit, skipping cells whose packed (attr<<8 | glyph) key is unchanged.
|
||||||
|
for (let r = 0; r < AA_WAVE_H; r++) {
|
||||||
|
for (let c = 0; c < AA_WAVE_W; c++) {
|
||||||
|
const idx = r * AA_WAVE_W + c
|
||||||
|
const att = ag_waveAttr[idx]
|
||||||
|
const ch = ag_waveTb[idx]
|
||||||
|
const key = (att << 8) | ch
|
||||||
|
if (ag_waveGlyph[idx] === key) continue
|
||||||
|
ag_waveGlyph[idx] = key
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XY-scope persistence visualiser (rows 7..30) ───────────────────────────
|
||||||
|
//
|
||||||
|
// 45°-rotated vectorscope, standard convention. Each PCM sample plots at
|
||||||
|
// col = centre_col + (L − R) · SX
|
||||||
|
// row = centre_row + (L + R) · SY
|
||||||
|
// giving the four canonical traces:
|
||||||
|
// in-phase mono (L = R) → vertical line ((L−R)=0, (L+R) varies)
|
||||||
|
// out-of-phase mono (L=−R) → horizontal line ((L+R)=0, (L−R) varies)
|
||||||
|
// pure L (R = 0) → lower-right diagonal — the `\` axis
|
||||||
|
// pure R (L = 0) → lower-left diagonal — the `/` axis
|
||||||
|
// (Positive mono sits below centre because screen row increases downward.)
|
||||||
|
// The glyph per cell follows channel dominance, the cell's intensity is
|
||||||
|
// bumped on every hit, and a global decay shrinks stale traces back to zero.
|
||||||
|
//
|
||||||
|
// Wavelet energies are used as *modulators* — the design's central idea:
|
||||||
|
//
|
||||||
|
// transient → faster decay + scattered spark emission
|
||||||
|
// bass/tonal → slower decay (sustained content breathes longer)
|
||||||
|
// noise → small jitter on plot position (texture fuzz)
|
||||||
|
//
|
||||||
|
// TSVM terminal cells are ~2:1 (taller than wide); SX is set to ~2×SY so the
|
||||||
|
// scope reads roughly circular under steady mono content.
|
||||||
|
|
||||||
|
const AG_XY_CX = AG_VIS_W >> 1 // centre column inside visualiser canvas
|
||||||
|
const AG_XY_CY = AG_VIS_H >> 1 // centre row
|
||||||
|
const AG_XY_SX = 18 // (L−R) → horizontal extent ±36 cells
|
||||||
|
const AG_XY_SY = 9 // (L+R) → vertical extent ±18 cells
|
||||||
|
|
||||||
|
// Bass mark: 2×2 cell indicator pinned to the centre of the vectorscope so
|
||||||
|
// the bass "subwoofer" sits underneath the beam's pivot point. Half-blocks
|
||||||
|
// form a compact 16×16-pixel "dot" centred in the 16×32-pixel 2×2 area.
|
||||||
|
const AG_BASS_VIS_R0 = AG_XY_CY - 1
|
||||||
|
const AG_BASS_VIS_C0 = AG_XY_CX - 1
|
||||||
|
const AG_BASS_VIS_R1 = AG_BASS_VIS_R0 + 1
|
||||||
|
const AG_BASS_VIS_C1 = AG_BASS_VIS_C0 + 1
|
||||||
|
const AG_BASS_SCR_R = AG_ROW_VIS_TOP + AG_BASS_VIS_R0
|
||||||
|
const AG_BASS_SCR_C = AG_COL_INSIDE_L + AG_BASS_VIS_C0
|
||||||
|
|
||||||
|
// Glyphs.
|
||||||
|
const AG_G_DOT = 0xFA // ·
|
||||||
|
const AG_G_BSL = 0x5C // \\
|
||||||
|
const AG_G_FSL = 0x2F // /
|
||||||
|
const AG_G_XCR = 0x58 // X
|
||||||
|
const AG_G_SPK = 0x2A // *
|
||||||
|
const AG_G_HBAR = 0xC4 // ─
|
||||||
|
|
||||||
|
function ag_updateXYScope() {
|
||||||
|
// Wavelet-driven modulators, all in [0, 1].
|
||||||
|
const transient = ag_bandEnergy[AG_WL_TRANSIENT]
|
||||||
|
const noise = ag_bandEnergy[AG_WL_NOISE]
|
||||||
|
const sustain = ag_bandEnergy[AG_WL_BASS] * 0.6 + ag_bandEnergy[AG_WL_TONAL] * 0.4
|
||||||
|
|
||||||
|
// Decay: base 0.93, longer for sustained content, much shorter for sharp
|
||||||
|
// transients. Clamped so a screaming hi-hat never freezes the trails and
|
||||||
|
// a deep pad never overflows.
|
||||||
|
let decay = 0.93 + 0.05 * (sustain > 1 ? 1 : sustain)
|
||||||
|
- 0.10 * (transient > 1 ? 1 : transient)
|
||||||
|
if (decay < 0.72) decay = 0.72
|
||||||
|
if (decay > 0.985) decay = 0.985
|
||||||
|
|
||||||
|
// Decay all cells.
|
||||||
|
for (let i = 0; i < ag_persist.length; i++) {
|
||||||
|
ag_persist[i] *= decay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plot every sample in the snapshot. Step 1 keeps lines continuous
|
||||||
|
// visually; with 1024 samples per ~50 ms frame, most cells get multiple
|
||||||
|
// hits and the persistence builds the "beam" silhouette.
|
||||||
|
const SX = AG_XY_SX
|
||||||
|
const SY = AG_XY_SY
|
||||||
|
const cx = AG_XY_CX
|
||||||
|
const cy = AG_XY_CY
|
||||||
|
const jitterAmt = noise * 0.06 // noise-driven beam fuzz
|
||||||
|
const plotBoost = 0.05
|
||||||
|
|
||||||
|
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||||
|
const L = ag_snapL[i]
|
||||||
|
const R = ag_snapR[i]
|
||||||
|
const mono = L + R // vertical axis ∈ [-2, 2]
|
||||||
|
const side = L - R // horizontal axis ∈ [-2, 2]
|
||||||
|
// Wavelet-driven jitter is symmetric — substitute a deterministic
|
||||||
|
// pseudo-random by mixing the snapshot index so we don't churn the
|
||||||
|
// shared Math.random() PRNG 1024× per frame.
|
||||||
|
const jx = (((i * 1103515245 + 12345) & 0xFFFF) / 65536 - 0.5) * jitterAmt
|
||||||
|
const jy = (((i * 1664525 + 1013904223) & 0xFFFF) / 65536 - 0.5) * jitterAmt
|
||||||
|
let col = cx + ((side + jx) * SX) | 0
|
||||||
|
let row = cy + ((mono + jy) * SY) | 0
|
||||||
|
if (col < 0 || col >= AG_VIS_W || row < 0 || row >= AG_VIS_H) continue
|
||||||
|
|
||||||
|
const absL = L < 0 ? -L : L
|
||||||
|
const absR = R < 0 ? -R : R
|
||||||
|
let glyph
|
||||||
|
if (absL + absR < 0.04) {
|
||||||
|
glyph = AG_G_DOT
|
||||||
|
} else if (absL > absR * 1.25) {
|
||||||
|
glyph = AG_G_BSL // L-dominant → \
|
||||||
|
} else if (absR > absL * 1.25) {
|
||||||
|
glyph = AG_G_FSL // R-dominant → /
|
||||||
|
} else {
|
||||||
|
glyph = AG_G_XCR // mixed → X
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = row * AG_VIS_W + col
|
||||||
|
let nv = ag_persist[idx] + plotBoost
|
||||||
|
if (nv > 1.0) nv = 1.0
|
||||||
|
ag_persist[idx] = nv
|
||||||
|
ag_persistGlyph[idx] = glyph
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transient spark emission — when high-freq energy peaks, scatter a few
|
||||||
|
// bright `*` glyphs across the canvas. Cap at ~32 sparks to stay cheap.
|
||||||
|
if (transient > 0.32) {
|
||||||
|
const nSparks = ((transient - 0.32) * 60) | 0
|
||||||
|
for (let s = 0; s < nSparks && s < 32; s++) {
|
||||||
|
const c = (Math.random() * AG_VIS_W) | 0
|
||||||
|
const r = (Math.random() * AG_VIS_H) | 0
|
||||||
|
const idx = r * AG_VIS_W + c
|
||||||
|
if (ag_persist[idx] < 0.85) ag_persist[idx] = 0.85
|
||||||
|
ag_persistGlyph[idx] = AG_G_SPK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ag_drawVisualiser() {
|
||||||
|
for (let r = 0; r < AG_VIS_H; r++) {
|
||||||
|
const rowOff = r * AG_VIS_W
|
||||||
|
const screenY = AG_ROW_VIS_TOP + r
|
||||||
|
const inBassRow = (r === AG_BASS_VIS_R0 || r === AG_BASS_VIS_R1)
|
||||||
|
for (let c = 0; c < AG_VIS_W; c++) {
|
||||||
|
// Bass mark owns its 2×2 cells — let ag_drawBassMark() paint them.
|
||||||
|
if (inBassRow && (c === AG_BASS_VIS_C0 || c === AG_BASS_VIS_C1)) continue
|
||||||
|
const idx = rowOff + c
|
||||||
|
const e = ag_persist[idx]
|
||||||
|
let levelIdx = (e * 5) | 0
|
||||||
|
if (levelIdx > 4) levelIdx = 4
|
||||||
|
if (levelIdx < 0) levelIdx = 0
|
||||||
|
const glyph = (levelIdx === 0) ? 0x20 : ag_persistGlyph[idx]
|
||||||
|
const fg = AG_BEAM_PAL[levelIdx]
|
||||||
|
if (ag_cellGlyph[idx] === glyph && ag_cellFg[idx] === fg) continue
|
||||||
|
ag_cellGlyph[idx] = glyph
|
||||||
|
ag_cellFg[idx] = fg
|
||||||
|
ag_color(fg, AG_COL_BG)
|
||||||
|
ag_mvprn(screenY, AG_COL_INSIDE_L + c, glyph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bass mark (rows 29-30, cols 2-3) ───────────────────────────────────────
|
||||||
|
// Brightness-only indicator driven by the sub-500 Hz residual of the Haar
|
||||||
|
// pyramid. Uses indices 1..4 of the beam palette so the dot never falls all
|
||||||
|
// the way to background — a quiet track still shows a faint amber ember.
|
||||||
|
|
||||||
|
function ag_drawBassMark() {
|
||||||
|
let idx = (ag_bassEnergy * 4) | 0
|
||||||
|
if (idx > 3) idx = 3
|
||||||
|
if (idx < 0) idx = 0
|
||||||
|
const fg = AG_BEAM_PAL[idx + 1]
|
||||||
|
if (fg === ag_lastBassFg) return
|
||||||
|
ag_lastBassFg = fg
|
||||||
|
ag_color(fg, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C, 0xDC)
|
||||||
|
ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C + 1, 0xDC)
|
||||||
|
ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C, 0xDF)
|
||||||
|
ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C + 1, 0xDF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stereo energy bar (row 31) ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Same idea as playtaud.drawStereo() but driven by raw PCM: for each sample,
|
||||||
|
// pan = side/|mid| → bin index, energy = sqrt(|mid|+|side|). Gaussian-ish
|
||||||
|
// 7-cell spread so individual sample clusters read as bars, not single spikes.
|
||||||
|
|
||||||
|
function ag_drawStereo() {
|
||||||
|
const W = AG_LANE_W
|
||||||
|
const bins = new Float32Array(W)
|
||||||
|
const N = AG_SNAPSHOT_N
|
||||||
|
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const L = ag_snapL[i]
|
||||||
|
const R = ag_snapR[i]
|
||||||
|
const mid = (L + R) * 0.5
|
||||||
|
const side = (L - R) * 0.5
|
||||||
|
const absM = mid < 0 ? -mid : mid
|
||||||
|
const absS = side < 0 ? -side : side
|
||||||
|
// Pan estimate, clamped — `side/|mid|` blows up near silence so we
|
||||||
|
// floor the denominator. This is a coarse stereo image, not a
|
||||||
|
// calibrated readout.
|
||||||
|
let pan = side / (absM + 0.02)
|
||||||
|
if (pan < -1) pan = -1; else if (pan > 1) pan = 1
|
||||||
|
const energy = Math.pow(absM + absS, 0.5)
|
||||||
|
if (energy <= 0) continue
|
||||||
|
|
||||||
|
let col = ((pan + 1) * 0.5 * (W - 1)) | 0
|
||||||
|
if (col < 0) col = 0; else if (col >= W) col = W - 1
|
||||||
|
bins[col] += energy
|
||||||
|
if (col >= 3) bins[col - 3] += energy * 0.05
|
||||||
|
if (col >= 2) bins[col - 2] += energy * 0.3
|
||||||
|
if (col >= 1) bins[col - 1] += energy * 0.75
|
||||||
|
if (col < W - 1) bins[col + 1] += energy * 0.75
|
||||||
|
if (col < W - 2) bins[col + 2] += energy * 0.3
|
||||||
|
if (col < W - 3) bins[col + 3] += energy * 0.05
|
||||||
|
}
|
||||||
|
// Calibrated for "typical" 32 kHz × 1024-sample snapshot at modest level.
|
||||||
|
const norm = 8.0 / N
|
||||||
|
for (let i = 0; i < W; i++) {
|
||||||
|
const v = bins[i] * norm
|
||||||
|
let idx = (v * 1.6) | 0
|
||||||
|
if (idx > 4) idx = 4
|
||||||
|
if (idx < 0) idx = 0
|
||||||
|
const glyph = AG_STAIRS[idx]
|
||||||
|
const fg = AG_STEREO_COL[idx]
|
||||||
|
if (ag_stereoGlyph[i] === glyph && ag_stereoFg[i] === fg) continue
|
||||||
|
ag_stereoGlyph[i] = glyph
|
||||||
|
ag_stereoFg[i] = fg
|
||||||
|
ag_color(fg, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_ROW_STEREO, AG_COL_INSIDE_L + i, glyph)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// audioInit({ title, tag }): paint the static frame.
|
||||||
|
// title : song title shown on row 2 (left)
|
||||||
|
// tag : 3-5 char format label embedded in the top border (e.g. "WAV", "MP2")
|
||||||
|
//
|
||||||
|
// audioFeedPcm(ptr, sampleCount): hand the visualiser a fresh slice of
|
||||||
|
// PCMu8-stereo-interleaved samples (typically the freshly decoded chunk).
|
||||||
|
//
|
||||||
|
// audioSetProgress(progress, elapsedSec, totalSec): update the title-row
|
||||||
|
// progress bar. Cheap — only redraws on change.
|
||||||
|
//
|
||||||
|
// audioRender(): repaint wavescope + visualiser + stereo bar from the latest
|
||||||
|
// snapshot. Internally rate-limited to ~20 Hz so callers can invoke
|
||||||
|
// liberally without juggling frame timing.
|
||||||
|
//
|
||||||
|
// audioClose(): restore cursor + move out of the panel for a clean exit.
|
||||||
|
|
||||||
|
function audioInit(params) {
|
||||||
|
ag_initParams = params || {}
|
||||||
|
ag_lastRenderNs = 0
|
||||||
|
ag_lastProgressIdx = -1
|
||||||
|
ag_lastTimeStr = ''
|
||||||
|
for (let i = 0; i < ag_snapL.length; i++) { ag_snapL[i] = 0; ag_snapR[i] = 0 }
|
||||||
|
for (let i = 0; i < ag_persist.length; i++) ag_persist[i] = 0
|
||||||
|
ag_persistGlyph.fill(0x20)
|
||||||
|
ag_cellGlyph.fill(-1); ag_cellFg.fill(-1)
|
||||||
|
ag_waveGlyph.fill(-1)
|
||||||
|
ag_stereoGlyph.fill(-1); ag_stereoFg.fill(-1)
|
||||||
|
ag_bassEnergy = 0
|
||||||
|
ag_lastBassFg = -1
|
||||||
|
|
||||||
|
con.curs_set(0)
|
||||||
|
con.clear()
|
||||||
|
ag_drawFrame()
|
||||||
|
ag_drawTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioSetProgress(progress, elapsedSec, totalSec) {
|
||||||
|
if (progress < 0) progress = 0; else if (progress > 1) progress = 1
|
||||||
|
ag_drawProgress(progress, elapsedSec | 0, totalSec | 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioRender() {
|
||||||
|
const now = sys.nanoTime()
|
||||||
|
if (now - ag_lastRenderNs < AG_RENDER_INTERVAL_NS) return
|
||||||
|
ag_lastRenderNs = now
|
||||||
|
|
||||||
|
ag_analyseHaar()
|
||||||
|
ag_updateXYScope()
|
||||||
|
ag_drawWavescope()
|
||||||
|
ag_drawVisualiser()
|
||||||
|
ag_drawBassMark()
|
||||||
|
ag_drawStereo()
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioClose() {
|
||||||
|
con.move(AG_ROW_BOT_BORDER + 1, 1)
|
||||||
|
con.curs_set(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exit polling ───────────────────────────────────────────────────────────
|
||||||
|
// Mirror the Backspace-to-quit convention already in playtaud.
|
||||||
|
|
||||||
|
function audioIsExitRequested() {
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
return sys.peek(-41) === 67
|
||||||
|
}
|
||||||
|
|
||||||
exports = {
|
exports = {
|
||||||
clearSubtitleArea,
|
clearSubtitleArea,
|
||||||
displaySubtitle,
|
displaySubtitle,
|
||||||
printTopBar,
|
printTopBar,
|
||||||
printBottomBar
|
printBottomBar,
|
||||||
|
audioInit,
|
||||||
|
audioFeedPcm,
|
||||||
|
audioSetProgress,
|
||||||
|
audioRender,
|
||||||
|
audioClose,
|
||||||
|
audioIsExitRequested
|
||||||
}
|
}
|
||||||
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)
|
||||||
|
|||||||
337
assets/disk0/tvdos/include/typesetter.mjs
Normal file
337
assets/disk0/tvdos/include/typesetter.mjs
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/*
|
||||||
|
* typesetter.mjs - Rich-text typesetter for TVDOS console output.
|
||||||
|
*
|
||||||
|
* Wraps and aligns text using a tiny markup language. Originally lifted
|
||||||
|
* out of taut_helpmsg.js so other tools (motd, help popups, ...) can
|
||||||
|
* share the same formatter.
|
||||||
|
*
|
||||||
|
* Markup
|
||||||
|
* ------
|
||||||
|
* <b>...</b> emphasised foreground colour
|
||||||
|
* <b>...</b> de-emphasised foreground colour
|
||||||
|
* <c>...</c> centre-align this source line
|
||||||
|
* <r>...</r> right-align this source line
|
||||||
|
* <l>...</l> left-align this source line
|
||||||
|
* <o>...</o> virtual typesetting box. Left anchor is the cursor
|
||||||
|
* column at the open tag, right anchor is the wrap edge.
|
||||||
|
* default alignment is fully justified (override per-call via opts).
|
||||||
|
*
|
||||||
|
* Entities
|
||||||
|
* --------
|
||||||
|
* µtone; "Microtone" wordmark
|
||||||
|
* &bul; &ddot; &mdot; bullet glyphs
|
||||||
|
* &updn; &udlr; arrow glyphs
|
||||||
|
* &keyoffsym; ¬ecutsym;
|
||||||
|
* &demisharp; ♯ &sesquisharp; &doublesharp; &triplesharp; &quadsharp;
|
||||||
|
* &demiflat; ♭ &sesquiflat; &doubleflat; &tripleflat; &quadflat;
|
||||||
|
* &accuptick; &accdntick; &accupup; &accdndn;
|
||||||
|
* non-breaking space
|
||||||
|
* ­ soft hyphen (currently dropped)
|
||||||
|
* < > literal angle brackets
|
||||||
|
*
|
||||||
|
* Usage
|
||||||
|
* -----
|
||||||
|
* let ts = require("typesetter")
|
||||||
|
* let lines = ts.typeset(text, width) // array of width-wide strings
|
||||||
|
* let lines = ts.typeset(text) // width = rest of current row
|
||||||
|
* let lines = ts.typeset(text, width, { defaultAlign: 'l' })
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Palette / ANSI helpers
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const COL_TEXT = 239 // popup body default (== colWHITE)
|
||||||
|
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||||
|
const COL_DEEMPH = 248 // <s>...</s> unhighlight
|
||||||
|
const COL_BRAND = 211 // first half of "Microtone"
|
||||||
|
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||||
|
|
||||||
|
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||||
|
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||||
|
const ESC_EMPH = fgEsc(COL_EMPH)
|
||||||
|
const ESC_DEEMPH = fgEsc(COL_DEEMPH)
|
||||||
|
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||||
|
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Entity expansion
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Replace &xxx; entities with their final printable representations.
|
||||||
|
function expandEntities(s) {
|
||||||
|
return s
|
||||||
|
.replaceAll('µtone;', MICROTONE)
|
||||||
|
.replaceAll('&bul;', '\u00F9')
|
||||||
|
.replaceAll('&ddot;', '\u008419u')
|
||||||
|
.replaceAll('&mdot;', '\u00FA')
|
||||||
|
.replaceAll('&updn;', '\u008418u')
|
||||||
|
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||||
|
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
||||||
|
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||||
|
.replaceAll(' ', '\u007F')
|
||||||
|
.replaceAll('­', '')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('&demisharp;', '\u0080\u0081')
|
||||||
|
.replaceAll('♯', '\u0082\u0083')
|
||||||
|
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
|
||||||
|
.replaceAll('&doublesharp;', '\u0086\u0087')
|
||||||
|
.replaceAll('&triplesharp;', '\u0088\u0089')
|
||||||
|
.replaceAll('&quadsharp;', '\u008A\u008B')
|
||||||
|
.replaceAll('&demiflat;', '\u008C\u008D')
|
||||||
|
.replaceAll('♭', '\u008E\u008F')
|
||||||
|
.replaceAll('&sesquiflat;', '\u0090\u0091')
|
||||||
|
.replaceAll('&doubleflat;', '\u0092\u0093')
|
||||||
|
.replaceAll('&tripleflat;', '\u0094\u0095')
|
||||||
|
.replaceAll('&quadflat;', '\u0096\u0097')
|
||||||
|
.replaceAll('&accuptick;', '\u009A')
|
||||||
|
.replaceAll('&accdntick;', '\u009B')
|
||||||
|
.replaceAll('&accupup;', '\u009C')
|
||||||
|
.replaceAll('&accdndn;', '\u009D')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Tokeniser
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
||||||
|
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
|
||||||
|
// {type:'sp'} - a single soft space (eligible for break/expansion)
|
||||||
|
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
|
||||||
|
//
|
||||||
|
// Width accounting:
|
||||||
|
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||||
|
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
|
||||||
|
// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word)
|
||||||
|
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
|
||||||
|
// - everything else : 1 visible char
|
||||||
|
function tokenise(line) {
|
||||||
|
const tokens = []
|
||||||
|
let buf = ''
|
||||||
|
let bufW = 0
|
||||||
|
let i = 0
|
||||||
|
|
||||||
|
const flushWord = () => {
|
||||||
|
if (buf.length > 0) {
|
||||||
|
tokens.push({type: 'word', text: buf, w: bufW})
|
||||||
|
buf = ''
|
||||||
|
bufW = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < line.length) {
|
||||||
|
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
||||||
|
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||||
|
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||||
|
if (line.slice(i, i + 3) === '<s>') { buf += ESC_DEEMPH; i += 3; continue }
|
||||||
|
if (line.slice(i, i + 4) === '</s>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||||
|
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||||
|
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||||
|
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||||
|
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
|
||||||
|
|
||||||
|
const c = line[i]
|
||||||
|
const cc = line.charCodeAt(i)
|
||||||
|
|
||||||
|
if (cc === 0x1B) {
|
||||||
|
// pre-existing ANSI escape - copy verbatim, zero visible width
|
||||||
|
const m = line.indexOf('m', i)
|
||||||
|
const end = (m < 0) ? line.length : m + 1
|
||||||
|
buf += line.slice(i, end)
|
||||||
|
i = end
|
||||||
|
}
|
||||||
|
else if (cc === 0x84) {
|
||||||
|
// TSVM \u0084<digits>u escape - copy verbatim, one visible char
|
||||||
|
const u = line.indexOf('u', i)
|
||||||
|
const end = (u < 0) ? line.length : u + 1
|
||||||
|
buf += line.slice(i, end)
|
||||||
|
bufW += 1
|
||||||
|
i = end
|
||||||
|
}
|
||||||
|
else if (c === ' ') {
|
||||||
|
flushWord()
|
||||||
|
tokens.push({type: 'sp'})
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
else if (cc === 0x00AD) {
|
||||||
|
// soft hyphen: drop (no break-point handling for now)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
buf += c
|
||||||
|
bufW += 1
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushWord()
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Line builder
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Build wrapped lines from a token stream then format each one according to alignment.
|
||||||
|
// Returns an array of strings, each exactly `width` visible chars wide (padded with
|
||||||
|
// trailing spaces) so the caller can blit them without further math.
|
||||||
|
function wrapAndAlign(tokens, width, alignment) {
|
||||||
|
const lines = [] // each: {tokens, indent, contentW}
|
||||||
|
let curTokens = []
|
||||||
|
let curW = 0
|
||||||
|
let curIndent = 0
|
||||||
|
let nextIndent = 0 // indent the *next* flushed line should use
|
||||||
|
|
||||||
|
const flushLine = () => {
|
||||||
|
// strip trailing soft spaces
|
||||||
|
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
|
||||||
|
curTokens.pop()
|
||||||
|
curW -= 1
|
||||||
|
}
|
||||||
|
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
|
||||||
|
curTokens = []
|
||||||
|
curW = 0
|
||||||
|
curIndent = nextIndent
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (tok.type === 'anchor') {
|
||||||
|
// anchor opens at the current visible column (accounting for indent)
|
||||||
|
if (tok.open) nextIndent = curIndent + curW
|
||||||
|
else nextIndent = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tok.type === 'sp') {
|
||||||
|
// ignore leading soft spaces on a fresh line
|
||||||
|
if (curW === 0) continue
|
||||||
|
// hard wrap if the line is already at the right edge
|
||||||
|
if (curIndent + curW + 1 > width) { flushLine(); continue }
|
||||||
|
curTokens.push(tok)
|
||||||
|
curW += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// word
|
||||||
|
const tw = tok.w
|
||||||
|
if (curIndent + curW + tw > width) {
|
||||||
|
flushLine()
|
||||||
|
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
|
||||||
|
if (curIndent + tw > width) {
|
||||||
|
curTokens.push(tok)
|
||||||
|
curW += tw
|
||||||
|
flushLine()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curTokens.push(tok)
|
||||||
|
curW += tw
|
||||||
|
}
|
||||||
|
|
||||||
|
if (curTokens.length > 0 || lines.length === 0) flushLine()
|
||||||
|
|
||||||
|
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLine(line, totalWidth, alignment, isLast) {
|
||||||
|
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
|
||||||
|
|
||||||
|
const indent = ' '.repeat(line.indent)
|
||||||
|
const remaining = totalWidth - line.indent - line.contentW
|
||||||
|
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
|
||||||
|
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
|
||||||
|
|
||||||
|
if (alignment === 'c') {
|
||||||
|
const left = remaining >> 1
|
||||||
|
return indent + pad(left) + flatText() + pad(remaining - left)
|
||||||
|
}
|
||||||
|
if (alignment === 'r') return indent + pad(remaining) + flatText()
|
||||||
|
if (alignment === 'l') return indent + flatText() + pad(remaining)
|
||||||
|
|
||||||
|
// justified: only expand spaces when there's slack and we're not on the
|
||||||
|
// last (or single) wrapped line
|
||||||
|
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
|
||||||
|
|
||||||
|
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
|
||||||
|
if (spaceCount === 0) return indent + flatText() + pad(remaining)
|
||||||
|
|
||||||
|
const baseExtra = (remaining / spaceCount) | 0
|
||||||
|
let leftover = remaining - baseExtra * spaceCount
|
||||||
|
|
||||||
|
let out = indent
|
||||||
|
for (const tok of line.tokens) {
|
||||||
|
if (tok.type === 'sp') {
|
||||||
|
const extra = baseExtra + (leftover > 0 ? 1 : 0)
|
||||||
|
if (leftover > 0) leftover -= 1
|
||||||
|
out += ' '.repeat(1 + extra)
|
||||||
|
} else {
|
||||||
|
out += tok.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
|
||||||
|
// strip its matching close tag, then tokenise + wrap.
|
||||||
|
function typesetSourceLine(line, width, defaultAlign) {
|
||||||
|
if (line.length === 0) return [' '.repeat(width)]
|
||||||
|
|
||||||
|
let alignment = defaultAlign || 'j' // justified default
|
||||||
|
const startMatch = line.match(/^<([crl])>/i)
|
||||||
|
if (startMatch) {
|
||||||
|
alignment = startMatch[1].toLowerCase()
|
||||||
|
line = line.slice(startMatch[0].length)
|
||||||
|
const closeRe = new RegExp(`</${alignment}>$`, 'i')
|
||||||
|
line = line.replace(closeRe, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = tokenise(line)
|
||||||
|
return wrapAndAlign(tokens, width, alignment)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typesetText(text, width, defaultAlign) {
|
||||||
|
text = expandEntities(text)
|
||||||
|
const out = []
|
||||||
|
for (const srcLine of text.split('\n')) {
|
||||||
|
for (const outLine of typesetSourceLine(srcLine, width, defaultAlign)) out.push(outLine)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience entry: `typeset(text)` defaults the wrap width to "rest of current row".
|
||||||
|
// `opts` may be `{ defaultAlign: 'l' | 'c' | 'r' | 'j' }`.
|
||||||
|
function typeset(text, customWidth, opts) {
|
||||||
|
let typesetWidth = customWidth
|
||||||
|
if (typesetWidth === undefined) {
|
||||||
|
const SCRW = con.getmaxyx()[1]
|
||||||
|
const currentPosX = con.getyx()[1] // 1-indexed
|
||||||
|
typesetWidth = SCRW - currentPosX + 1
|
||||||
|
}
|
||||||
|
let defaultAlign = (opts && opts.defaultAlign) || 'j'
|
||||||
|
return typesetText(text, typesetWidth, defaultAlign)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Module exports
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
typeset,
|
||||||
|
typesetText,
|
||||||
|
typesetSourceLine,
|
||||||
|
tokenise,
|
||||||
|
expandEntities,
|
||||||
|
fgEsc,
|
||||||
|
COL_TEXT,
|
||||||
|
COL_EMPH,
|
||||||
|
COL_BRAND,
|
||||||
|
COL_BRAND_DIM,
|
||||||
|
ESC_DEFAULT,
|
||||||
|
ESC_EMPH,
|
||||||
|
ESC_DEEMPH,
|
||||||
|
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.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[1]}u`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -180,4 +174,769 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
|||||||
return [currentCursorPos, currentScrollPos]
|
return [currentCursorPos, currentScrollPos]
|
||||||
}
|
}
|
||||||
|
|
||||||
exports = { WindowObject, scrollVert, scrollHorz }
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal dialog with optional body text, input fields, a scrollable selection
|
||||||
|
// list, and OK/Cancel-style buttons. Layout from top to bottom:
|
||||||
|
// title bar, message, fields, list, buttons.
|
||||||
|
//
|
||||||
|
// opts = {
|
||||||
|
// title: string,
|
||||||
|
// message: string | string[]?, -- optional body text drawn above fields/list
|
||||||
|
// drawFrame: function(wo)?, -- override for the window-frame painter;
|
||||||
|
// same contract as WindowObject's
|
||||||
|
// `drawFrame` slot. Useful when the caller
|
||||||
|
// wants its own border / title styling.
|
||||||
|
//
|
||||||
|
// fields: [{label, initial?, width, maxLength?}, ...] -- omit / [] for no input
|
||||||
|
// field. Label does NOT get auto-colon.
|
||||||
|
// `maxLength` caps insertable chars
|
||||||
|
// (default: width * 4).
|
||||||
|
//
|
||||||
|
// list: { -- optional vertical selection list
|
||||||
|
// items: [{label, ...}, ...], -- arbitrary user objects; only `label`
|
||||||
|
// is read by the default renderer.
|
||||||
|
// height: number, -- visible row count.
|
||||||
|
// width: number?, -- inner width override (default: popup w-4).
|
||||||
|
// cursor: number?, -- initial cursor row (default: first selectable).
|
||||||
|
// selectable: function(item, i)->bool?, -- default: every item selectable. Non-
|
||||||
|
// selectable rows are skipped by arrow keys.
|
||||||
|
// When NO row is selectable, arrow / PgUp
|
||||||
|
// / PgDn scroll the view instead.
|
||||||
|
// renderItem: function(ctx)?, -- per-row painter; ctx exposes
|
||||||
|
// { y, x, w, item, idx, isCursor, focused,
|
||||||
|
// listBg, selBg, fg, hlFg, dimFg }.
|
||||||
|
// Default prints `item.label`.
|
||||||
|
// onActivate: function(item, i, key)?, -- fired on Enter ('\n') / Space (' ')
|
||||||
|
// / left-click ('click'); return an
|
||||||
|
// action string to close the dialog,
|
||||||
|
// or null to stay open.
|
||||||
|
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
||||||
|
// scrollbarChars: number[6]?, -- glyph codes for the scrollbar:
|
||||||
|
// [troughTopEmpty, troughMidEmpty,
|
||||||
|
// troughBotEmpty, troughTopFilled,
|
||||||
|
// troughMidFilled, troughBotFilled].
|
||||||
|
// Default [0xBA,0xBA,0xBA,0xDB,0xDB,0xDB]
|
||||||
|
// (CP437-safe). Callers with a custom
|
||||||
|
// charset (e.g. taut) pass their own.
|
||||||
|
// drawWell: bool?, -- draw the list background
|
||||||
|
// bg: number?, -- list background colour (default 242).
|
||||||
|
// },
|
||||||
|
//
|
||||||
|
// buttons: [{label, action, default?}, ...] -- defaults to [OK, Cancel] (+ Delete
|
||||||
|
// if `allowDelete:true`)
|
||||||
|
// allowDelete: bool, -- inserts a Delete button (fsh compat)
|
||||||
|
// colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?, listBg?, listSelBg?}
|
||||||
|
// -- per-call overrides
|
||||||
|
// disableKeyRepeat: bool, -- when true, key won't repeat when held down
|
||||||
|
// onKey: function(ks, shiftDown, ctx)?, -- escape hatch for callers that need
|
||||||
|
// extra key bindings. Runs BEFORE the
|
||||||
|
// built-in handlers. Return true to
|
||||||
|
// consume the key. `ctx` exposes
|
||||||
|
// { render, close(result),
|
||||||
|
// getListCursor, setListCursor }.
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Returns {action, values, listCursor, listItem}: `action` is the chosen button's
|
||||||
|
// `action` or the value returned from `onActivate` (default "ok"/"cancel"/"delete"),
|
||||||
|
// or "cancel" on Esc; `values` is the array of field strings in field order;
|
||||||
|
// `listCursor` is the final cursor index (-1 if there is no list); `listItem` is
|
||||||
|
// the item at that index.
|
||||||
|
//
|
||||||
|
// Behaviour:
|
||||||
|
// - Tab / Shift+Tab and arrow Down / Up cycle focus across fields, list, and buttons.
|
||||||
|
// Inside the list, arrow Up / Down move the cursor between selectable rows;
|
||||||
|
// PgUp/PgDn move a page; Home/End jump to the first/last selectable row.
|
||||||
|
// - Left / Right inside a field move the caret; on the list or a button they cycle focus.
|
||||||
|
// - Home / End jump to start / end of the focused field.
|
||||||
|
// - Enter on a field jumps to the next field, then to the first button. Enter
|
||||||
|
// or Space on a button activates it. Enter or Space on a list row invokes
|
||||||
|
// `onActivate(item, idx, key)`; if that returns a string, the dialog closes
|
||||||
|
// with that action.
|
||||||
|
// - Insert at caret. Backspace deletes left of caret; Forward-Del deletes right.
|
||||||
|
// - Blinking caret (`con.curs_set(1)`) is positioned on the focused field and
|
||||||
|
// hidden when the list or a button has focus.
|
||||||
|
// - Mouse: left-click on a button activates it; click on a field puts focus
|
||||||
|
// on that field and positions the caret under the click; click on a list row
|
||||||
|
// moves the cursor (and fires `onActivate` if defined); mouse-wheel inside the
|
||||||
|
// list scrolls it. Mouse hover on a button moves focus to it (the same focus
|
||||||
|
// the keyboard uses).
|
||||||
|
const _dialogScreen = con.getmaxyx()
|
||||||
|
const _dialogPixDim = graphics.getPixelDimension()
|
||||||
|
const _CELL_PW = (_dialogPixDim[0] / _dialogScreen[1]) | 0
|
||||||
|
const _CELL_PH = (_dialogPixDim[1] / _dialogScreen[0]) | 0
|
||||||
|
function _pxToCell(px, py) { return [(py / _CELL_PH | 0) + 1, (px / _CELL_PW | 0) + 1] }
|
||||||
|
|
||||||
|
function showDialog(opts) {
|
||||||
|
const fields = opts.fields || []
|
||||||
|
const values = fields.map(f => (f.initial == null) ? '' : ('' + f.initial))
|
||||||
|
const cursors = values.map(v => v.length)
|
||||||
|
|
||||||
|
let oldFG = con.get_color_fore()
|
||||||
|
let oldBG = con.get_color_back()
|
||||||
|
|
||||||
|
let buttons
|
||||||
|
if (opts.buttons) {
|
||||||
|
buttons = opts.buttons
|
||||||
|
} else {
|
||||||
|
buttons = [{label: 'OK', action: 'ok', default: true}]
|
||||||
|
if (opts.allowDelete) buttons.push({label: 'Delete', action: 'delete'})
|
||||||
|
buttons.push({label: 'Cancel', action: 'cancel'})
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = opts.title || ''
|
||||||
|
const message = opts.message
|
||||||
|
const messageLines = !message ? []
|
||||||
|
: Array.isArray(message) ? message
|
||||||
|
: ('' + message).split('\n')
|
||||||
|
|
||||||
|
const list = opts.list || null
|
||||||
|
const drawWell = list?.drawWell ?? true
|
||||||
|
const c = opts.colours || {}
|
||||||
|
const fg = (c.fg != null) ? c.fg : 254
|
||||||
|
const bg = (c.bg != null) ? c.bg : 244
|
||||||
|
const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240
|
||||||
|
const dimFg = (c.dimFg != null) ? c.dimFg : 249
|
||||||
|
const hlFg = (c.hlFg != null) ? c.hlFg : 240
|
||||||
|
const focusBg = (c.focusBg != null) ? c.focusBg : 253
|
||||||
|
const listBg = (c.listBg != null) ? c.listBg : (drawWell) ? 243 : bg
|
||||||
|
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
||||||
|
|
||||||
|
// List state
|
||||||
|
const listItems = list ? (list.items || []) : []
|
||||||
|
const listSelectable = list && list.selectable ? list.selectable : (() => true)
|
||||||
|
const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0
|
||||||
|
const hasList = !!list
|
||||||
|
const listOnActivate = list ? list.onActivate : null
|
||||||
|
const listBgColour = (list && list.bg != null) ? list.bg : listBg
|
||||||
|
// Scrollbar glyphs: [trough top/mid/bottom empty, then top/mid/bottom filled].
|
||||||
|
// Default is CP437-safe (0xBA track, 0xDB thumb); callers with their own
|
||||||
|
// charset (e.g. taut's 0xBA..0xBF) pass a 6-item override.
|
||||||
|
const listScrollbarChars = (list && Array.isArray(list.scrollbarChars) && list.scrollbarChars.length >= 6)
|
||||||
|
? list.scrollbarChars
|
||||||
|
: [0xBA, 0xBA, 0xBA, 0xDB, 0xDB, 0xDB]
|
||||||
|
function firstSelectable(from, dir) {
|
||||||
|
if (!hasList || listItems.length === 0) return -1
|
||||||
|
let i = from
|
||||||
|
for (let n = 0; n < listItems.length; n++) {
|
||||||
|
if (i >= 0 && i < listItems.length && listSelectable(listItems[i], i)) return i
|
||||||
|
i += dir
|
||||||
|
if (i < 0) i = listItems.length - 1
|
||||||
|
if (i >= listItems.length) i = 0
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
let listCursor = hasList
|
||||||
|
? (list.cursor != null ? list.cursor : firstSelectable(0, +1))
|
||||||
|
: -1
|
||||||
|
let listScroll = 0
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
const buttonGap = 3
|
||||||
|
const maxFieldW = fields.reduce((m, f) => Math.max(m, f.width), 16)
|
||||||
|
const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0)
|
||||||
|
// When the caller pins `list.width`, trust it — string `.length` overcounts
|
||||||
|
// visual width whenever items embed ANSI escapes or TVDOS \x84NNu sequences
|
||||||
|
// (e.g. taut's help popup, whose rows are pre-typeset with fg-colour escapes).
|
||||||
|
const longestItem = hasList && list.width == null
|
||||||
|
? listItems.reduce((m, it) => Math.max(m, (it.label || '').length), 0)
|
||||||
|
: 0
|
||||||
|
const titleW = title.length + 4
|
||||||
|
const btnRowW = buttons.reduce((s, b) => s + b.label.length + 4, 0) + buttonGap * Math.max(0, buttons.length - 1)
|
||||||
|
const listMinW = hasList
|
||||||
|
? (list.width != null ? list.width + 4 : longestItem + 6)
|
||||||
|
: 0
|
||||||
|
const w = 2+Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, listMinW, 22)
|
||||||
|
|
||||||
|
const msgRows = messageLines.length + (messageLines.length > 0 ? 1 : 0)
|
||||||
|
const fieldsBlockH = fields.length * 4
|
||||||
|
const listBlockH = hasList ? listHeight + 2 : 0 // top border + rows + bottom border
|
||||||
|
|
||||||
|
let bodyRows = msgRows
|
||||||
|
if (fields.length > 0) bodyRows += fieldsBlockH + 1 // +1 spacing after fields
|
||||||
|
if (hasList) bodyRows += listBlockH + 1 // +1 spacing after list
|
||||||
|
if (bodyRows === 0) bodyRows = 1 // at least one row above buttons
|
||||||
|
const buttonsRowOff = 1 + bodyRows
|
||||||
|
const h = buttonsRowOff + 2
|
||||||
|
|
||||||
|
const screen = con.getmaxyx()
|
||||||
|
const row = Math.max(2, Math.floor((screen[0] - h) / 2))
|
||||||
|
const col = Math.max(2, Math.floor((screen[1] - w) / 2))
|
||||||
|
|
||||||
|
// Focus layout: 0..fields.length-1 = fields, [+1 = list if present], then buttons.
|
||||||
|
const listFocusIdx = hasList ? fields.length : -1
|
||||||
|
const buttonsFocusBase = fields.length + (hasList ? 1 : 0)
|
||||||
|
const totalFocus = buttonsFocusBase + buttons.length
|
||||||
|
|
||||||
|
// Pick initial focus: explicit default > list > first field > first button.
|
||||||
|
let focusIdx = -1
|
||||||
|
for (let i = 0; i < buttons.length; i++) {
|
||||||
|
if (buttons[i].default) { focusIdx = buttonsFocusBase + i; break }
|
||||||
|
}
|
||||||
|
if (focusIdx < 0) {
|
||||||
|
if (fields.length > 0) focusIdx = 0
|
||||||
|
else if (hasList) focusIdx = listFocusIdx
|
||||||
|
else focusIdx = buttonsFocusBase
|
||||||
|
}
|
||||||
|
let done = null
|
||||||
|
|
||||||
|
function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 }
|
||||||
|
function fieldLabelRow(i) { return row + 1 + msgRows + i * 4 }
|
||||||
|
function fieldBoxRow(i) { return fieldLabelRow(i) + 1 }
|
||||||
|
function fieldContentRow(i) { return fieldLabelRow(i) + 2 }
|
||||||
|
function fieldBoxCol() { return col + 2 }
|
||||||
|
function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } }
|
||||||
|
|
||||||
|
function listBlockTopRow() {
|
||||||
|
return row + 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 0)
|
||||||
|
}
|
||||||
|
function listBlockCol() { return col + 2 }
|
||||||
|
function listBlockWidth() { return w - 4 } // inner content width incl. borders
|
||||||
|
function listContentRow(i) { return listBlockTopRow() + 1 + (i - listScroll) }
|
||||||
|
function listContentCol() { return listBlockCol() + 1 }
|
||||||
|
function listScrollbarNeeded() {
|
||||||
|
if (!hasList) return false
|
||||||
|
if (list.showScrollbar != null) return list.showScrollbar
|
||||||
|
return listItems.length > listHeight
|
||||||
|
}
|
||||||
|
function listContentInnerW() {
|
||||||
|
return listBlockWidth() - 2 - (listScrollbarNeeded() ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonRegions() {
|
||||||
|
let bx = col + Math.floor((w - btnRowW) / 2)
|
||||||
|
return buttons.map(b => {
|
||||||
|
const r = { x: bx, y: row + buttonsRowOff, w: b.label.length + 4 }
|
||||||
|
bx += b.label.length + 4 + buttonGap
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFrameBox() {
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
for (let r = row; r < row + h; r++) {
|
||||||
|
con.move(r, col)
|
||||||
|
print(' '.repeat(w))
|
||||||
|
}
|
||||||
|
const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title, opts.drawFrame)
|
||||||
|
wo.isHighlighted = true
|
||||||
|
wo.titleBack = bg
|
||||||
|
wo.drawFrame()
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMessage() {
|
||||||
|
if (messageLines.length === 0) return
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
for (let i = 0; i < messageLines.length; i++) {
|
||||||
|
con.move(row + 1 + i, col + 2)
|
||||||
|
print(messageLines[i].padEnd(w - 4, ' '))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawField(i) {
|
||||||
|
const f = fields[i]
|
||||||
|
const fbCol = fieldBoxCol()
|
||||||
|
const fbRow = fieldBoxRow(i)
|
||||||
|
const fw = f.width
|
||||||
|
const focused = (focusIdx === i)
|
||||||
|
const frameFg = focused ? fg : dimFg
|
||||||
|
|
||||||
|
// Label
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
con.move(fieldLabelRow(i), fbCol)
|
||||||
|
print(f.label)
|
||||||
|
|
||||||
|
// Top border (3px padding w/ TSVM chr rom)
|
||||||
|
con.color_pair(fieldBg, bg)
|
||||||
|
con.move(fbRow, fbCol)
|
||||||
|
print('\u00EC' + '\u00A9'.repeat(fw) + '\u00ED')
|
||||||
|
|
||||||
|
// Left border (3px padding w/ TSVM chr rom)
|
||||||
|
con.move(fbRow + 1, fbCol)
|
||||||
|
print('\u00AB')
|
||||||
|
|
||||||
|
// the content
|
||||||
|
con.color_pair(fg, fieldBg)
|
||||||
|
const s = fieldScroll(cursors[i], fw)
|
||||||
|
const vis = values[i].substring(s, s + fw)
|
||||||
|
print(vis.padEnd(fw, ' '))
|
||||||
|
|
||||||
|
// Right border (3px padding w/ TSVM chr rom)
|
||||||
|
con.color_pair(fieldBg, bg)
|
||||||
|
con.move(fbRow + 1, fbCol + fw + 1)
|
||||||
|
print('\u00AA')
|
||||||
|
|
||||||
|
// Bottom border (3px padding w/ TSVM chr rom)
|
||||||
|
con.move(fbRow + 2, fbCol)
|
||||||
|
print('\u00F4' + '\u00AC'.repeat(fw) + '\u00F5')
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawList() {
|
||||||
|
if (!hasList) return
|
||||||
|
const lbCol = listBlockCol()
|
||||||
|
const lbRow = listBlockTopRow()
|
||||||
|
const lw = listBlockWidth()
|
||||||
|
const innerW = listContentInnerW()
|
||||||
|
const focused = (focusIdx === listFocusIdx)
|
||||||
|
const frameFg = focused ? fg : dimFg
|
||||||
|
const sbar = listScrollbarNeeded()
|
||||||
|
|
||||||
|
// Top border (drawField style)
|
||||||
|
if (drawWell) {
|
||||||
|
con.color_pair(listBgColour, bg)
|
||||||
|
con.move(lbRow, lbCol)
|
||||||
|
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side borders + rows
|
||||||
|
for (let r = 0; r < listHeight; r++) {
|
||||||
|
if (drawWell) {
|
||||||
|
con.color_pair(listBgColour, bg)
|
||||||
|
con.move(lbRow + 1 + r, lbCol)
|
||||||
|
print('\u00AB')
|
||||||
|
con.move(lbRow + 1 + r, lbCol + lw - 1)
|
||||||
|
print('\u00AA')
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = listScroll + r
|
||||||
|
con.move(lbRow + 1 + r, lbCol + 1)
|
||||||
|
if (idx >= listItems.length) {
|
||||||
|
con.color_pair(fg, listBgColour)
|
||||||
|
print(' '.repeat(innerW))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const it = listItems[idx]
|
||||||
|
const isCursor = (idx === listCursor)
|
||||||
|
const ctx = {
|
||||||
|
y: lbRow + 1 + r,
|
||||||
|
x: lbCol + 1,
|
||||||
|
w: innerW,
|
||||||
|
item: it,
|
||||||
|
idx: idx,
|
||||||
|
isCursor: isCursor,
|
||||||
|
focused: focused,
|
||||||
|
listBg: listBgColour,
|
||||||
|
selBg: listSelBg,
|
||||||
|
fg: fg,
|
||||||
|
hlFg: hlFg,
|
||||||
|
dimFg: dimFg,
|
||||||
|
}
|
||||||
|
if (list.renderItem) {
|
||||||
|
list.renderItem(ctx)
|
||||||
|
} else {
|
||||||
|
const useFg = (isCursor && focused) ? hlFg : fg
|
||||||
|
const useBg = (isCursor && focused) ? listSelBg : listBgColour
|
||||||
|
con.color_pair(useFg, useBg)
|
||||||
|
const label = (it.label || '').substring(0, innerW - 1)
|
||||||
|
print(' ' + label.padEnd(innerW - 1, ' '))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar column
|
||||||
|
if (sbar) {
|
||||||
|
con.color_pair(dimFg, listBgColour)
|
||||||
|
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
||||||
|
const maxScroll = Math.max(1, listItems.length - listHeight)
|
||||||
|
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
||||||
|
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
|
||||||
|
// filled (thumb) variant over the empty (trough) one.
|
||||||
|
const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1
|
||||||
|
con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
if (drawWell) {
|
||||||
|
con.color_pair(listBgColour, bg)
|
||||||
|
con.move(lbRow + 1 + listHeight, lbCol)
|
||||||
|
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawButton(i, regions) {
|
||||||
|
const b = buttons[i]
|
||||||
|
const bIdx = buttonsFocusBase + i
|
||||||
|
const focused = (focusIdx === bIdx)
|
||||||
|
const r = regions[i]
|
||||||
|
const useFg = focused ? hlFg : fg
|
||||||
|
const useBg = focused ? focusBg : bg
|
||||||
|
con.color_pair(useFg, useBg)
|
||||||
|
con.move(r.y, r.x-1)
|
||||||
|
if (focused) {
|
||||||
|
con.color_pair(useBg, bg)
|
||||||
|
print('\u00DE')
|
||||||
|
con.color_pair(useFg, useBg)
|
||||||
|
print('[ ' + b.label + ' ]')
|
||||||
|
con.color_pair(useBg, bg)
|
||||||
|
print('\u00DD')
|
||||||
|
}
|
||||||
|
else
|
||||||
|
print(' [ ' + b.label + ' ] ')
|
||||||
|
con.color_pair(fg, bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionCaret() {
|
||||||
|
if (focusIdx < fields.length) {
|
||||||
|
const fw = fields[focusIdx].width
|
||||||
|
const s = fieldScroll(cursors[focusIdx], fw)
|
||||||
|
con.move(fieldContentRow(focusIdx), fieldBoxCol() + 1 + (cursors[focusIdx] - s))
|
||||||
|
con.curs_set(1)
|
||||||
|
} else {
|
||||||
|
con.curs_set(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureListCursorVisible() {
|
||||||
|
if (!hasList) return
|
||||||
|
if (listCursor < 0) return
|
||||||
|
if (listCursor < listScroll) listScroll = listCursor
|
||||||
|
else if (listCursor >= listScroll + listHeight) listScroll = listCursor - listHeight + 1
|
||||||
|
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||||
|
if (listScroll > maxScroll) listScroll = maxScroll
|
||||||
|
if (listScroll < 0) listScroll = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollListBy(dir) {
|
||||||
|
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||||
|
let s = listScroll + dir
|
||||||
|
if (s < 0) s = 0
|
||||||
|
if (s > maxScroll) s = maxScroll
|
||||||
|
listScroll = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveListCursor(dir) {
|
||||||
|
if (!hasList || listItems.length === 0) return
|
||||||
|
// Scroll the view when nothing in the list is selectable (e.g. a help text body).
|
||||||
|
if (listCursor < 0) { scrollListBy(dir); return }
|
||||||
|
let next = listCursor
|
||||||
|
for (let n = 0; n < listItems.length; n++) {
|
||||||
|
next += dir
|
||||||
|
if (next < 0 || next >= listItems.length) return
|
||||||
|
if (listSelectable(listItems[next], next)) {
|
||||||
|
listCursor = next
|
||||||
|
ensureListCursorVisible()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageListCursor(dir) {
|
||||||
|
if (!hasList || listItems.length === 0) return
|
||||||
|
if (listCursor < 0) { scrollListBy(dir * listHeight); return }
|
||||||
|
let target = listCursor + dir * listHeight
|
||||||
|
if (target < 0) target = 0
|
||||||
|
if (target >= listItems.length) target = listItems.length - 1
|
||||||
|
// Snap to nearest selectable
|
||||||
|
let probe = target
|
||||||
|
const step = dir < 0 ? -1 : 1
|
||||||
|
while (probe >= 0 && probe < listItems.length && !listSelectable(listItems[probe], probe)) probe += step
|
||||||
|
if (probe < 0 || probe >= listItems.length) probe = firstSelectable(target, -step)
|
||||||
|
if (probe >= 0) { listCursor = probe; ensureListCursorVisible() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
drawFrameBox()
|
||||||
|
drawMessage()
|
||||||
|
for (let i = 0; i < fields.length; i++) drawField(i)
|
||||||
|
drawList()
|
||||||
|
const regs = buttonRegions()
|
||||||
|
for (let i = 0; i < buttons.length; i++) drawButton(i, regs)
|
||||||
|
positionCaret()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFocus(dir) {
|
||||||
|
focusIdx = (focusIdx + dir + totalFocus) % totalFocus
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateButton(i) {
|
||||||
|
done = {
|
||||||
|
action: buttons[i].action,
|
||||||
|
values: values.slice(),
|
||||||
|
listCursor: listCursor,
|
||||||
|
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateListItem(idx, key) {
|
||||||
|
if (!hasList || !listOnActivate) return false
|
||||||
|
if (idx < 0 || idx >= listItems.length) return false
|
||||||
|
if (!listSelectable(listItems[idx], idx)) return false
|
||||||
|
const result = listOnActivate(listItems[idx], idx, key)
|
||||||
|
if (result == null) {
|
||||||
|
// Callback consumed the event but kept the dialog open (e.g. radio
|
||||||
|
// toggle); reflect any state changes it made.
|
||||||
|
render()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
done = {
|
||||||
|
action: result,
|
||||||
|
values: values.slice(),
|
||||||
|
listCursor: idx,
|
||||||
|
listItem: listItems[idx],
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitTestMouse(ev) {
|
||||||
|
const cell = _pxToCell(ev[1], ev[2])
|
||||||
|
const cy = cell[0], cx = cell[1]
|
||||||
|
const btnRegs = buttonRegions()
|
||||||
|
for (let i = 0; i < btnRegs.length; i++) {
|
||||||
|
const r = btnRegs[i]
|
||||||
|
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'button', idx: i }
|
||||||
|
}
|
||||||
|
for (let i = 0; i < fields.length; i++) {
|
||||||
|
const r = fieldContentRegion(i)
|
||||||
|
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'field', idx: i, cx: cx, region: r }
|
||||||
|
}
|
||||||
|
if (hasList) {
|
||||||
|
const lbRow = listBlockTopRow()
|
||||||
|
const lbCol = listBlockCol()
|
||||||
|
const innerW = listContentInnerW()
|
||||||
|
if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol + 1 && cx < lbCol + 1 + innerW) {
|
||||||
|
const r = cy - (lbRow + 1)
|
||||||
|
const idx = listScroll + r
|
||||||
|
if (idx >= 0 && idx < listItems.length) return { kind: 'list', idx: idx }
|
||||||
|
}
|
||||||
|
if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol && cx < lbCol + listBlockWidth()) {
|
||||||
|
return { kind: 'listblank' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalCtx = {
|
||||||
|
render: () => render(),
|
||||||
|
close: (result) => {
|
||||||
|
done = Object.assign({
|
||||||
|
action: 'cancel',
|
||||||
|
values: values.slice(),
|
||||||
|
listCursor: listCursor,
|
||||||
|
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||||
|
}, result || {})
|
||||||
|
},
|
||||||
|
getListCursor: () => listCursor,
|
||||||
|
setListCursor: (n) => {
|
||||||
|
if (!hasList) return
|
||||||
|
if (n < 0 || n >= listItems.length) return
|
||||||
|
listCursor = n
|
||||||
|
ensureListCursorVisible()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureListCursorVisible()
|
||||||
|
render()
|
||||||
|
|
||||||
|
let eventJustReceived = true
|
||||||
|
while (done === null) {
|
||||||
|
input.withEvent(ev => {
|
||||||
|
if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
|
||||||
|
eventJustReceived = false; return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev[0] === 'mouse_move') {
|
||||||
|
const hit = hitTestMouse(ev)
|
||||||
|
if (hit && hit.kind === 'button') {
|
||||||
|
const newFocus = buttonsFocusBase + hit.idx
|
||||||
|
if (newFocus !== focusIdx) {
|
||||||
|
focusIdx = newFocus
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev[0] === 'mouse_down') {
|
||||||
|
if (ev[3] !== 1) return
|
||||||
|
const hit = hitTestMouse(ev)
|
||||||
|
if (!hit) return
|
||||||
|
if (hit.kind === 'button') {
|
||||||
|
focusIdx = buttonsFocusBase + hit.idx
|
||||||
|
render()
|
||||||
|
activateButton(hit.idx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hit.kind === 'field') {
|
||||||
|
focusIdx = hit.idx
|
||||||
|
const fw = fields[hit.idx].width
|
||||||
|
const s = fieldScroll(cursors[hit.idx], fw)
|
||||||
|
const newCur = s + (hit.cx - hit.region.x)
|
||||||
|
cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur))
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hit.kind === 'list') {
|
||||||
|
focusIdx = listFocusIdx
|
||||||
|
if (listSelectable(listItems[hit.idx], hit.idx)) {
|
||||||
|
listCursor = hit.idx
|
||||||
|
ensureListCursorVisible()
|
||||||
|
render()
|
||||||
|
if (activateListItem(hit.idx, 'click')) return
|
||||||
|
} else {
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hit.kind === 'listblank') {
|
||||||
|
focusIdx = listFocusIdx
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev[0] === 'mouse_wheel' && hasList) {
|
||||||
|
const hit = hitTestMouse(ev)
|
||||||
|
if (!hit || (hit.kind !== 'list' && hit.kind !== 'listblank')) return
|
||||||
|
const dy = (ev[3] | 0) * 3
|
||||||
|
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||||
|
let next = listScroll + dy
|
||||||
|
if (next < 0) next = 0
|
||||||
|
if (next > maxScroll) next = maxScroll
|
||||||
|
if (next !== listScroll) { listScroll = next; render() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev[0] !== 'key_down') return
|
||||||
|
if (opts.disableKeyRepeat && 1 !== ev[2]) return
|
||||||
|
const ks = ev[1]
|
||||||
|
const shiftDown = (ev.includes(59) || ev.includes(60))
|
||||||
|
|
||||||
|
if (opts.onKey && opts.onKey(ks, shiftDown, externalCtx)) return
|
||||||
|
|
||||||
|
if (ks === '<ESC>') {
|
||||||
|
done = {
|
||||||
|
action: 'cancel',
|
||||||
|
values: values.slice(),
|
||||||
|
listCursor: listCursor,
|
||||||
|
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ks === '\t' || ks === '<TAB>') { moveFocus(shiftDown ? -1 : 1); return }
|
||||||
|
|
||||||
|
// Vertical movement: arrows operate within the list when it has focus.
|
||||||
|
if (ks === '<UP>') {
|
||||||
|
if (focusIdx === listFocusIdx) { moveListCursor(-1); render() }
|
||||||
|
else moveFocus(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<DOWN>') {
|
||||||
|
if (focusIdx === listFocusIdx) { moveListCursor(+1); render() }
|
||||||
|
else moveFocus(+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<PAGE_UP>') {
|
||||||
|
if (focusIdx === listFocusIdx) { pageListCursor(-1); render() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<PAGE_DOWN>') {
|
||||||
|
if (focusIdx === listFocusIdx) { pageListCursor(+1); render() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ks === '<LEFT>') {
|
||||||
|
if (focusIdx < fields.length) {
|
||||||
|
if (cursors[focusIdx] > 0) { cursors[focusIdx] -= 1; render() }
|
||||||
|
} else moveFocus(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<RIGHT>') {
|
||||||
|
if (focusIdx < fields.length) {
|
||||||
|
if (cursors[focusIdx] < values[focusIdx].length) { cursors[focusIdx] += 1; render() }
|
||||||
|
} else moveFocus(+1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<HOME>') {
|
||||||
|
if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() }
|
||||||
|
else if (focusIdx === listFocusIdx) {
|
||||||
|
const t = firstSelectable(0, +1)
|
||||||
|
if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() }
|
||||||
|
else { listScroll = 0; render() }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<END>') {
|
||||||
|
if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() }
|
||||||
|
else if (focusIdx === listFocusIdx) {
|
||||||
|
const t = firstSelectable(listItems.length - 1, -1)
|
||||||
|
if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() }
|
||||||
|
else { listScroll = Math.max(0, listItems.length - listHeight); render() }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focusIdx < fields.length) {
|
||||||
|
if (ks === '\n') {
|
||||||
|
if (focusIdx < fields.length - 1) focusIdx = focusIdx + 1
|
||||||
|
else if (hasList) focusIdx = listFocusIdx
|
||||||
|
else focusIdx = buttonsFocusBase
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '\x08') {
|
||||||
|
const cur = cursors[focusIdx]
|
||||||
|
if (cur > 0) {
|
||||||
|
const v = values[focusIdx]
|
||||||
|
values[focusIdx] = v.substring(0, cur - 1) + v.substring(cur)
|
||||||
|
cursors[focusIdx] = cur - 1
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ks === '<DEL>') {
|
||||||
|
const cur = cursors[focusIdx]
|
||||||
|
const v = values[focusIdx]
|
||||||
|
if (cur < v.length) {
|
||||||
|
values[focusIdx] = v.substring(0, cur) + v.substring(cur + 1)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof ks === 'string' && ks.length === 1) {
|
||||||
|
const code = ks.charCodeAt(0)
|
||||||
|
const cap = fields[focusIdx].maxLength != null
|
||||||
|
? fields[focusIdx].maxLength
|
||||||
|
: fields[focusIdx].width * 4
|
||||||
|
if (code >= 32 && code < 256 && values[focusIdx].length < cap) {
|
||||||
|
const v = values[focusIdx]
|
||||||
|
const cur = cursors[focusIdx]
|
||||||
|
values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur)
|
||||||
|
cursors[focusIdx] = cur + 1
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (focusIdx === listFocusIdx) {
|
||||||
|
if (ks === '\n' || ks === ' ') {
|
||||||
|
if (listCursor >= 0 && activateListItem(listCursor, ks)) return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ks === '\n' || ks === ' ') { activateButton(focusIdx - buttonsFocusBase); return }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal-dialog convention: wait for the user to release whatever key closed
|
||||||
|
// the dialog before handing control back. TVDOS's input strobo
|
||||||
|
// (TVDOS.SYS:input.withEvent) keeps re-firing `key_down` for a held key
|
||||||
|
// once its ~250 ms initial-press delay elapses; without this drain a brief
|
||||||
|
// hold on Enter inside a popup would surface as a fresh Enter to whatever
|
||||||
|
// the popup was covering, e.g. activating the file under zfm's More menu.
|
||||||
|
// A mouse close (or any path with no key held) leaves the head key at 0
|
||||||
|
// and skips the wait.
|
||||||
|
sys.poke(-40, 255)
|
||||||
|
const heldHead = sys.peek(-41)
|
||||||
|
if (heldHead !== 0) {
|
||||||
|
while (true) {
|
||||||
|
input.withEvent(() => {})
|
||||||
|
if (sys.peek(-41) !== heldHead) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
con.curs_set(0)
|
||||||
|
con.color_pair(oldFG, oldBG)
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { WindowObject, scrollVert, scrollHorz, showDialog }
|
||||||
|
|||||||
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user