mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-07 14:04:05 +09:00
Compare commits
68 Commits
e3bd4a1b59
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
db3ffdedb6 | ||
|
|
5b9b96c8de | ||
|
|
8e8374ba99 | ||
|
|
34fba4b2f2 | ||
|
|
1d28c89937 | ||
|
|
61524b3685 | ||
|
|
e6f77c4789 | ||
|
|
00c0e18c1a | ||
|
|
135c7b9c4e | ||
|
|
295c1f7fe2 | ||
|
|
e74a373605 | ||
|
|
b1a0a9f801 | ||
|
|
bdc2578072 |
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,20 +1,78 @@
|
|||||||
# 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) 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
* **Pattern.** A rectangular block of rows × channels, conceptually similar to a MIDI clip in a DAW but on a strict grid: at most one note event per row per channel. Patterns have a fixed row count (typically 64), and the entire song is assembled by sequencing patterns rather than by placing clips on a continuous timeline.
|
||||||
|
|
||||||
|
* **Cue list** (also called *order list* in other trackers). The song-level playlist of pattern indices that defines playback order. The same pattern can appear in many cue slots — editing the pattern updates every occurrence. There is no continuous timeline; the song's runtime is whatever the cue list yields, navigated by effects B (jump) and C (break). Some trackers use one cue slot that spans the entire channels; Taud uses per-channel cues.
|
||||||
|
|
||||||
|
* **Channel / Voice.** A vertical column within every pattern, fixed in count for the whole song (closer in spirit to a mixer channel than a DAW track). Each channel plays at most one note at a time; chords need multiple channels. Channels persist their state — volume, pan, vibrato phase, filter — across pattern boundaries.
|
||||||
|
|
||||||
|
* **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 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.
|
||||||
|
|
||||||
|
* **Effect column.** Each cell can carry one effect command (opcode + 16-bit argument) that fires on its row. Unlike a DAW automation lane, effects are inline with the notes — there is no continuous curve, only discrete per-row events that compose with the engine's tick loop.
|
||||||
|
|
||||||
|
* **Volume column / panning column.** Two extra mini-lanes per cell, each carrying its own 6-bit value + 2-bit selector (set / slide-up / slide-down / fine-slide). They run alongside the main effect column, so a single cell can carry both a main effect *and* a volume-column slide.
|
||||||
|
|
||||||
|
* **Effect memory / recall.** Most effects remember their last non-zero argument; re-issuing the same effect with `$0000` recalls and re-applies it. This is how trackers express "continue that slide" without re-typing the rate every row. Each effect has either a private memory slot or shares one with a small cohort of related effects (see §6).
|
||||||
|
|
||||||
|
* **Fine slides** are basically "relatively set something" operations. They apply delta on the first tick of the row only.
|
||||||
|
|
||||||
|
* **Instruments vs. samples.** Notes don't reference a sample directly — they reference an **instrument**, which wraps a sample with envelopes (volume / pan / pitch), a default note volume, an NNA (New Note Action; see below), and a fadeout setting. The same sample can be wrapped by several instruments with different envelopes, much like a sampler patch in a DAW.
|
||||||
|
|
||||||
|
* **Sample loops.** Held notes don't work the way a DAW sustain pedal does. The sample itself contains a loop region (loop_start..loop_end) that the playhead replays endlessly until the note is released or cut — "sustain" comes from the sample data, not from a held key.
|
||||||
|
|
||||||
|
* **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.
|
||||||
|
|
||||||
|
* **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.
|
||||||
|
|
||||||
|
* **Vibrato / tremolo / panbrello.** Per-channel LFOs applied to pitch (H, U), volume (R), and panning (Y) respectively. Each has independent speed, depth, and waveform. These are not DAW automation envelopes — they're cyclic modulators, more like a synth's LFO knob.
|
||||||
|
|
||||||
|
* **Arpeggio.** A chip tune staple: rapidly cycle one channel between three pitches across consecutive ticks to fake a chord on a single voice (effect J). At the default 50 Hz tick rate the cycle is fast enough to perceive as a chord rather than three separate notes.
|
||||||
|
|
||||||
|
* **Sample offset.** Start sample playback partway into the sample data rather than at byte 0 (effect O). Common uses: trigger a long sample mid-attack to skip a slow onset, or pick a different drum hit from a multi-sample bank.
|
||||||
|
|
||||||
|
* **Pattern jump / break / loop.** Three flow-control tools without a direct DAW analog. **B** jumps to a cue index; **C** breaks out of the current pattern into a specific row of the *next* one in the cue list; **S$Bx** sets a per-channel loop point and repeats the bracketed range a fixed number of times. They operate on the cue list, not on a timeline. This pattern-wise flow control (including delays. see below) applies to the entire channels; there will be no divergence where one channel loops but other channels don't.
|
||||||
|
|
||||||
|
* **Pattern delay / fine pattern delay.** **S$Ex** repeats the current row N additional times (notes don't re-trigger across repetitions, but tick-0 events do); **S$6x** extends the current row by N additional ticks without repeating it. Together they let composers stretch row timing locally without touching global speed or tempo.
|
||||||
|
|
||||||
|
* **Volume fadeout.** A linear per-tick volume decay applied after key-off (or NNA Note-Fade). For sustained instruments whose volume envelope holds non-zero forever, the fadeout is the *only* mechanism that eventually retires the voice — without a stored fadeout, key-off lets such voices ring indefinitely.
|
||||||
|
|
||||||
## 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)
|
||||||
@@ -37,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`:
|
||||||
|
|
||||||
@@ -81,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
|
||||||
|
|
||||||
@@ -97,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -117,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,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:
|
||||||
|
|
||||||
@@ -253,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.**
|
||||||
|
|
||||||
@@ -418,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:
|
||||||
|
|
||||||
@@ -454,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:
|
||||||
|
|
||||||
@@ -489,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.**
|
||||||
|
|
||||||
@@ -605,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -615,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:
|
||||||
|
|
||||||
@@ -645,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.
|
||||||
|
|
||||||
@@ -655,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.**
|
||||||
|
|
||||||
@@ -685,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -715,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:
|
||||||
|
|
||||||
@@ -746,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.
|
||||||
|
|
||||||
@@ -779,7 +837,7 @@ if V.dittoActive and armRow <= N <= V.dittoEndRow:
|
|||||||
srcRow = V.dittoSourceStart + ((N - V.dittoSourceStart) mod V.dittoLength)
|
srcRow = V.dittoSourceStart + ((N - V.dittoSourceStart) mod V.dittoLength)
|
||||||
src = patternRows[V.pattern][srcRow]
|
src = patternRows[V.pattern][srcRow]
|
||||||
|
|
||||||
cell.note = (raw.note != 0xFFFF) ? raw.note : src.note
|
cell.note = (raw.note != 0x0000) ? raw.note : src.note
|
||||||
cell.instrument = (raw.instrument != 0) ? raw.instrument : src.instrument
|
cell.instrument = (raw.instrument != 0) ? raw.instrument : src.instrument
|
||||||
|
|
||||||
# SEL_FINE / 0 is the canonical no-op encoding for the vol- and pan-columns;
|
# SEL_FINE / 0 is the canonical no-op encoding for the vol- and pan-columns;
|
||||||
@@ -826,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:
|
||||||
|
|
||||||
@@ -885,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).
|
||||||
@@ -927,16 +985,17 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
|||||||
|
|
||||||
# S $0x00 — Amiga LPF/LED Switch
|
# S $0x00 — Amiga LPF/LED Switch
|
||||||
|
|
||||||
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is somewhat 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
@@ -948,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 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1093,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).
|
||||||
|
|
||||||
@@ -1115,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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1125,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1133,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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1143,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.
|
||||||
|
|
||||||
@@ -1151,11 +1210,11 @@ Q retrigger counters do **not** reset between SEx repetitions.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## S $Fxxx — Funk repeat with speed $xxx (non-destructive)
|
## S $Fxxx — Funk repeat (Invert loop) with speed $xxx (non-destructive)
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
@@ -1177,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1192,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**
|
||||||
|
|
||||||
@@ -1207,11 +1266,11 @@ 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Effects That Modifies Global Behaviour
|
# Effects that modifies global behaviour
|
||||||
|
|
||||||
Effects in this section modifies the behaviour of the mixer. Primary intention of the commands is to provide switches for legacy tracker and modern DAW behaviours.
|
Effects in this section modifies the behaviour of the mixer. Primary intention of the commands is to provide switches for legacy tracker and modern DAW behaviours.
|
||||||
|
|
||||||
@@ -1225,73 +1284,12 @@ 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.
|
||||||
- rrr = 4: SNES 4-tap Gaussian
|
- rrr = 4: SNES 4-tap gaussian.
|
||||||
- rrr = 5: Preserve delta modulation (linear intp.)
|
- rrr = 5: NES DPCM simulation.
|
||||||
|
|
||||||
### Volume Fadeout
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```
|
|
||||||
fadeoutVolume -= storedFadeout / 1024.0
|
|
||||||
clamp fadeoutVolume to [0, 1]
|
|
||||||
if fadeoutVolume == 0: voice deactivates
|
|
||||||
```
|
|
||||||
|
|
||||||
Boundary semantics:
|
|
||||||
|
|
||||||
| `storedFadeout` | Behaviour |
|
|
||||||
| --- | --- |
|
|
||||||
| `0` | No fade. Voice plays at envelope-driven volume indefinitely. |
|
|
||||||
| `1..1023` | Graduated fade — completes in `1024 / storedFadeout` ticks. |
|
|
||||||
| `1024` | Exact 1-tick cut. The canonical "kill on key-off" value. |
|
|
||||||
| `1025..4095` | Also a 1-tick cut (clamped at 0). Headroom for converter robustness. |
|
|
||||||
|
|
||||||
There is no separate "use fadeout" flag — both extremes share the same field, exactly as in the IT and XM file formats.
|
|
||||||
|
|
||||||
**Tick-rate worked example** (default 50 Hz, BPM 125, speed 6):
|
|
||||||
|
|
||||||
- `storedFadeout = 1` → fade ≈ 20.5 s
|
|
||||||
- `storedFadeout = 32` → fade ≈ 640 ms
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
- **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
|
|
||||||
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
|
|
||||||
```
|
|
||||||
- **FT2 / XM** (`xm2taud.py`): XM files store fadeout as a 16-bit field. Spec range 0..0xFFF; MilkyTracker writes up to 32767 to encode the "cut" UI slider position (`SectionInstruments.cpp:499-500`). FT2's per-tick decrement is `stored / 32768` — to match Taud's `stored / 1024` rate, **divide source by 32 (round-to-nearest):**
|
|
||||||
```python
|
|
||||||
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
|
|
||||||
```
|
|
||||||
XM stored 1..15 round to Taud 0; the originals were >11 min at 50 Hz — effectively no-fade anyway. Stored 32 → Taud 1 (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
|
|
||||||
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
|
|
||||||
|
|
||||||
**Implementation.**
|
|
||||||
- Panning (equal-energy):
|
|
||||||
- L_gain = cos(πx / 512.0)
|
|
||||||
- R_gain = sin(πx / 512.0)
|
|
||||||
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
|
|
||||||
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
|
|
||||||
- period = AMIGA_BASE_PERIOD × 2^(−(noteVal − C4) / 4096)
|
|
||||||
- period_new = period − slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
|
|
||||||
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
|
|
||||||
- Linear-frequency tone (E / F / G in Hz/tick). The `slideArg` is a **signed Hz delta per tick** at the audible reference 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, identical to the value MONOTONE stores in its 1xx/2xx/3xx commands. Sign convention matches linear/Amiga modes (negative for E, positive for F):
|
|
||||||
- LINEAR_FREQ_C4_HZ = 261.625565... (12-TET, so A4 = 440 Hz exactly)
|
|
||||||
- freq = LINEAR_FREQ_C4_HZ × 2^((noteVal − C4) / 4096)
|
|
||||||
- freq_new = max(freq + slideArg, 1.0)
|
|
||||||
- noteVal_new = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1340,30 +1338,97 @@ 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Miscellaneous implementation details
|
||||||
|
|
||||||
|
This section documents important implementation details that are not covered by sections above.
|
||||||
|
|
||||||
|
## Volume fadeout
|
||||||
|
|
||||||
|
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 **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
|
||||||
|
clamp fadeoutVolume to [0, 1]
|
||||||
|
if fadeoutVolume == 0: voice deactivates
|
||||||
|
```
|
||||||
|
|
||||||
|
Boundary semantics:
|
||||||
|
|
||||||
|
| `storedFadeout` | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | No fade. Voice plays at envelope-driven volume indefinitely. |
|
||||||
|
| `1..1023` | Graduated fade — completes in `1024 / storedFadeout` ticks. |
|
||||||
|
| `1024` | Exact 1-tick cut. The canonical "kill on key-off" value. |
|
||||||
|
| `1025..4095` | Also a 1-tick cut (clamped at 0). Headroom for converter robustness. |
|
||||||
|
|
||||||
|
There is no separate "use fadeout" flag — both extremes share the same field, exactly as in the IT and XM file formats.
|
||||||
|
|
||||||
|
**Tick-rate worked example** (default 50 Hz, BPM 125, speed 6):
|
||||||
|
|
||||||
|
- `storedFadeout = 1` → fade ≈ 20.5 s
|
||||||
|
- `storedFadeout = 32` → fade ≈ 640 ms
|
||||||
|
- `storedFadeout = 1024` → ~20 ms (one tick)
|
||||||
|
|
||||||
|
**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:**
|
||||||
|
```python
|
||||||
|
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
|
||||||
|
```
|
||||||
|
- **FT2 / XM** (`xm2taud.py`): XM files store fadeout as a 16-bit field. Spec range 0..0xFFF; MilkyTracker writes up to 32767 to encode the "cut" UI slider position (`SectionInstruments.cpp:499-500`). FT2's per-tick decrement is `stored / 32768` — to match Taud's `stored / 1024` rate, **divide source by 32 (round-to-nearest):**
|
||||||
|
```python
|
||||||
|
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
|
||||||
|
```
|
||||||
|
XM stored 1..15 round to Taud 0; the originals were >11 min at 50 Hz — effectively no-fade anyway. Stored 32 → Taud 1 (~20 s). Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
|
||||||
|
- **MOD / S3M / MON**: source has no instrument-level fadeout. Converter writes Taud `0`. Notes retire on sample-end or pattern note-cut.
|
||||||
|
|
||||||
|
**Implementation.**
|
||||||
|
- Panning (equal-energy):
|
||||||
|
- L_gain = cos(πx / 512.0)
|
||||||
|
- R_gain = sin(πx / 512.0)
|
||||||
|
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
|
||||||
|
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
|
||||||
|
- period = AMIGA_BASE_PERIOD × 2^(−(noteVal − C4) / 4096)
|
||||||
|
- period_new = period − slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
|
||||||
|
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
|
||||||
|
- Linear-frequency tone (E / F / G in Hz/tick). The `slideArg` is a **signed Hz delta per tick** at the audible reference 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, identical to the value MONOTONE stores in its 1xx/2xx/3xx commands. Sign convention matches linear/Amiga modes (negative for E, positive for F):
|
||||||
|
- LINEAR_FREQ_C4_HZ = 261.625565... (12-TET, so A4 = 440 Hz exactly)
|
||||||
|
- freq = LINEAR_FREQ_C4_HZ × 2^((noteVal − C4) / 4096)
|
||||||
|
- freq_new = max(freq + slideArg, 1.0)
|
||||||
|
- noteVal_new = C4 + 4096 × log2(freq_new / LINEAR_FREQ_C4_HZ)
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
**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 +1 @@
|
|||||||
let p=_BIOS.FIRST_BOOTABLE_PORT;com.sendMessage(p[0],"DEVRST\x17");com.sendMessage(p[0],'OPENR"tvdos/hyve.SYS",'+p[1]);let r=com.getStatusCode(p[0]);if(0==r)if(com.sendMessage(p[0],"READ"),r=com.getStatusCode(p[0]),0==r){let g=com.pullMessage(p[0]);eval(g)}else println("I/O Error");else println("TVDOS.SYS not found");println("Shutting down...");println("It is now safe to turn off the power")
|
let p=_BIOS.FIRST_BOOTABLE_PORT;com.sendMessage(p[0],"DEVRST\x17");com.sendMessage(p[0],'OPENR"tvdos/TVDOS.SYS",'+p[1]);let r=com.getStatusCode(p[0]);if(0==r)if(com.sendMessage(p[0],"READ"),r=com.getStatusCode(p[0]),0==r){let g=com.pullMessage(p[0]);eval(g)}else println("I/O Error");else println("TVDOS.SYS not found");println("Shutting down...");println("It is now safe to turn off the power")
|
||||||
@@ -1,10 +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;$PATH
|
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||||
set KEYBOARD=us_colemak
|
rem
|
||||||
|
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||||
rem this line specifies which shell to be presented after the boot precess:
|
rem run per-console here rather than once at boot.
|
||||||
tvdos/i18n/korean
|
tvdos/i18n/korean
|
||||||
zfm
|
|
||||||
|
rem The interactive shell for this console.
|
||||||
command -fancy
|
command -fancy
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2020-2024 CuriousTorvald
|
Copyright (c) 2020-2026 CuriousTorvald
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
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,3 +1,3 @@
|
|||||||
TVDOS (c) 2020-2024 CuriousTorvald
|
TVDOS (c) 2020-2026 CuriousTorvald
|
||||||
|
|
||||||
TVDOS is provided "as is", without warranty of any kind; in no event shall the authors or copyright holders be liable for any claim, damages or other liabilities. Run 'less COPYING' for more information.
|
TVDOS is provided "as is", without warranty of any kind; in no event shall the authors or copyright holders be liable for any claim, damages or other liabilities. Run 'less COPYING' for more information.
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ if (exec_args !== undefined && exec_args[1] !== undefined && exec_args[1].starts
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEVERSION = "1.2.1"
|
const THEVERSION = "1.2.2"
|
||||||
|
|
||||||
const PROD = true
|
const PROD = true
|
||||||
let INDEX_BASE = 0
|
let INDEX_BASE = 0
|
||||||
@@ -4197,7 +4197,7 @@ bF.load = function(args) { // LOAD function
|
|||||||
if (args[1] === undefined) throw lang.missingOperand
|
if (args[1] === undefined) throw lang.missingOperand
|
||||||
var fileOpened = fs.open(args[1], "R")
|
var fileOpened = fs.open(args[1], "R")
|
||||||
|
|
||||||
|
serial.printerr('load '+args[1])
|
||||||
if (replUsrConfirmed || cmdbuf.length == 0) {
|
if (replUsrConfirmed || cmdbuf.length == 0) {
|
||||||
if (!fileOpened) {
|
if (!fileOpened) {
|
||||||
fileOpened = fs.open(args[1]+".BAS", "R")
|
fileOpened = fs.open(args[1]+".BAS", "R")
|
||||||
@@ -4241,7 +4241,7 @@ bF.yes = function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bF.catalog = function(args) { // CATALOG function
|
bF.catalog = function(args) { // CATALOG function
|
||||||
if (args[1] === undefined) args[1] = "\\"
|
if (args[1] === undefined) args[1] = BASIC_HOME_PATH
|
||||||
var pathOpened = fs.open(args[1], 'R')
|
var pathOpened = fs.open(args[1], 'R')
|
||||||
if (!pathOpened) {
|
if (!pathOpened) {
|
||||||
throw lang.noSuchFile
|
throw lang.noSuchFile
|
||||||
@@ -4251,6 +4251,57 @@ bF.catalog = function(args) { // CATALOG function
|
|||||||
com.sendMessage(port, "LIST")
|
com.sendMessage(port, "LIST")
|
||||||
println(com.pullMessage(port))
|
println(com.pullMessage(port))
|
||||||
}
|
}
|
||||||
|
// Load a file by absolute disk path (bypasses BASIC_HOME_PATH).
|
||||||
|
// Used by COMPILE to fetch /tbas/compile.js.
|
||||||
|
bF._slurpAbsolute = function(path) {
|
||||||
|
var port = _BIOS.FIRST_BOOTABLE_PORT
|
||||||
|
com.sendMessage(port[0], "FLUSH")
|
||||||
|
com.sendMessage(port[0], "CLOSE")
|
||||||
|
com.sendMessage(port[0], 'OPENR"' + path + '",' + port[1])
|
||||||
|
if (com.getStatusCode(port[0]) != 0) return undefined
|
||||||
|
com.sendMessage(port[0], "READ")
|
||||||
|
if (com.getStatusCode(port[0]) >= 128) return undefined
|
||||||
|
var s = com.pullMessage(port[0])
|
||||||
|
com.sendMessage(port[0], "FLUSH"); com.sendMessage(port[0], "CLOSE")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
bF.compile = function(args) { // COMPILE "OUT.JS" -- transpile cmdbuf to JS
|
||||||
|
if (args[1] === undefined) {
|
||||||
|
println("Usage: COMPILE \"out.js\""); return
|
||||||
|
}
|
||||||
|
if (cmdbuf.length === 0) {
|
||||||
|
println("No program loaded"); return
|
||||||
|
}
|
||||||
|
if (bS._compileImpl === undefined) {
|
||||||
|
// Lazy-load compile.js from /tbas/compile.js
|
||||||
|
var src = bF._slurpAbsolute("/tbas/compile.js")
|
||||||
|
if (src === undefined) {
|
||||||
|
println("Cannot load /tbas/compile.js")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { eval(src) } catch (e) {
|
||||||
|
println("Failed to load compiler: " + e); return
|
||||||
|
}
|
||||||
|
if (bS._compileImpl === undefined) {
|
||||||
|
println("compile.js loaded but did not define bS._compileImpl"); return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var outpath = args[1]
|
||||||
|
// Strip surrounding quotes if any
|
||||||
|
if ((outpath.charAt(0) === '"' || outpath.charAt(0) === "'") &&
|
||||||
|
outpath.charAt(outpath.length - 1) === outpath.charAt(0)) {
|
||||||
|
outpath = outpath.substring(1, outpath.length - 1)
|
||||||
|
}
|
||||||
|
// Default to .js extension if missing
|
||||||
|
if (!/\.[A-Za-z0-9]+$/.test(outpath)) outpath += ".js"
|
||||||
|
try {
|
||||||
|
var n = bS._compileImpl(outpath)
|
||||||
|
println("Wrote " + n + " bytes to " + outpath)
|
||||||
|
} catch (e) {
|
||||||
|
serial.printerr(e + "\n" + (e.stack || ""))
|
||||||
|
println("Compile error: " + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
Object.freeze(bF)
|
Object.freeze(bF)
|
||||||
|
|
||||||
if (exec_args !== undefined && exec_args[1] !== undefined) {
|
if (exec_args !== undefined && exec_args[1] !== undefined) {
|
||||||
|
|||||||
564
assets/disk0/tbas/compile.js
Normal file
564
assets/disk0/tbas/compile.js
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
// Terran BASIC -> JavaScript compiler
|
||||||
|
// Loaded into basic.js's context by `bF.compile`. Re-uses bF._interpretLine
|
||||||
|
// (tokeniser + elaborator + parser + pruner) verbatim and emits a self-
|
||||||
|
// contained JS program that does its work via `let bS = require("tbas")`.
|
||||||
|
//
|
||||||
|
// On load, attaches `bS._compileImpl` to the live bS object.
|
||||||
|
|
||||||
|
;(function() {
|
||||||
|
|
||||||
|
// ---------- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
function isValidJsId(s) {
|
||||||
|
return /^[A-Z_][A-Z0-9_]*$/i.test(s)
|
||||||
|
}
|
||||||
|
function varRef(name) {
|
||||||
|
const u = String(name).toUpperCase()
|
||||||
|
return isValidJsId(u) ? `bS.__state.vars.${u}` : `bS.__state.vars[${JSON.stringify(u)}]`
|
||||||
|
}
|
||||||
|
function jsLit(v) { return JSON.stringify(v) }
|
||||||
|
|
||||||
|
// Resolve a literal AST node down to a raw JS value at compile time. Used
|
||||||
|
// for harvesting DATA constants. Only constant-propagatable types are
|
||||||
|
// permitted; otherwise compile-time evaluation fails.
|
||||||
|
function literalValue(node) {
|
||||||
|
if (!node) return undefined
|
||||||
|
switch (node.astType) {
|
||||||
|
case "num": return Number(node.astValue)
|
||||||
|
case "string": return String(node.astValue)
|
||||||
|
case "bool": return Boolean(node.astValue)
|
||||||
|
case "null": return undefined
|
||||||
|
case "lit": return String(node.astValue) // bare identifier in DATA: keep as string
|
||||||
|
default:
|
||||||
|
throw Error("DATA: unsupported literal node type: " + node.astType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the maximum varIndex used at the immediate scope of a lambda body,
|
||||||
|
// hence its arity.
|
||||||
|
function lambdaArity(body) {
|
||||||
|
let maxIdx = -1
|
||||||
|
function walk(t, level) {
|
||||||
|
if (!t || !t.astType) return
|
||||||
|
if (t.astType === "defun_args" && t.astValue[0] === level) {
|
||||||
|
if (t.astValue[1] > maxIdx) maxIdx = t.astValue[1]
|
||||||
|
}
|
||||||
|
// descend into nested usrdefun (its body lives in astValue, not leaves)
|
||||||
|
if (t.astType === "usrdefun" && t.astValue && t.astValue.astLeaves !== undefined) {
|
||||||
|
walk(t.astValue, level + 1)
|
||||||
|
}
|
||||||
|
// generic descent
|
||||||
|
if (t.astLeaves) {
|
||||||
|
for (let i = 0; i < t.astLeaves.length; i++) walk(t.astLeaves[i], level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(body, 0)
|
||||||
|
return maxIdx + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- expression lowering ---------------------------------------------
|
||||||
|
|
||||||
|
// `depth` tracks the number of enclosing lambdas during emission. When we
|
||||||
|
// emit a lambda we increment it; defun_args [d, i] becomes _aN_i where
|
||||||
|
// N = depth - 1 - d (the absolute lambda index of the binding scope).
|
||||||
|
function compileExpr(tree, depth) {
|
||||||
|
if (tree === undefined || tree === null) return "undefined"
|
||||||
|
|
||||||
|
// Empty parens / wrapper node: descend into the single child
|
||||||
|
if (tree.astType === "null") {
|
||||||
|
if (tree.astLeaves && tree.astLeaves[0] !== undefined) return compileExpr(tree.astLeaves[0], depth)
|
||||||
|
return "undefined"
|
||||||
|
}
|
||||||
|
if (tree.astValue === undefined && tree.astLeaves && tree.astLeaves.length === 1) {
|
||||||
|
return compileExpr(tree.astLeaves[0], depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tree.astType) {
|
||||||
|
case "num": return String(Number(tree.astValue))
|
||||||
|
case "string": return jsLit(String(tree.astValue))
|
||||||
|
case "bool": return tree.astValue ? "true" : "false"
|
||||||
|
case "lit": return compileLit(tree)
|
||||||
|
case "defun_args": {
|
||||||
|
const d = tree.astValue[0], i = tree.astValue[1]
|
||||||
|
const scope = depth - 1 - d
|
||||||
|
if (scope < 0) throw Error("defun_args refers to a scope outside the program (depth=" + depth + ", d=" + d + ")")
|
||||||
|
return "_a" + scope + "_" + i
|
||||||
|
}
|
||||||
|
case "usrdefun": return compileLambdaExpr(tree, depth)
|
||||||
|
case "array": return compileArrayRef(tree, depth)
|
||||||
|
case "function": return compileFunctionExpr(tree, depth)
|
||||||
|
case "op": return compileOpExpr(tree, depth)
|
||||||
|
default:
|
||||||
|
throw Error("Cannot compile expression node of type: " + tree.astType + " (value=" + tree.astValue + ")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileLit(tree) {
|
||||||
|
const name = String(tree.astValue).toUpperCase()
|
||||||
|
// Built-in zero-arg / pass-as-value functions: when a builtin name is
|
||||||
|
// referenced as a value (e.g. assigned to a variable for later use as a
|
||||||
|
// higher-order arg), emit a JS function reference. For a plain variable
|
||||||
|
// read, emit the vars table lookup.
|
||||||
|
// Heuristic: if the name matches a builtin we know about, prefer the
|
||||||
|
// function; otherwise, vars lookup.
|
||||||
|
if (RUNTIME_BUILTINS.has(name)) {
|
||||||
|
return "bS." + (isValidJsId(name) ? name : `[${jsLit(name)}]`)
|
||||||
|
}
|
||||||
|
return varRef(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileArrayRef(tree, depth) {
|
||||||
|
// tree.astValue = array variable name; tree.astLeaves = index expressions
|
||||||
|
if (!tree.astLeaves || tree.astLeaves.length === 0) {
|
||||||
|
return varRef(tree.astValue)
|
||||||
|
}
|
||||||
|
const indices = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||||
|
return `bS.__arrGet(${varRef(tree.astValue)}, [${indices.join(",")}])`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileFunctionExpr(tree, depth) {
|
||||||
|
const name = String(tree.astValue).toUpperCase()
|
||||||
|
|
||||||
|
if (name === "PRINT" || name === "EMIT") {
|
||||||
|
// PRINT/EMIT used as expression — emit as IIFE returning undefined
|
||||||
|
return "(" + compilePrintLike(tree, name, depth) + ", undefined)"
|
||||||
|
}
|
||||||
|
// user function call by name: <varname>(args) — when astType is "function"
|
||||||
|
// and astValue is a string that matches a variable, the parser may have
|
||||||
|
// generated this. Treat it as: invoke the var.
|
||||||
|
if (!RUNTIME_BUILTINS.has(name)) {
|
||||||
|
// Not a known builtin: treat as a user defined function call
|
||||||
|
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||||
|
return `bS.__runFn(${varRef(name)}, [${args.join(",")}])`
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||||
|
return `bS.${isValidJsId(name) ? name : `[${jsLit(name)}]`}(${args.join(",")})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ARITH_OP = {
|
||||||
|
"+": (l,r) => `bS.__add(${l},${r})`,
|
||||||
|
"-": (l,r) => `((${l})-(${r}))`,
|
||||||
|
"*": (l,r) => `((${l})*(${r}))`,
|
||||||
|
"/": (l,r) => `bS.__div(${l},${r})`,
|
||||||
|
"\\": (l,r) => `bS.__intdiv(${l},${r})`,
|
||||||
|
"MOD":(l,r) => `bS.__mod(${l},${r})`,
|
||||||
|
"^": (l,r) => `bS.__pow(${l},${r})`,
|
||||||
|
"==": (l,r) => `((${l})==(${r}))`,
|
||||||
|
"<>": (l,r) => `((${l})!=(${r}))`,
|
||||||
|
"><": (l,r) => `((${l})!=(${r}))`,
|
||||||
|
"<": (l,r) => `((${l})<(${r}))`,
|
||||||
|
">": (l,r) => `((${l})>(${r}))`,
|
||||||
|
"<=": (l,r) => `((${l})<=(${r}))`,
|
||||||
|
"=<": (l,r) => `((${l})<=(${r}))`,
|
||||||
|
">=": (l,r) => `((${l})>=(${r}))`,
|
||||||
|
"=>": (l,r) => `((${l})>=(${r}))`,
|
||||||
|
"AND":(l,r) => `bS.AND(${l},${r})`,
|
||||||
|
"OR": (l,r) => `bS.OR(${l},${r})`,
|
||||||
|
"<<": (l,r) => `((${l})<<(${r}))`,
|
||||||
|
">>": (l,r) => `((${l})>>>(${r}))`,
|
||||||
|
"BAND":(l,r) => `((${l})&(${r}))`,
|
||||||
|
"BOR": (l,r) => `((${l})|(${r}))`,
|
||||||
|
"BXOR":(l,r) => `((${l})^(${r}))`,
|
||||||
|
}
|
||||||
|
const UNARY_OP = {
|
||||||
|
"UNARYMINUS": (a) => `(-(${a}))`,
|
||||||
|
"UNARYPLUS": (a) => `(+(${a}))`,
|
||||||
|
"UNARYLOGICNOT":(a) => `(!(${a}))`,
|
||||||
|
"UNARYBNOT": (a) => `(~(${a}))`,
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileOpExpr(tree, depth) {
|
||||||
|
const op = String(tree.astValue)
|
||||||
|
const leaves = tree.astLeaves || []
|
||||||
|
|
||||||
|
// Unary
|
||||||
|
if (UNARY_OP[op] && (leaves.length === 1 || leaves[1] === undefined)) {
|
||||||
|
return UNARY_OP[op](compileExpr(leaves[0], depth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary arithmetic / comparison / logic
|
||||||
|
if (ARITH_OP[op] && leaves.length === 2) {
|
||||||
|
return ARITH_OP[op](compileExpr(leaves[0], depth), compileExpr(leaves[1], depth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generator / range
|
||||||
|
if (op === "TO" && leaves.length === 2) {
|
||||||
|
return `new bS.__ForGen(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)}, 1)`
|
||||||
|
}
|
||||||
|
if (op === "STEP" && leaves.length === 2) {
|
||||||
|
return `bS.STEP(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List ops
|
||||||
|
if ((op === "!" || op === "~" || op === "#") && leaves.length === 2) {
|
||||||
|
const fn = (op === "!") ? "['!']" : (op === "~") ? "['~']" : "['#']"
|
||||||
|
return `bS${fn}(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment as expression — returns the assigned value
|
||||||
|
if (op === "=" && leaves.length === 2) {
|
||||||
|
return "(" + compileAssignExpr(tree, depth) + ")"
|
||||||
|
}
|
||||||
|
if (op === "IN" && leaves.length === 2) {
|
||||||
|
// Used inside FOR/FOREACH; compileFor unwraps these. As a value, treat
|
||||||
|
// as { asgnVarName, asgnValue } so a stray IN still works.
|
||||||
|
const name = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||||
|
const rhs = compileExpr(leaves[1], depth)
|
||||||
|
return `({asgnVarName: ${name}, asgnValue: ${rhs}})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functional / monad ops
|
||||||
|
if ((op === ">>=" || op === ">>~" || op === "." || op === "$" ||
|
||||||
|
op === "&" || op === "~<" || op === "<*>" || op === "<$>" ||
|
||||||
|
op === "<~>") && leaves.length === 2) {
|
||||||
|
return `bS[${jsLit(op)}](${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||||
|
}
|
||||||
|
if (op === "@" && leaves.length === 1) {
|
||||||
|
// Monad return as prefix
|
||||||
|
return `bS.MRET(${compileExpr(leaves[0], depth)})`
|
||||||
|
}
|
||||||
|
if (op === "~>") {
|
||||||
|
throw Error("Compiler: bare ~> survived prune (should be usrdefun)")
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Error("Cannot compile op '" + op + "' with " + leaves.length + " operand(s)")
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileLambdaExpr(tree, depth) {
|
||||||
|
// tree.astType === "usrdefun"; tree.astValue holds the body AST; if
|
||||||
|
// tree.astLeaves is non-empty, this is an immediate application.
|
||||||
|
const body = tree.astValue
|
||||||
|
if (!body || !body.astType) throw Error("Malformed usrdefun")
|
||||||
|
|
||||||
|
const arity = lambdaArity(body)
|
||||||
|
const newDepth = depth + 1
|
||||||
|
const params = []
|
||||||
|
for (let i = 0; i < arity; i++) params.push("_a" + (newDepth - 1) + "_" + i)
|
||||||
|
const bodyJs = compileExpr(body, newDepth)
|
||||||
|
const arrow = `((${params.join(",")}) => (${bodyJs}))`
|
||||||
|
|
||||||
|
if (tree.astLeaves && tree.astLeaves.length > 0) {
|
||||||
|
const args = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||||
|
return `${arrow}(${args.join(",")})`
|
||||||
|
}
|
||||||
|
return arrow
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileAssignExpr(tree, depth) {
|
||||||
|
// op "=" with leaves[0] as target, leaves[1] as RHS
|
||||||
|
const lhs = tree.astLeaves[0]
|
||||||
|
const rhs = compileExpr(tree.astLeaves[1], depth)
|
||||||
|
|
||||||
|
if (lhs.astType === "lit") {
|
||||||
|
const name = String(lhs.astValue).toUpperCase()
|
||||||
|
return `(${varRef(name)} = ${rhs})`
|
||||||
|
}
|
||||||
|
// The parser emits "function" or "array" for `A(i,j) = ...` — both mean
|
||||||
|
// "store into element of A".
|
||||||
|
if (lhs.astType === "array" || lhs.astType === "function") {
|
||||||
|
const indices = lhs.astLeaves.map(l => compileExpr(l, depth))
|
||||||
|
return `(bS.__arrSet(${varRef(lhs.astValue)}, [${indices.join(",")}], ${rhs}), ${rhs})`
|
||||||
|
}
|
||||||
|
throw Error("Cannot assign to LHS of type " + lhs.astType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- statement lowering ----------------------------------------------
|
||||||
|
|
||||||
|
function compilePrintLike(tree, fname, depth) {
|
||||||
|
const leaves = (tree.astLeaves || []).slice()
|
||||||
|
const seps = (tree.astSeps || []).slice()
|
||||||
|
|
||||||
|
let suppressNewline = false
|
||||||
|
if (leaves.length > 0 && leaves[leaves.length - 1] !== undefined &&
|
||||||
|
leaves[leaves.length - 1].astType === "null") {
|
||||||
|
suppressNewline = true
|
||||||
|
leaves.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueExprs = leaves.map(l => compileExpr(l, depth))
|
||||||
|
if (suppressNewline) valueExprs.push("bS.__PRINT_NONL")
|
||||||
|
const sepArr = seps.slice(0, leaves.length - 1)
|
||||||
|
|
||||||
|
return `bS.${fname}([${valueExprs.join(", ")}], ${jsLit(sepArr)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPc(pc) {
|
||||||
|
if (pc[0] === Infinity) return "pc=[Infinity,0];"
|
||||||
|
return "pc=[" + pc[0] + "," + pc[1] + "];"
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileStatement(tree, lnum, stmt, nextPc) {
|
||||||
|
if (!tree) return setPc(nextPc)
|
||||||
|
if (tree.astType === "null" && tree.astLeaves && tree.astLeaves[0]) {
|
||||||
|
return compileStatement(tree.astLeaves[0], lnum, stmt, nextPc)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFn = (tree.astType === "function" || tree.astType === "op")
|
||||||
|
const fname = isFn ? String(tree.astValue).toUpperCase() : null
|
||||||
|
|
||||||
|
switch (fname) {
|
||||||
|
case "GOTO": {
|
||||||
|
const target = compileGotoTarget(tree.astLeaves[0])
|
||||||
|
return `pc=${target};`
|
||||||
|
}
|
||||||
|
case "GOSUB": {
|
||||||
|
const target = compileGotoTarget(tree.astLeaves[0])
|
||||||
|
return `gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${target};`
|
||||||
|
}
|
||||||
|
case "RETURN":
|
||||||
|
return `pc=gosubStack.pop(); if(!pc) throw new Error("RETURN without GOSUB");`
|
||||||
|
case "END":
|
||||||
|
return "pc=[Infinity,0];"
|
||||||
|
case "IF":
|
||||||
|
return compileIf(tree, lnum, stmt, nextPc)
|
||||||
|
case "ON":
|
||||||
|
return compileOn(tree, lnum, stmt, nextPc)
|
||||||
|
case "FOR":
|
||||||
|
case "FOREACH":
|
||||||
|
return compileFor(tree, lnum, stmt, nextPc, fname === "FOREACH")
|
||||||
|
case "NEXT":
|
||||||
|
return compileNext(tree, lnum, stmt, nextPc)
|
||||||
|
case "READ": {
|
||||||
|
const target = tree.astLeaves[0]
|
||||||
|
if (target.astType !== "lit") throw Error("READ: target must be a variable")
|
||||||
|
return `${varRef(target.astValue)}=bS.__readData(); ${setPc(nextPc)}`
|
||||||
|
}
|
||||||
|
case "RESTORE":
|
||||||
|
return `bS.__state.dataCursor=0; ${setPc(nextPc)}`
|
||||||
|
case "DATA":
|
||||||
|
case "LABEL":
|
||||||
|
return setPc(nextPc) // harvested at compile time
|
||||||
|
case "DIM":
|
||||||
|
return compileDim(tree, lnum, stmt, nextPc)
|
||||||
|
case "PRINT":
|
||||||
|
case "EMIT":
|
||||||
|
return `${compilePrintLike(tree, fname, 0)}; ${setPc(nextPc)}`
|
||||||
|
case "OPTIONBASE":
|
||||||
|
return `bS.OPTIONBASE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||||
|
case "OPTIONDEBUG":
|
||||||
|
return `bS.OPTIONDEBUG(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||||
|
case "OPTIONTRACE":
|
||||||
|
return `bS.OPTIONTRACE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||||
|
case "INPUT": {
|
||||||
|
// INPUT <var> -> read into var
|
||||||
|
const target = tree.astLeaves[tree.astLeaves.length - 1]
|
||||||
|
if (target.astType !== "lit") throw Error("INPUT: target must be a variable")
|
||||||
|
return `${varRef(target.astValue)}=bS.INPUT(); ${setPc(nextPc)}`
|
||||||
|
}
|
||||||
|
case "=":
|
||||||
|
return `${compileAssignExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||||
|
case "IN":
|
||||||
|
// bare IN as a statement is unusual but harmless
|
||||||
|
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||||
|
case "REM":
|
||||||
|
return setPc(nextPc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: evaluate as an expression for side effect, then advance
|
||||||
|
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileGotoTarget(leaf) {
|
||||||
|
// Always route through __resolveTarget so non-existent line numbers snap
|
||||||
|
// upward to the next existing line — matching basic.js's main loop,
|
||||||
|
// which increments lnum until it finds a populated cmdbuf entry.
|
||||||
|
if (leaf.astType === "num") return `bS.__resolveTarget(${Number(leaf.astValue)})`
|
||||||
|
if (leaf.astType === "string") return `bS.__resolveTarget(${jsLit(leaf.astValue)})`
|
||||||
|
if (leaf.astType === "lit") {
|
||||||
|
const name = String(leaf.astValue)
|
||||||
|
return `bS.__resolveTarget(bS.__state.gotoLabels[${jsLit(name)}]!==undefined ? ${jsLit(name)} : ${varRef(name)})`
|
||||||
|
}
|
||||||
|
return `bS.__resolveTarget(${compileExpr(leaf, 0)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileIf(tree, lnum, stmt, nextPc) {
|
||||||
|
const test = compileExpr(tree.astLeaves[0], 0)
|
||||||
|
const thenStmt = compileStatement(tree.astLeaves[1], lnum, stmt, nextPc)
|
||||||
|
const elseStmt = (tree.astLeaves[2])
|
||||||
|
? compileStatement(tree.astLeaves[2], lnum, stmt, nextPc)
|
||||||
|
: setPc(nextPc)
|
||||||
|
return `if(bS.__test(${test})){${thenStmt}}else{${elseStmt}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileOn(tree, lnum, stmt, nextPc) {
|
||||||
|
// children: testExpr, jumpFnLit, target0, target1, ...
|
||||||
|
const testExpr = compileExpr(tree.astLeaves[0], 0)
|
||||||
|
const jmpFn = String(tree.astLeaves[1].astValue).toUpperCase()
|
||||||
|
const targets = tree.astLeaves.slice(2)
|
||||||
|
|
||||||
|
const cases = targets.map((t, i) => {
|
||||||
|
const tgt = compileGotoTarget(t)
|
||||||
|
if (jmpFn === "GOSUB") {
|
||||||
|
return `case ${i}: gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${tgt}; break;`
|
||||||
|
}
|
||||||
|
return `case ${i}: pc=${tgt}; break;`
|
||||||
|
})
|
||||||
|
return `{const _o=(${testExpr})-bS.__state.indexBase; switch(_o){${cases.join(" ")} default: ${setPc(nextPc)}}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileFor(tree, lnum, stmt, nextPc, isForEach) {
|
||||||
|
const child = tree.astLeaves[0]
|
||||||
|
if (child.astType !== "op" || (child.astValue !== "=" && child.astValue !== "IN")) {
|
||||||
|
throw Error("FOR/FOREACH: expected = or IN, got " + child.astType + ":" + child.astValue)
|
||||||
|
}
|
||||||
|
const varname = String(child.astLeaves[0].astValue).toUpperCase()
|
||||||
|
let iter = compileExpr(child.astLeaves[1], 0)
|
||||||
|
if (isForEach) {
|
||||||
|
// ensure we coerce generators into arrays for FOREACH semantics
|
||||||
|
iter = `(function(_x){return bS.__isGenerator(_x)?bS.__genToArray(_x):_x})(${iter})`
|
||||||
|
}
|
||||||
|
// Pass nextPc — the PC of the loop body's first statement — so NEXT can
|
||||||
|
// jump straight back without relying on fall-through.
|
||||||
|
return `bS.__forSetup(${jsLit(varname)}, ${iter}, ${nextPc[0]}, ${nextPc[1]}); ${setPc(nextPc)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileNext(tree, lnum, stmt, nextPc) {
|
||||||
|
let argExpr = "undefined"
|
||||||
|
const leaves = tree.astLeaves || []
|
||||||
|
if (leaves.length === 1 && leaves[0] && leaves[0].astType === "lit") {
|
||||||
|
argExpr = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||||
|
}
|
||||||
|
return `{const _n=bS.__forNext(${argExpr}); if(_n){pc=_n;}else{${setPc(nextPc)}}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileDim(tree, lnum, stmt, nextPc) {
|
||||||
|
// tree.astLeaves contains array constructor calls: each leaf is either
|
||||||
|
// an `array` node OR a `function` node (the parser doesn't distinguish
|
||||||
|
// `A(5)` from a function call until runtime). astValue is the variable
|
||||||
|
// name and astLeaves are the dimension expressions.
|
||||||
|
const stmts = []
|
||||||
|
for (let i = 0; i < tree.astLeaves.length; i++) {
|
||||||
|
const leaf = tree.astLeaves[i]
|
||||||
|
if (leaf.astType !== "array" && leaf.astType !== "function") {
|
||||||
|
throw Error("DIM: expected array decl, got " + leaf.astType)
|
||||||
|
}
|
||||||
|
const name = String(leaf.astValue).toUpperCase()
|
||||||
|
const dims = leaf.astLeaves.map(l => compileExpr(l, 0))
|
||||||
|
stmts.push(`${varRef(name)}=bS.__dim([${dims.join(",")}]);`)
|
||||||
|
}
|
||||||
|
return stmts.join(" ") + " " + setPc(nextPc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- top-level entry --------------------------------------------------
|
||||||
|
|
||||||
|
// Set of builtin names exposed by tbas.mjs. Used to decide whether a `lit`
|
||||||
|
// in expression position is a variable or a function reference.
|
||||||
|
const RUNTIME_BUILTINS = new Set([
|
||||||
|
"PRINT","EMIT","INPUT","CIN",
|
||||||
|
"ABS","SGN","INT","FLOOR","CEIL","FIX","ROUND","SQR","CBR",
|
||||||
|
"SIN","COS","TAN","ASN","ACO","ATN","SINH","COSH","TANH",
|
||||||
|
"EXP","LOG","MIN","MAX","RND",
|
||||||
|
"SPC","LEFT","RIGHT","MID","CHR",
|
||||||
|
"LEN","HEAD","TAIL","INIT","LAST","MAP","FOLD","FILTER","ARRAY",
|
||||||
|
"CLS","CLPX","PLOT","GOTOYX","TEXTFORE","TEXTBACK",
|
||||||
|
"POKE","PEEK","GETKEYSDOWN","CPUT","CGET","CSTA",
|
||||||
|
"TYPEOF","OPTIONBASE","OPTIONDEBUG","OPTIONTRACE",
|
||||||
|
"MRET","MLIST","MJOIN",
|
||||||
|
"AND","OR","NOT",
|
||||||
|
"DO","CLEAR","END","TO","STEP",
|
||||||
|
"FOR","FOREACH","NEXT","IF","ON","GOTO","GOSUB","RETURN",
|
||||||
|
"DIM","DATA","READ","RESTORE","LABEL","REM",
|
||||||
|
"TEST",
|
||||||
|
])
|
||||||
|
|
||||||
|
bS._compileImpl = function(outpath) {
|
||||||
|
if (typeof cmdbuf === "undefined") throw Error("compile.js: cmdbuf not available")
|
||||||
|
if (typeof bF === "undefined") throw Error("compile.js: bF not available")
|
||||||
|
if (typeof bF._interpretLine !== "function") throw Error("compile.js: bF._interpretLine not available")
|
||||||
|
|
||||||
|
// Reset parser-side state so we don't pollute the live interpreter
|
||||||
|
if (typeof lambdaBoundVars !== "undefined") lambdaBoundVars.length = 0
|
||||||
|
const savedPrescan = (typeof prescan !== "undefined") ? prescan : false
|
||||||
|
if (typeof prescan !== "undefined") prescan = true // suppress execution of LABEL/DATA prescan side-effects
|
||||||
|
|
||||||
|
// ---- pass 1: parse every line ----
|
||||||
|
const programTrees = [] // [lnum] -> array of statements
|
||||||
|
for (let lnum = 0; lnum < cmdbuf.length; lnum++) {
|
||||||
|
const linestr = cmdbuf[lnum]
|
||||||
|
if (linestr === undefined) continue
|
||||||
|
const trees = bF._interpretLine(lnum, String(linestr).trim())
|
||||||
|
if (trees !== undefined) programTrees[lnum] = trees
|
||||||
|
}
|
||||||
|
if (typeof prescan !== "undefined") prescan = savedPrescan
|
||||||
|
|
||||||
|
// ---- pass 2: ordered list of populated lnums and successor table ----
|
||||||
|
const linenums = []
|
||||||
|
for (let lnum = 0; lnum < programTrees.length; lnum++) {
|
||||||
|
if (programTrees[lnum] !== undefined) linenums.push(lnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPcOf(idx, stmtIdx) {
|
||||||
|
const lnum = linenums[idx]
|
||||||
|
const stmts = programTrees[lnum]
|
||||||
|
if (stmtIdx + 1 < stmts.length) return [lnum, stmtIdx + 1]
|
||||||
|
if (idx + 1 < linenums.length) return [linenums[idx + 1], 0]
|
||||||
|
return [Infinity, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pass 3: harvest DATA constants and LABEL definitions ----
|
||||||
|
const dataConsts = []
|
||||||
|
const labelMap = {}
|
||||||
|
for (let i = 0; i < linenums.length; i++) {
|
||||||
|
const lnum = linenums[i]
|
||||||
|
const stmts = programTrees[lnum]
|
||||||
|
for (let s = 0; s < stmts.length; s++) {
|
||||||
|
const t = stmts[s]
|
||||||
|
if (!t) continue
|
||||||
|
if (t.astValue === "DATA") {
|
||||||
|
for (let k = 0; k < t.astLeaves.length; k++) {
|
||||||
|
dataConsts.push(literalValue(t.astLeaves[k]))
|
||||||
|
}
|
||||||
|
} else if (t.astValue === "LABEL") {
|
||||||
|
const lblNode = t.astLeaves[0]
|
||||||
|
if (!lblNode) throw Error("LABEL with no name on line " + lnum)
|
||||||
|
const lblName = String(lblNode.astValue)
|
||||||
|
labelMap[lblName] = [lnum, s]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pass 4: emit case bodies ----
|
||||||
|
const cases = []
|
||||||
|
for (let i = 0; i < linenums.length; i++) {
|
||||||
|
const lnum = linenums[i]
|
||||||
|
const stmts = programTrees[lnum]
|
||||||
|
for (let s = 0; s < stmts.length; s++) {
|
||||||
|
const next = nextPcOf(i, s)
|
||||||
|
const body = compileStatement(stmts[s], lnum, s, next)
|
||||||
|
cases.push(` case ${lnum}*32+${s}: { ${body} break; }`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pass 5: assemble final output ----
|
||||||
|
const firstPc = (linenums.length > 0) ? `[${linenums[0]},0]` : `[Infinity,0]`
|
||||||
|
const labelMapJs = "{" + Object.keys(labelMap).map(k =>
|
||||||
|
`${jsLit(k)}: [${labelMap[k][0]}, ${labelMap[k][1]}]`
|
||||||
|
).join(", ") + "}"
|
||||||
|
|
||||||
|
const out =
|
||||||
|
`// Compiled by Terran BASIC -> JS compiler (assets/disk0/tbas/compile.js)
|
||||||
|
// Source line count: ${linenums.length}
|
||||||
|
let bS = require("tbas")
|
||||||
|
bS.__reset()
|
||||||
|
bS.__data(${jsLit(dataConsts)})
|
||||||
|
bS.__labels(${labelMapJs})
|
||||||
|
bS.__setLines(${jsLit(linenums)})
|
||||||
|
let pc = ${firstPc}
|
||||||
|
const gosubStack = []
|
||||||
|
while (pc[0] !== Infinity) {
|
||||||
|
switch (pc[0]*32 + pc[1]) {
|
||||||
|
${cases.join("\n")}
|
||||||
|
default: pc = [Infinity, 0]; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// ---- write to disk via basic.js's fs (writes under BASIC_HOME_PATH) ----
|
||||||
|
const opened = fs.open(outpath, "W")
|
||||||
|
if (!opened) throw Error("Cannot open " + outpath + " for writing")
|
||||||
|
fs.write(out)
|
||||||
|
return out.length
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -19,9 +19,9 @@ var Note = (function() {
|
|||||||
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.OFF = 0x0000; // key-off
|
t.NOP = 0x0000; // no-op (empty row)
|
||||||
t.CUT = 0xFFFE; // note cut (immediate)
|
t.OFF = 0x0001; // key-off
|
||||||
t.NOP = 0xFFFF; // no-op (empty row)
|
t.CUT = 0x0002; // note cut (immediate)
|
||||||
return t;
|
return t;
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -147,10 +149,12 @@ _TVDOS.variables = {
|
|||||||
LANG: "EN",
|
LANG: "EN",
|
||||||
KEYBOARD: "us_qwerty",
|
KEYBOARD: "us_qwerty",
|
||||||
PATH: "\\tvdos\\bin;\\home",
|
PATH: "\\tvdos\\bin;\\home",
|
||||||
|
INCLPATH: "\\tvdos\\include;\\home",
|
||||||
PATHEXT: ".com;.bat;.app;.js;.alias",
|
PATHEXT: ".com;.bat;.app;.js;.alias",
|
||||||
HELPPATH: "\\tvdos\\help",
|
HELPPATH: "\\tvdos\\help",
|
||||||
OS_NAME: "TSVM Disk Operating System",
|
OS_NAME: "TSVM Disk Operating System",
|
||||||
OS_VERSION: _TVDOS.VERSION
|
OS_VERSION: _TVDOS.VERSION,
|
||||||
|
USERCONFIGPATH: "\\home\\config",
|
||||||
};
|
};
|
||||||
Object.freeze(_TVDOS);
|
Object.freeze(_TVDOS);
|
||||||
|
|
||||||
@@ -162,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
|
||||||
}
|
}
|
||||||
@@ -937,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,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)
|
||||||
|
|
||||||
@@ -1108,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;
|
||||||
@@ -1135,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)
|
||||||
|
|
||||||
@@ -1405,9 +1442,6 @@ let requireFromMemory = (ptr) => {
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
|
|
||||||
var GL = require("A:/tvdos/include/gl.mjs")
|
|
||||||
|
|
||||||
|
|
||||||
// @param cmdsrc JS source code
|
// @param cmdsrc JS source code
|
||||||
// @param args arguments for the program, must be Array, and args[0] is always the name of the program, e.g.
|
// @param args arguments for the program, must be Array, and args[0] is always the name of the program, e.g.
|
||||||
// for command line 'echo foo bar', args[0] must be 'echo'
|
// for command line 'echo foo bar', args[0] must be 'echo'
|
||||||
@@ -1420,7 +1454,7 @@ var execApp = (cmdsrc, args, appname) => {
|
|||||||
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
||||||
`${appname}`); // making 'exec_args' a app-level global
|
`${appname}`); // making 'exec_args' a app-level global
|
||||||
|
|
||||||
execAppPrg(args);
|
return execAppPrg(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1437,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 = []
|
||||||
@@ -577,8 +576,76 @@ shell.coreutils = {
|
|||||||
ver: function(args) {
|
ver: function(args) {
|
||||||
println(welcome_text)
|
println(welcome_text)
|
||||||
},
|
},
|
||||||
|
which: function(args) {
|
||||||
|
if (args[1] === undefined) {
|
||||||
|
printerrln(`Usage: ${args[0].toUpperCase()} program_name`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
let cmd = args[1]
|
||||||
|
|
||||||
|
if (shell.coreutils[cmd.toLowerCase()] !== undefined) {
|
||||||
|
println(`${cmd}: shell built-in command`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileExists = false
|
||||||
|
var searchFile
|
||||||
|
var searchPath = ""
|
||||||
|
|
||||||
|
if (shell.isValidDriveLetter(cmd[0]) && cmd[1] == ':') {
|
||||||
|
searchFile = files.open(cmd)
|
||||||
|
searchPath = trimStartRevSlash(searchFile.path)
|
||||||
|
fileExists = searchFile.exists
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var searchDir = (cmd.startsWith("/")) ? [""] : ["/"+shell_pwd.join("/")].concat(_TVDOS.getPath())
|
||||||
|
|
||||||
|
var pathExt = []
|
||||||
|
if (cmd.split(".")[1] === undefined)
|
||||||
|
_TVDOS.variables.PATHEXT.split(';').forEach(function(it) { pathExt.push(it); pathExt.push(it.toUpperCase()); })
|
||||||
|
else
|
||||||
|
pathExt.push("")
|
||||||
|
|
||||||
|
searchLoop:
|
||||||
|
for (var i = 0; i < searchDir.length; i++) {
|
||||||
|
for (var j = 0; j < pathExt.length; j++) {
|
||||||
|
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||||
|
searchPath = trimStartRevSlash(search + cmd + pathExt[j])
|
||||||
|
|
||||||
|
searchFile = files.open(`${CURRENT_DRIVE}:\\${searchPath}`)
|
||||||
|
if (searchFile.exists) {
|
||||||
|
fileExists = true
|
||||||
|
break searchLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileExists) {
|
||||||
|
printerrln(`${cmd}: not found`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
println(searchFile.fullPath)
|
||||||
|
return 0
|
||||||
|
},
|
||||||
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
|
||||||
@@ -590,14 +657,19 @@ shell.coreutils.ls = shell.coreutils.dir
|
|||||||
shell.coreutils.time = shell.coreutils.date
|
shell.coreutils.time = shell.coreutils.date
|
||||||
shell.coreutils.md = shell.coreutils.mkdir
|
shell.coreutils.md = shell.coreutils.mkdir
|
||||||
shell.coreutils.move = shell.coreutils.mv
|
shell.coreutils.move = shell.coreutils.mv
|
||||||
|
shell.coreutils.where = shell.coreutils.which
|
||||||
// end of command aliases
|
// end of command aliases
|
||||||
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); },
|
||||||
@@ -614,13 +686,25 @@ require = function(path) {
|
|||||||
if (path[1] == ":") return shell.require(path)
|
if (path[1] == ":") return shell.require(path)
|
||||||
else {
|
else {
|
||||||
// if the path starts with ".", look for the current directory
|
// if the path starts with ".", look for the current directory
|
||||||
// if the path starts with [A-Za-z0-9], look for the DOSDIR/includes
|
// if the path starts with [A-Za-z0-9], search through INCLPATH
|
||||||
if (path[0] == '.') return shell.require(shell.resolvePathInput(path).full + ".mjs")
|
if (path[0] == '.') return shell.require(shell.resolvePathInput(path).full + ".mjs")
|
||||||
else return shell.require(`A:${_TVDOS.variables.DOSDIR}/include/${path}.mjs`)
|
else {
|
||||||
|
let inclDirs = (_TVDOS.variables.INCLPATH || "").split(';').filter(function(it) { return it.length > 0 })
|
||||||
|
for (let i = 0; i < inclDirs.length; i++) {
|
||||||
|
let dir = inclDirs[i]
|
||||||
|
if (!dir.endsWith('\\') && !dir.endsWith('/')) dir += '\\'
|
||||||
|
let candidate = `${CURRENT_DRIVE}:${dir}${path}.mjs`
|
||||||
|
if (files.open(candidate).exists) return shell.require(candidate)
|
||||||
|
}
|
||||||
|
// no match found; defer to shell.require with the first entry so the error mentions a sensible path
|
||||||
|
let firstDir = inclDirs[0] || `${_TVDOS.variables.DOSDIR}\\include`
|
||||||
|
if (!firstDir.endsWith('\\') && !firstDir.endsWith('/')) firstDir += '\\'
|
||||||
|
return shell.require(`${CURRENT_DRIVE}:${firstDir}${path}.mjs`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shell.execute = function(line) {
|
shell.execute = function(line, nameOverride) {
|
||||||
if (0 == line.size) return
|
if (0 == line.size) return
|
||||||
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
||||||
let statements = [] // [[echo, "hai"], [less]]
|
let statements = [] // [[echo, "hai"], [less]]
|
||||||
@@ -746,6 +830,8 @@ shell.execute = function(line) {
|
|||||||
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!
|
||||||
@@ -757,19 +843,28 @@ shell.execute = function(line) {
|
|||||||
// parse alias
|
// parse alias
|
||||||
// $0: all arguments
|
// $0: all arguments
|
||||||
// $1..9: specific arguments
|
// $1..9: specific arguments
|
||||||
|
// Tokens that contain whitespace or shell metacharacters must be re-quoted
|
||||||
|
// before re-execution, otherwise the re-parse splits them on spaces.
|
||||||
|
var quoteAliasArg = function(s) {
|
||||||
|
if (s === undefined || s === null) return ""
|
||||||
|
s = ''+s
|
||||||
|
if (s.length === 0) return ""
|
||||||
|
if (/[\s"|><&]/.test(s)) return '"' + s.replaceAll('"', '^"') + '"'
|
||||||
|
return s
|
||||||
|
}
|
||||||
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!
|
||||||
lines.forEach(function(line) {
|
lines.forEach(function(line) {
|
||||||
var newLine = line
|
var newLine = line
|
||||||
|
|
||||||
// replace $1..$9
|
// replace $1..$9
|
||||||
for (let j = 1; j < 9; j++) {
|
for (let j = 1; j <= 9; j++) {
|
||||||
newLine = newLine.replaceAll('$'+j, tokens[j])
|
newLine = newLine.replaceAll('$'+j, quoteAliasArg(tokens[j]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace $0
|
// replace $0
|
||||||
newLine = newLine.replaceAll('$0', tokens.slice(1).join(' '))
|
newLine = newLine.replaceAll('$0', tokens.slice(1).map(quoteAliasArg).join(' '))
|
||||||
|
|
||||||
shell.execute(newLine)
|
shell.execute(newLine, cmd)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else if ("APP" == extension) {
|
else if ("APP" == extension) {
|
||||||
@@ -786,6 +881,10 @@ shell.execute = function(line) {
|
|||||||
errorlevel = 0 // reset the number
|
errorlevel = 0 // reset the number
|
||||||
|
|
||||||
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
||||||
|
if (nameOverride !== undefined) {
|
||||||
|
tokens[0] = (''+nameOverride)
|
||||||
|
cmd = tokens[0]
|
||||||
|
}
|
||||||
_G.shellProgramTitles.push(cmd.toUpperCase())
|
_G.shellProgramTitles.push(cmd.toUpperCase())
|
||||||
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
||||||
//serial.println(_G.shellProgramTitles)
|
//serial.println(_G.shellProgramTitles)
|
||||||
@@ -825,6 +924,9 @@ shell.execute = function(line) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
shell.runningScriptPaths.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +986,246 @@ 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
|
||||||
|
try {
|
||||||
|
let userConfigPath = `${CURRENT_DRIVE}:${_TVDOS.variables.USERCONFIGPATH}`
|
||||||
|
let userConfigDir = files.open(userConfigPath)
|
||||||
|
if (!userConfigDir.exists) {
|
||||||
|
debugprintln(`command.js > creating USERCONFIGPATH at ${userConfigPath}`)
|
||||||
|
userConfigDir.mkDir()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > USERCONFIGPATH creation failed: " + e.message)
|
||||||
|
}
|
||||||
|
|
||||||
if (exec_args[1] !== undefined) {
|
if (exec_args[1] !== undefined) {
|
||||||
// only meaningful switches would be either -c or -k anyway
|
// only meaningful switches would be either -c or -k anyway
|
||||||
@@ -928,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)
|
||||||
@@ -960,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
|
||||||
@@ -1,5 +1,956 @@
|
|||||||
/**
|
/**
|
||||||
* Hopper is a package manager for TSVM
|
* Hopper is a package manager for TVDOS
|
||||||
* Created by CuriousTorvald on 2026-04-16
|
* Created by CuriousTorvald on 2026-04-16
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const SYSTEM_PACKEAGE_DEF_DIR = "A:/tvdos/hopper"
|
||||||
|
const USER_BASE_DIR = "A:/hopper"
|
||||||
|
const USER_PACKAGE_DEF_DIR = `${USER_BASE_DIR}/manifests`
|
||||||
|
const USER_PACKAGE_BIN_DIR = `${USER_BASE_DIR}/bin`
|
||||||
|
const USER_PACKAGE_INCLUDE_DIR = `${USER_BASE_DIR}/include`
|
||||||
|
const MANIFEST_EXT = "hop.per"
|
||||||
|
const MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list`
|
||||||
|
|
||||||
|
const net = require("net")
|
||||||
|
|
||||||
|
// SYNOPSIS
|
||||||
|
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||||
|
//// default searches from ProperName
|
||||||
|
// hopper {install,in} query [-v version]
|
||||||
|
// hopper {remove,rm} query
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Manifest parsing
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function splitList(s) {
|
||||||
|
if (!s) return []
|
||||||
|
return s.split(";").map(it => it.trim()).filter(it => it.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseManifest(text) {
|
||||||
|
const m = {}
|
||||||
|
text.split("\n").forEach(rawLine => {
|
||||||
|
const line = rawLine.replace(/\r$/, "")
|
||||||
|
if (line.length === 0) return
|
||||||
|
const idx = line.indexOf(":")
|
||||||
|
if (idx < 0) return
|
||||||
|
const key = line.substring(0, idx).trim()
|
||||||
|
const value = line.substring(idx + 1).trim()
|
||||||
|
m[key] = value
|
||||||
|
})
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
function readManifestFile(path) {
|
||||||
|
const f = files.open(path)
|
||||||
|
if (!f.exists || f.isDirectory) return undefined
|
||||||
|
const m = parseManifest(f.sread())
|
||||||
|
m._manifestPath = path
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
function _listManifestsFrom(dirPath, origin) {
|
||||||
|
const dir = files.open(dirPath)
|
||||||
|
if (!dir.exists || !dir.isDirectory) return []
|
||||||
|
const out = []
|
||||||
|
dir.list().forEach(entry => {
|
||||||
|
if (entry.isDirectory) return
|
||||||
|
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
|
||||||
|
const m = readManifestFile(entry.fullPath)
|
||||||
|
if (m !== undefined) {
|
||||||
|
m._origin = origin
|
||||||
|
out.push(m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// System packages (shipped with TVDOS) live in SYSTEM_PACKAGE_DEF_DIR
|
||||||
|
// and are read-only as far as hopper is concerned. User packages,
|
||||||
|
// installed by `hopper install`, live under USER_PACKAGE_DEF_DIR. The
|
||||||
|
// resolver treats both as "installed", but the install/remove paths
|
||||||
|
// refuse to modify anything tagged `_origin === "system"`.
|
||||||
|
function listInstalledManifests() {
|
||||||
|
return _listManifestsFrom(SYSTEM_PACKEAGE_DEF_DIR, "system")
|
||||||
|
.concat(_listManifestsFrom(USER_PACKAGE_DEF_DIR, "user"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInstalledManifest(name) {
|
||||||
|
// Prefer user-installed copy when a system package with the same name
|
||||||
|
// also exists -- but that combination is normally refused at install.
|
||||||
|
const userDirect = `${USER_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||||
|
let m = readManifestFile(userDirect)
|
||||||
|
if (m !== undefined) { m._origin = "user"; return m }
|
||||||
|
|
||||||
|
const sysDirect = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||||
|
m = readManifestFile(sysDirect)
|
||||||
|
if (m !== undefined) { m._origin = "system"; return m }
|
||||||
|
|
||||||
|
const all = listInstalledManifests()
|
||||||
|
for (let i = 0; i < all.length; i++) {
|
||||||
|
if ((all[i].HopperPackageName || "") === name) return all[i]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes/no prompt. Empty input falls back to `defaultYes`.
|
||||||
|
function confirm(prompt, defaultYes) {
|
||||||
|
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
||||||
|
print(`${prompt} ${hint} `)
|
||||||
|
const ans = (read() || "").trim().toLowerCase()
|
||||||
|
if (ans === "") return !!defaultYes
|
||||||
|
return ans === "y" || ans === "yes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Install layout helpers
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// User-installed packages live under `A:/hopper/`. Files are routed
|
||||||
|
// by extension: `.mjs` includes go under `include/`, everything else
|
||||||
|
// (`.js`, `.alias`, `.lfs`, data blobs, ...) lands in `bin/`. The
|
||||||
|
// downloaded manifest is saved under `manifests/` with a
|
||||||
|
// `SystemPackagePath` field appended that lists the resulting paths.
|
||||||
|
|
||||||
|
// Strip query/fragment and take the last `/`-separated component of `url`.
|
||||||
|
function urlBasename(url) {
|
||||||
|
let s = String(url || "")
|
||||||
|
const qm = s.indexOf("?"); if (qm >= 0) s = s.substring(0, qm)
|
||||||
|
const hash = s.indexOf("#"); if (hash >= 0) s = s.substring(0, hash)
|
||||||
|
const slash = s.lastIndexOf("/")
|
||||||
|
return (slash < 0) ? s : s.substring(slash + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeForBasename(name) {
|
||||||
|
return (String(name || "").toLowerCase().endsWith(".mjs"))
|
||||||
|
? USER_PACKAGE_INCLUDE_DIR
|
||||||
|
: USER_PACKAGE_BIN_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a USER_BASE_DIR-relative absolute path ("A:/hopper/bin/foo.js")
|
||||||
|
// into its declarable form ("/hopper/bin/foo.js"), matching the
|
||||||
|
// `SystemPackagePath` convention used by the system manifests.
|
||||||
|
function declarablePath(absPath) {
|
||||||
|
let p = String(absPath || "").replace(/\\/g, "/")
|
||||||
|
if (/^[A-Za-z]:/.test(p)) p = p.substring(2)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse PackageFileList (semicolon-separated full URLs) into a list of
|
||||||
|
// download descriptors: { url, basename, localPath }.
|
||||||
|
function parsePackageFileList(s) {
|
||||||
|
const out = []
|
||||||
|
splitList(s || "").forEach(url => {
|
||||||
|
const base = urlBasename(url)
|
||||||
|
if (base.length === 0) return
|
||||||
|
const dir = routeForBasename(base)
|
||||||
|
out.push({ url: url, basename: base, localPath: `${dir}/${base}` })
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureUserDirs() {
|
||||||
|
[USER_BASE_DIR, USER_PACKAGE_BIN_DIR, USER_PACKAGE_INCLUDE_DIR, USER_PACKAGE_DEF_DIR].forEach(p => {
|
||||||
|
const d = files.open(p)
|
||||||
|
if (!d.exists) d.mkDir()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-emit a parsed manifest, preserving insertion order, dropping
|
||||||
|
// internal `_*` keys, and replacing any pre-existing SystemPackagePath
|
||||||
|
// with the locally-computed one so the field always reflects what is
|
||||||
|
// actually on disk.
|
||||||
|
function serializeManifest(manifestObj, installedPathStr) {
|
||||||
|
const lines = []
|
||||||
|
Object.keys(manifestObj).forEach(k => {
|
||||||
|
if (k.length > 0 && k[0] === "_") return
|
||||||
|
if (k === "SystemPackagePath") return
|
||||||
|
lines.push(`${k}:${manifestObj[k]}`)
|
||||||
|
})
|
||||||
|
lines.push(`SystemPackagePath:${installedPathStr}`)
|
||||||
|
return lines.join("\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete every file declared in `manifest.SystemPackagePath` plus the
|
||||||
|
// manifest file itself. Wildcards are expanded via `expandSystemPath`.
|
||||||
|
function deleteInstalledFiles(manifest) {
|
||||||
|
const removed = []
|
||||||
|
splitList(manifest.SystemPackagePath || "").forEach(p => {
|
||||||
|
expandSystemPath(p).forEach(abs => {
|
||||||
|
const fd = files.open(abs)
|
||||||
|
if (!fd.exists) return
|
||||||
|
try { fd.remove(); removed.push(abs) }
|
||||||
|
catch (e) { printerrln(` ! failed to remove ${abs}: ${e}`) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (manifest._manifestPath) {
|
||||||
|
const mfd = files.open(manifest._manifestPath)
|
||||||
|
if (mfd.exists) {
|
||||||
|
try { mfd.remove(); removed.push(manifest._manifestPath) }
|
||||||
|
catch (e) { printerrln(` ! failed to remove ${manifest._manifestPath}: ${e}`) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SemVer (strict X.Y.Z) and constraint matching
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Versions are strict Semantic Versioning: three non-negative integer
|
||||||
|
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
|
||||||
|
//
|
||||||
|
// Constraint grammar (intentionally small, expandable later):
|
||||||
|
// * any version
|
||||||
|
// X.* major X, any minor/patch
|
||||||
|
// X.Y.* major X, minor Y, any patch
|
||||||
|
// X.Y.Z exact
|
||||||
|
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
|
||||||
|
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
|
||||||
|
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
|
||||||
|
//
|
||||||
|
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
|
||||||
|
|
||||||
|
function parseVersion(v) {
|
||||||
|
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||||
|
if (!m) return [0, 0, 0]
|
||||||
|
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersion(a, b) {
|
||||||
|
const A = parseVersion(a), B = parseVersion(b)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function _matchSingleConstraint(version, c) {
|
||||||
|
c = c.trim()
|
||||||
|
if (c === "" || c === "*") return true
|
||||||
|
|
||||||
|
// Operator form: ^, ~, >=, <=, >, <, =
|
||||||
|
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
|
||||||
|
if (opMatch) {
|
||||||
|
const op = opMatch[1]
|
||||||
|
const target = opMatch[2]
|
||||||
|
const cmp = compareVersion(version, target)
|
||||||
|
const [tM, tm] = parseVersion(target)
|
||||||
|
switch (op) {
|
||||||
|
case "=": return cmp === 0
|
||||||
|
case ">": return cmp > 0
|
||||||
|
case ">=": return cmp >= 0
|
||||||
|
case "<": return cmp < 0
|
||||||
|
case "<=": return cmp <= 0
|
||||||
|
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
|
||||||
|
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
|
||||||
|
const parts = c.split(".")
|
||||||
|
const vparts = parseVersion(version)
|
||||||
|
for (let i = 0; i < parts.length && i < 3; i++) {
|
||||||
|
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
|
||||||
|
const expected = parseInt(parts[i], 10)
|
||||||
|
if (isNaN(expected) || vparts[i] !== expected) return false
|
||||||
|
}
|
||||||
|
// All listed parts matched literally; remaining parts (if any) must be 0
|
||||||
|
for (let i = parts.length; i < 3; i++) {
|
||||||
|
if (vparts[i] !== 0) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function satisfies(version, constraint) {
|
||||||
|
if (!constraint) return true
|
||||||
|
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRequires(s) {
|
||||||
|
const out = []
|
||||||
|
splitList(s || "").forEach(entry => {
|
||||||
|
// "<name>" or "<name> <constraint>"
|
||||||
|
const idx = entry.search(/\s+/)
|
||||||
|
if (idx < 0) {
|
||||||
|
out.push({ name: entry, constraint: "*" })
|
||||||
|
} else {
|
||||||
|
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// HopperProvides entries are "<name>" or "<name> <version>". A bare name
|
||||||
|
// falls back to the package's own HopperPackageVersion — the same idea
|
||||||
|
// as RPM's `Provides: aalib = 1.2.0` (where the package's real name and
|
||||||
|
// version may differ from the virtual identity it exposes).
|
||||||
|
function parseProvides(s, fallbackVersion) {
|
||||||
|
const out = []
|
||||||
|
splitList(s || "").forEach(entry => {
|
||||||
|
const idx = entry.search(/\s+/)
|
||||||
|
if (idx < 0) {
|
||||||
|
out.push({ name: entry, version: fallbackVersion })
|
||||||
|
} else {
|
||||||
|
const v = entry.substring(idx + 1).trim()
|
||||||
|
out.push({ name: entry.substring(0, idx), version: v || fallbackVersion })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the version a candidate exposes for `name`. If `name` matches
|
||||||
|
// the package's own name (or isn't declared in HopperProvides at all),
|
||||||
|
// returns the package's own version.
|
||||||
|
function providedVersionOf(candidate, name) {
|
||||||
|
if (candidate.provides) {
|
||||||
|
for (let i = 0; i < candidate.provides.length; i++) {
|
||||||
|
if (candidate.provides[i].name === name) return candidate.provides[i].version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidate.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Candidate index (installed + upstream)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function _manifestToCandidate(m, source) {
|
||||||
|
const name = m.HopperPackageName || ""
|
||||||
|
const version = m.HopperPackageVersion || "0.0.0"
|
||||||
|
const provides = parseProvides(m.HopperProvides || "", version)
|
||||||
|
// Every package implicitly provides itself at its own version. Only
|
||||||
|
// synthesise this when the manifest didn't declare it explicitly.
|
||||||
|
if (name && !provides.some(p => p.name === name)) {
|
||||||
|
provides.unshift({ name: name, version: version })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
version: version,
|
||||||
|
requires: parseRequires(m.HopperRequires || ""),
|
||||||
|
provides: provides,
|
||||||
|
source: source, // "installed" | "upstream"
|
||||||
|
manifest: m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns map: packageName -> array<Candidate>
|
||||||
|
function buildCandidateIndex() {
|
||||||
|
const idx = new Map()
|
||||||
|
function add(c) {
|
||||||
|
if (!idx.has(c.name)) idx.set(c.name, [])
|
||||||
|
// De-dupe (name+version+source)
|
||||||
|
const arr = idx.get(c.name)
|
||||||
|
if (arr.some(x => x.version === c.version && x.source === c.source)) return
|
||||||
|
arr.push(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
|
||||||
|
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
|
||||||
|
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything that satisfies a requirement on `name`: a package whose own
|
||||||
|
// HopperPackageName matches OR whose HopperProvides declares `name`.
|
||||||
|
// Each candidate now carries `provides` as {name, version} pairs; the
|
||||||
|
// package's own (name, version) is always present (see
|
||||||
|
// _manifestToCandidate), so a single pass over `provides` is enough.
|
||||||
|
function findProviders(idx, name) {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
idx.forEach(candidates => {
|
||||||
|
candidates.forEach(c => {
|
||||||
|
if (seen.has(c)) return
|
||||||
|
if (c.provides.some(p => p.name === name)) {
|
||||||
|
out.push(c)
|
||||||
|
seen.add(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: installed first (no churn), then highest version, then upstream order.
|
||||||
|
function sortCandidates(cands) {
|
||||||
|
return cands.slice().sort((a, b) => {
|
||||||
|
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||||
|
return -compareVersion(a.version, b.version)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// State: chosen :: Map<packageName, Candidate>
|
||||||
|
// At every choice point we snapshot the whole map so that backtracking
|
||||||
|
// also undoes any transitive picks. The candidate ordering encodes the
|
||||||
|
// preference policy:
|
||||||
|
//
|
||||||
|
// 1. Keep installed if it satisfies the constraint.
|
||||||
|
// 2. Otherwise pick the newest upstream version that satisfies.
|
||||||
|
// 3. If newer versions cause downstream conflicts, walk older versions
|
||||||
|
// (downgrade) until either something fits or candidates are exhausted.
|
||||||
|
//
|
||||||
|
// The structure is intentionally close to DPLL: each "decision" is the
|
||||||
|
// candidate we assign to a variable, and "unit propagation" is the
|
||||||
|
// recursive resolve() call over each requirement. Replacing this with
|
||||||
|
// clause learning / a watched-literals scheme later would be local.
|
||||||
|
|
||||||
|
function resolveAll(idx, requirements) {
|
||||||
|
const chosen = new Map()
|
||||||
|
const issues = []
|
||||||
|
|
||||||
|
function snapshot() { return new Map(chosen) }
|
||||||
|
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||||
|
|
||||||
|
function _resolve(reqName, constraint, trail) {
|
||||||
|
const existing = chosen.get(reqName)
|
||||||
|
if (existing !== undefined) {
|
||||||
|
const v = providedVersionOf(existing, reqName)
|
||||||
|
return satisfies(v, constraint)
|
||||||
|
? { ok: true }
|
||||||
|
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = findProviders(idx, reqName)
|
||||||
|
if (providers.length === 0) {
|
||||||
|
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
|
||||||
|
}
|
||||||
|
// Satisfaction checks the virtual version the candidate exposes
|
||||||
|
// for `reqName` (HopperProvides), not necessarily the package's
|
||||||
|
// own HopperPackageVersion.
|
||||||
|
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
||||||
|
if (matching.length === 0) {
|
||||||
|
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||||
|
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastReason = null
|
||||||
|
for (let i = 0; i < matching.length; i++) {
|
||||||
|
const cand = matching[i]
|
||||||
|
const snap = snapshot()
|
||||||
|
chosen.set(cand.name, cand)
|
||||||
|
|
||||||
|
let allOk = true
|
||||||
|
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
|
||||||
|
for (let j = 0; j < cand.requires.length; j++) {
|
||||||
|
const req = cand.requires[j]
|
||||||
|
const r = _resolve(req.name, req.constraint, subTrail)
|
||||||
|
if (!r.ok) {
|
||||||
|
allOk = false
|
||||||
|
lastReason = r.reason
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allOk) return { ok: true }
|
||||||
|
restore(snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
|
||||||
|
}
|
||||||
|
|
||||||
|
requirements.forEach(req => {
|
||||||
|
const r = _resolve(req.name, req.constraint, [])
|
||||||
|
if (!r.ok) issues.push(r.reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { chosen, issues }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare resolved assignment against currently-installed state.
|
||||||
|
function classifyPlan(idx, chosen) {
|
||||||
|
const installedByName = new Map()
|
||||||
|
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
|
||||||
|
|
||||||
|
const actions = []
|
||||||
|
chosen.forEach((cand, name) => {
|
||||||
|
const inst = installedByName.get(name)
|
||||||
|
if (cand.source === "installed") {
|
||||||
|
actions.push({ action: "keep", name, version: cand.version })
|
||||||
|
}
|
||||||
|
else if (inst === undefined) {
|
||||||
|
actions.push({ action: "install", name, version: cand.version })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
|
||||||
|
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||||
|
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||||
|
else actions.push({ action: "reinstall", name, version: cand.version })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
function printPlan(actions, target) {
|
||||||
|
const changing = actions.filter(a => a.action !== "keep")
|
||||||
|
if (changing.length === 0) {
|
||||||
|
println(`Nothing to do: ${target} is already installed and satisfied.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
println("Plan:")
|
||||||
|
changing.forEach(a => {
|
||||||
|
switch (a.action) {
|
||||||
|
case "install": println(` + install ${a.name} ${a.version}`); break
|
||||||
|
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||||
|
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||||
|
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Remote mirrors
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// `mirrors.list` lives next to the installed package manifests.
|
||||||
|
// Each non-empty, non-`#` line is the URL prefix of a Hopper mirror.
|
||||||
|
// The mirror MUST expose `<prefix>mirror_manifest` (key:value pairs
|
||||||
|
// describing the mirror) and `<prefix>filelist` (CSV with rows of
|
||||||
|
// `packagename,version,hoppermanifest-filename`).
|
||||||
|
//
|
||||||
|
// Trailing slash on the prefix is optional and will be added if missing.
|
||||||
|
|
||||||
|
function loadMirrorList() {
|
||||||
|
const f = files.open(MIRROR_LIST_PATH)
|
||||||
|
if (!f.exists || f.isDirectory) return []
|
||||||
|
return f.sread().split("\n")
|
||||||
|
.map(line => line.replace(/\r$/, "").trim())
|
||||||
|
.filter(line => line.length > 0 && line[0] !== "#")
|
||||||
|
.map(line => line.endsWith("/") ? line : (line + "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFileList(text) {
|
||||||
|
const out = []
|
||||||
|
text.split("\n").forEach(raw => {
|
||||||
|
const line = raw.replace(/\r$/, "").trim()
|
||||||
|
if (line.length === 0 || line[0] === "#") return
|
||||||
|
const parts = line.split(",")
|
||||||
|
if (parts.length < 3) return
|
||||||
|
out.push({
|
||||||
|
name: parts[0].trim(),
|
||||||
|
version: parts[1].trim(),
|
||||||
|
file: parts[2].trim(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchManifestsFromMirror(prefix) {
|
||||||
|
const mfText = net.fetchText(prefix + "mirror_manifest")
|
||||||
|
if (mfText === null) {
|
||||||
|
printerrln(` ! could not reach mirror: ${prefix}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const mirror = parseManifest(mfText)
|
||||||
|
const mirrorName = mirror.HopperMirrorName || prefix
|
||||||
|
|
||||||
|
const flText = net.fetchText(prefix + "filelist")
|
||||||
|
if (flText === null) {
|
||||||
|
printerrln(` ! mirror "${mirrorName}" has no filelist`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
parseFileList(flText).forEach(entry => {
|
||||||
|
const manifestText = net.fetchText(prefix + entry.file)
|
||||||
|
if (manifestText === null) {
|
||||||
|
printerrln(` ! mirror "${mirrorName}" missing ${entry.file}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const m = parseManifest(manifestText)
|
||||||
|
m._mirrorName = mirrorName
|
||||||
|
m._mirrorPrefix = prefix
|
||||||
|
m._manifestUrl = prefix + entry.file
|
||||||
|
out.push(m)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-invocation memoisation. Search and install both pull the same
|
||||||
|
// data; we only want to hit the network once per `hopper ...` call.
|
||||||
|
let _remoteCache = null
|
||||||
|
|
||||||
|
function fetchRemoteCandidates() {
|
||||||
|
if (_remoteCache !== null) return _remoteCache
|
||||||
|
|
||||||
|
const mirrors = loadMirrorList()
|
||||||
|
if (mirrors.length === 0) {
|
||||||
|
_remoteCache = []
|
||||||
|
return _remoteCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!net.isAvailable()) {
|
||||||
|
printerrln("Warning: no HTTP modem attached; remote mirrors will be skipped.")
|
||||||
|
_remoteCache = []
|
||||||
|
return _remoteCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
mirrors.forEach(prefix => {
|
||||||
|
fetchManifestsFromMirror(prefix).forEach(m => out.push(m))
|
||||||
|
})
|
||||||
|
_remoteCache = out
|
||||||
|
return _remoteCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Search
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function fieldCandidates(manifest, field) {
|
||||||
|
switch (field) {
|
||||||
|
case "provides": return splitList(manifest.HopperProvides || "")
|
||||||
|
case "requires": return splitList(manifest.HopperRequires || "")
|
||||||
|
case "description": return [manifest.ProperDescription || ""]
|
||||||
|
case "author": return [manifest.ProperAuthor || ""]
|
||||||
|
default: return [manifest.ProperName || "", manifest.HopperPackageName || ""]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(manifest, field, query) {
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
return fieldCandidates(manifest, field).some(c => c.toLowerCase().indexOf(q) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSearchResult(m, origin) {
|
||||||
|
const name = m.ProperName || m.HopperPackageName || "(unnamed)"
|
||||||
|
const ver = m.HopperPackageVersion || "?"
|
||||||
|
println(` [${origin}] ${name} -- ${m.HopperPackageName} ${ver}`)
|
||||||
|
if (m.ProperDescription) println(` ${m.ProperDescription}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdSearch(args) {
|
||||||
|
let field = "name"
|
||||||
|
let query = undefined
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i]
|
||||||
|
if (a === "--provides") field = "provides"
|
||||||
|
else if (a === "--requires") field = "requires"
|
||||||
|
else if (a === "--description") field = "description"
|
||||||
|
else if (a === "--author") field = "author"
|
||||||
|
else if (a.startsWith("--")) { printerrln(`Unknown option: ${a}`); return 1 }
|
||||||
|
else query = a
|
||||||
|
}
|
||||||
|
if (query === undefined) {
|
||||||
|
printerrln("Usage: hopper search [--provides|--requires|--description|--author] <query>")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
println(`Searching installed packages in ${SYSTEM_PACKEAGE_DEF_DIR} ...`)
|
||||||
|
const sysHits = listInstalledManifests().filter(m => matchesQuery(m, field, query))
|
||||||
|
if (sysHits.length === 0) println(" (no matches)")
|
||||||
|
else sysHits.forEach(m => printSearchResult(m, "installed"))
|
||||||
|
|
||||||
|
println("")
|
||||||
|
println("Searching remote mirrors ...")
|
||||||
|
const remote = fetchRemoteCandidates()
|
||||||
|
if (remote.length === 0) {
|
||||||
|
println(" (no mirrors configured or reachable)")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const netHits = remote.filter(m => matchesQuery(m, field, query))
|
||||||
|
if (netHits.length === 0) println(" (no matches)")
|
||||||
|
else netHits.forEach(m => printSearchResult(m, m._mirrorName || "remote"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Install
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Each upstream manifest declares its payload via `PackageFileList`,
|
||||||
|
// a semicolon-separated list of full URLs. Hopper fetches each URL and
|
||||||
|
// drops the result in /hopper/bin (default) or /hopper/include (.mjs).
|
||||||
|
// The locally-saved manifest gets a `SystemPackagePath` field appended
|
||||||
|
// listing the resulting absolute paths, which is what `cmdRemove` later
|
||||||
|
// walks to clean up.
|
||||||
|
|
||||||
|
function _installOne(action, candidate) {
|
||||||
|
const m = candidate.manifest
|
||||||
|
const files_ = parsePackageFileList(m.PackageFileList)
|
||||||
|
if (files_.length === 0) {
|
||||||
|
printerrln(` ! ${candidate.name}: upstream manifest has no PackageFileList; cannot install`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch first, write second: a single 404 should not leave a
|
||||||
|
// half-installed package behind.
|
||||||
|
const fetched = []
|
||||||
|
for (let i = 0; i < files_.length; i++) {
|
||||||
|
const f = files_[i]
|
||||||
|
println(` fetch ${f.url}`)
|
||||||
|
const body = net.fetchText(f.url)
|
||||||
|
if (body === null || body === undefined) {
|
||||||
|
printerrln(` ! failed to fetch ${f.url}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
fetched.push({ entry: f, body: body })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are replacing an existing user-installed copy, remove its
|
||||||
|
// old files first so a renamed payload doesn't leave orphans.
|
||||||
|
if (action !== "install") {
|
||||||
|
const oldManifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||||
|
const old = readManifestFile(oldManifestPath)
|
||||||
|
if (old !== undefined) {
|
||||||
|
splitList(old.SystemPackagePath || "").forEach(p => {
|
||||||
|
expandSystemPath(p).forEach(abs => {
|
||||||
|
const fd = files.open(abs)
|
||||||
|
if (fd.exists) {
|
||||||
|
try { fd.remove() }
|
||||||
|
catch (e) { printerrln(` ! could not remove old ${abs}: ${e}`) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write payload files.
|
||||||
|
fetched.forEach(item => {
|
||||||
|
const fd = files.open(item.entry.localPath)
|
||||||
|
if (!fd.exists) fd.mkFile()
|
||||||
|
fd.swrite(item.body)
|
||||||
|
println(` write ${item.entry.localPath}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save the manifest with SystemPackagePath appended.
|
||||||
|
const sysPath = fetched.map(item => declarablePath(item.entry.localPath)).join(";")
|
||||||
|
const manifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||||
|
const mfd = files.open(manifestPath)
|
||||||
|
if (!mfd.exists) mfd.mkFile()
|
||||||
|
mfd.swrite(serializeManifest(m, sysPath))
|
||||||
|
println(` write ${manifestPath}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdInstall(args) {
|
||||||
|
let query = undefined
|
||||||
|
let version = undefined
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
||||||
|
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||||
|
else query = args[i]
|
||||||
|
}
|
||||||
|
if (query === undefined) {
|
||||||
|
printerrln("Usage: hopper install <package> [-v <version>]")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetConstraint = version || "*"
|
||||||
|
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
|
||||||
|
println(`Resolving ${query}${verSuffix} ...`)
|
||||||
|
|
||||||
|
const idx = buildCandidateIndex()
|
||||||
|
|
||||||
|
// Sanity check: target must exist in the index (installed or upstream).
|
||||||
|
if (findProviders(idx, query).length === 0) {
|
||||||
|
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||||
|
// constraints can drive upgrades of dependencies. The installed-set
|
||||||
|
// requirements follow at "*" so the resolver still has to keep them
|
||||||
|
// alive (preferring installed candidates when their version still fits,
|
||||||
|
// otherwise upgrading or downgrading them).
|
||||||
|
const seed = [{ name: query, constraint: targetConstraint }]
|
||||||
|
listInstalledManifests().forEach(m => {
|
||||||
|
if (m.HopperPackageName === query) return
|
||||||
|
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const { chosen, issues } = resolveAll(idx, seed)
|
||||||
|
if (issues.length > 0) {
|
||||||
|
printerrln("Resolution failed:")
|
||||||
|
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||||
|
printerrln("")
|
||||||
|
printerrln("No solution found -- not installable.")
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = classifyPlan(idx, chosen)
|
||||||
|
printPlan(plan, query)
|
||||||
|
|
||||||
|
const changing = plan.filter(a => a.action !== "keep")
|
||||||
|
if (changing.length === 0) return 0
|
||||||
|
|
||||||
|
// Pre-flight: refuse to clobber system packages, and require every
|
||||||
|
// upstream candidate to actually carry a payload list.
|
||||||
|
const blockers = []
|
||||||
|
changing.forEach(a => {
|
||||||
|
const cand = chosen.get(a.name)
|
||||||
|
const inst = findInstalledManifest(a.name)
|
||||||
|
if (inst && inst._origin === "system") {
|
||||||
|
blockers.push(`${a.name}: cannot ${a.action} -- a system package with that name is already installed`)
|
||||||
|
}
|
||||||
|
if (cand && cand.source === "upstream" && !(cand.manifest.PackageFileList && cand.manifest.PackageFileList.length > 0)) {
|
||||||
|
blockers.push(`${a.name}: upstream manifest declares no PackageFileList`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (blockers.length > 0) {
|
||||||
|
printerrln("Cannot proceed:")
|
||||||
|
blockers.forEach(b => printerrln(` - ${b}`))
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!net.isAvailable()) {
|
||||||
|
printerrln("No HTTP modem attached; cannot fetch package files.")
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
println("")
|
||||||
|
if (!confirm("Proceed with installation?", true)) {
|
||||||
|
println("Aborted.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUserDirs()
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
for (let i = 0; i < changing.length; i++) {
|
||||||
|
const a = changing[i]
|
||||||
|
const cand = chosen.get(a.name)
|
||||||
|
if (a.action === "install" || a.action === "reinstall") {
|
||||||
|
println(`${a.action} ${a.name} ${a.version}`)
|
||||||
|
} else {
|
||||||
|
println(`${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
||||||
|
}
|
||||||
|
if (!_installOne(a.action, cand)) {
|
||||||
|
failed++
|
||||||
|
printerrln(` ! ${a.name}: aborted`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
printerrln(`${failed} package(s) failed to install.`)
|
||||||
|
return 7
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Done.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Remove
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a
|
||||||
|
// concrete list of files on the A: drive. Supports a simple '*' wildcard
|
||||||
|
// in the filename component.
|
||||||
|
function expandSystemPath(pattern) {
|
||||||
|
const sysDrive = "A:"
|
||||||
|
|
||||||
|
if (pattern.indexOf("*") < 0) {
|
||||||
|
return [`${sysDrive}${pattern}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
const fwd = pattern.lastIndexOf("/")
|
||||||
|
const bck = pattern.lastIndexOf("\\")
|
||||||
|
const lastSep = Math.max(fwd, bck)
|
||||||
|
const dirPart = (lastSep < 0) ? "" : pattern.substring(0, lastSep)
|
||||||
|
const namePart = (lastSep < 0) ? pattern : pattern.substring(lastSep + 1)
|
||||||
|
|
||||||
|
const dir = files.open(`${sysDrive}${dirPart}/`)
|
||||||
|
if (!dir.exists || !dir.isDirectory) return []
|
||||||
|
|
||||||
|
const escaped = namePart.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
|
||||||
|
const re = new RegExp(`^${escaped}$`, "i")
|
||||||
|
|
||||||
|
const out = []
|
||||||
|
dir.list().forEach(entry => {
|
||||||
|
if (entry.isDirectory) return
|
||||||
|
if (re.test(entry.name)) out.push(entry.fullPath)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function cmdRemove(args) {
|
||||||
|
const query = args[0]
|
||||||
|
if (query === undefined) {
|
||||||
|
printerrln("Usage: hopper remove <package>")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = findInstalledManifest(query)
|
||||||
|
if (m === undefined) {
|
||||||
|
printerrln(`Package not installed: ${query}`)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if (m._origin === "system") {
|
||||||
|
printerrln(`Cannot remove ${query}: it is a system package.`)
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = m.ProperName || m.HopperPackageName || query
|
||||||
|
const ver = m.HopperPackageVersion || "?"
|
||||||
|
println(`Preparing removal of ${name} (${m.HopperPackageName} ${ver}) ...`)
|
||||||
|
|
||||||
|
const paths = splitList(m.SystemPackagePath || "")
|
||||||
|
println("")
|
||||||
|
println("The following files will be deleted:")
|
||||||
|
if (paths.length === 0) {
|
||||||
|
println(" (manifest declares no files)")
|
||||||
|
}
|
||||||
|
paths.forEach(p => {
|
||||||
|
const expanded = expandSystemPath(p)
|
||||||
|
if (expanded.length === 0) {
|
||||||
|
println(` (no match on disk) ${p}`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expanded.forEach(e => println(` ${e}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
println(` ${m._manifestPath}`)
|
||||||
|
|
||||||
|
println("")
|
||||||
|
if (!confirm("Proceed with removal?", false)) {
|
||||||
|
println("Aborted.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = deleteInstalledFiles(m)
|
||||||
|
removed.forEach(p => println(` removed ${p}`))
|
||||||
|
if (removed.length === 0) println(" (nothing was removed)")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Dispatch
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
println("Hopper - Package manager for TVDOS")
|
||||||
|
println("")
|
||||||
|
println("Usage:")
|
||||||
|
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||||
|
println(" hopper {install,in} <package> [-v <version>]")
|
||||||
|
println(" hopper {remove,rm} <package>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const _hopperArgs = (typeof exec_args !== "undefined" && exec_args) ? exec_args.slice(1) : []
|
||||||
|
const _hopperCmd = _hopperArgs[0]
|
||||||
|
const _hopperRest = _hopperArgs.slice(1)
|
||||||
|
|
||||||
|
switch (_hopperCmd) {
|
||||||
|
case "search":
|
||||||
|
case "se":
|
||||||
|
return cmdSearch(_hopperRest)
|
||||||
|
case "install":
|
||||||
|
case "in":
|
||||||
|
return cmdInstall(_hopperRest)
|
||||||
|
case "remove":
|
||||||
|
case "rm":
|
||||||
|
return cmdRemove(_hopperRest)
|
||||||
|
case undefined:
|
||||||
|
printUsage()
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
printerrln(`Unknown command: ${_hopperCmd}`)
|
||||||
|
printUsage()
|
||||||
|
return 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" }
|
||||||
|
}
|
||||||
@@ -1,209 +1,122 @@
|
|||||||
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}`)
|
|
||||||
|
|
||||||
|
|
||||||
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 = `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.resetParams(0)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(0)
|
||||||
audio.setPcmMode(0)
|
audio.setPcmMode(0)
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
audio.setPcmQueueCapacityIndex(0, 2)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(0, 255)
|
||||||
audio.play(0)
|
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()
|
||||||
|
|
||||||
|
// After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin
|
||||||
|
// (MMIO). Bounce them through RAM so single-byte peek in the
|
||||||
|
// 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(0) >= QUEUE_MAX) {
|
if (audio.getPosition(0) >= QUEUE_MAX) {
|
||||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
||||||
printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`)
|
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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,196 +1,81 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
function secToReadable(n) {
|
|
||||||
let mins = ''+((n/60)|0)
|
|
||||||
let secs = ''+(n % 60)
|
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
seqread.prepare(filePath)
|
||||||
|
|
||||||
|
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||||
audio.resetParams(0)
|
audio.resetParams(0)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(0)
|
||||||
audio.setPcmMode(0)
|
audio.setPcmMode(0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(0, 255)
|
||||||
|
|
||||||
let readLength = 1
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
function printPlayBar() {
|
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||||
if (interactive) {
|
tag: "PCM"
|
||||||
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 stopPlay = false
|
||||||
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(0)
|
||||||
|
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(0, readPtr, readLength, 0)
|
||||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
audio.setSampleUploadLength(0, readLength)
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
audio.startSampleUpload(0)
|
||||||
|
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
if (repeat > 1) sys.sleep(10)
|
||||||
if (readLength <= 0) {
|
|
||||||
printdbg(`readLength = ${readLength}`)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
audio.play(0)
|
||||||
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,68 @@ 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) {
|
|
||||||
let mins = ''+((n/60)|0)
|
|
||||||
let secs = ''+(n % 60)
|
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
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.resetParams(0)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(0)
|
||||||
audio.setPcmMode(0)
|
audio.setPcmMode(0)
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
audio.setPcmQueueCapacityIndex(0, 2)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(0, 255)
|
||||||
audio.play(0)
|
audio.play(0)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
|
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||||
|
tag: "TAD"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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 +156,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()
|
||||||
|
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
|
||||||
// After decoding, the decoded PCMu8 samples are in tadDecodedBin
|
|
||||||
serial.println(` Decoded ${sampleCount} samples`)
|
|
||||||
|
|
||||||
// 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)
|
audio.tadUploadDecoded(0, sampleCount)
|
||||||
|
// After upload tadDecodedBin still holds the chunk until the next
|
||||||
|
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||||
|
// during the playback wait below.
|
||||||
|
|
||||||
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"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
1228
assets/disk0/tvdos/bin/playtaud.js
Normal file
1228
assets/disk0/tvdos/bin/playtaud.js
Normal file
File diff suppressed because it is too large
Load Diff
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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -307,7 +307,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
|||||||
// Execute the player with modified environment
|
// Execute the player with modified environment
|
||||||
exec_args[1] = targetPath
|
exec_args[1] = targetPath
|
||||||
if (playerFile) {
|
if (playerFile) {
|
||||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||||
if (files.open(playerPath).exists) {
|
if (files.open(playerPath).exists) {
|
||||||
eval(files.readText(playerPath))
|
eval(files.readText(playerPath))
|
||||||
} else {
|
} else {
|
||||||
@@ -334,7 +334,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute the appropriate player
|
// Execute the appropriate player
|
||||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||||
if (!files.open(playerPath).exists) {
|
if (!files.open(playerPath).exists) {
|
||||||
serial.println(`Warning: Player script not found: ${playerPath}`)
|
serial.println(`Warning: Player script not found: ${playerPath}`)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,329 +1,189 @@
|
|||||||
// 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)
|
||||||
|
|
||||||
|
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 && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
|
if (audio.getPosition(0) <= 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(0, decodePtr, decodedSampleLength, 0)
|
||||||
|
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||||
|
audio.startSampleUpload(0)
|
||||||
|
|
||||||
|
sys.spin()
|
||||||
|
}
|
||||||
|
audio.play(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -67,13 +70,13 @@ a s d f g h j k
|
|||||||
let helpCommon = `<c>COMMON CONTROLS</c>
|
let helpCommon = `<c>COMMON CONTROLS</c>
|
||||||
<c>\u00B7${'\u00B8'.repeat(15)}\u00B9</c>
|
<c>\u00B7${'\u00B8'.repeat(15)}\u00B9</c>
|
||||||
&bul;<b>!</b> : <O>show this help message</O>
|
&bul;<b>!</b> : <O>show this help message</O>
|
||||||
&bul;<b>Y</b> : <O>play the entire song from the current cue</O>
|
&bul;<b>Y</b> : <O>plays the entire song from the current cue</O>
|
||||||
&bul;<b>U</b> : <O>play the current cue then stop</O>
|
&bul;<b>U</b> : <O>plays the current cue then stop</O>
|
||||||
&bul;<b>I</b> : <O>play the current row</O>
|
&bul;<b>I</b> : <O>plays the current row</O>
|
||||||
&bul;<b>O</b> : <O>stop the playback</O>
|
&bul;<b>O</b> : <O>stops the playback</O>
|
||||||
&bul;<b>tab</b> : <O>switch forward a tab</O>
|
&bul;<b>tab</b> : <O>switchs forward a tab</O>
|
||||||
&bul;<b>TAB</b> : <O>switch backward a tab</O>
|
&bul;<b>TAB</b> : <O>switchs backward a tab</O>
|
||||||
&bul;<b>q</b> : <O>close µtone;</O>
|
&bul;<b>q</b> : <O>closes µtone;</O>
|
||||||
`
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -85,29 +88,29 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
|||||||
<b> VIEW MODE</b>
|
<b> VIEW MODE</b>
|
||||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||||
&bul;Note jamming : <O>plays the note</O>
|
&bul;Note jamming : <O>plays the note</O>
|
||||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by voices and rows</O>
|
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by voices and rows</O>
|
||||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>
|
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||||
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggle 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>toggle soloing of the selected voice</O>
|
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
||||||
&bul;<b>m</b> : <O>toggle 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>change 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>
|
||||||
&bul;Note jamming : <O>(note column) inserts the note</O>
|
&bul;Note jamming : <O>(note column) inserts the note</O>
|
||||||
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lower/raise a note by one octave (or period)</O>
|
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lowers/raises a note by one octave (or period)</O>
|
||||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lower/raise a note by one unit</O>
|
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lowers/raises a note by one unit</O>
|
||||||
&bul;<b>z</b> : <O>(note column) insert a key-off &keyoffsym;</O>
|
&bul;<b>z</b> : <O>(note column) inserts a key-off &keyoffsym;</O>
|
||||||
&bul;<b>x</b> : <O>(note column) insert a note-cut ¬ecutsym;</O>
|
&bul;<b>x</b> : <O>(note column) inserts a note-cut ¬ecutsym;</O>
|
||||||
&bul;<b>.</b> : <O>clear fields</O>
|
&bul;<b>.</b> : <O>clears fields</O>
|
||||||
&bul;<b>bksp</b> : <O>delete one character on the selected column</O>
|
&bul;<b>bksp</b> : <O>deletes one character on the selected column</O>
|
||||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>f</b> : <O>inserts a (hexa)decimal number</O>
|
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>f</b> : <O>inserts a (hexa)decimal number</O>
|
||||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>z</b> : <O>(fx column) inserts an effect</O>
|
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>z</b> : <O>(fx column) inserts an effect</O>
|
||||||
&bul;<b>^</b>&mdot;<b>v</b> : <O>(volume column) slide up/down</O>
|
&bul;<b>^</b>&mdot;<b>v</b> : <O>(volume column) slide up/down</O>
|
||||||
&bul;<b><</b>&mdot;<b>></b>: <O>(panning column) slide left/right</O>
|
&bul;<b><</b>&mdot;<b>></b>: <O>(panning column) slide left/right</O>
|
||||||
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
||||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by columns and rows</O>
|
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by columns and rows</O>
|
||||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</O>
|
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||||
|
|
||||||
<b> ACCIDENTALS</b>
|
<b> ACCIDENTALS</b>
|
||||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||||
@@ -116,281 +119,54 @@ Timeline has two distinct modes: view and edit mode. Two modes are toggled using
|
|||||||
|
|
||||||
<b> GLOBAL EDIT</b>
|
<b> GLOBAL EDIT</b>
|
||||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||||
&bul;<b>Q</b> : <O>retune current song into different tuning</O>
|
&bul;<b>Q</b> : <O>retunes current song into different tuning and strategy. In general, nearest-note works best for macrotonals, nearest-harmonic and nearest-delta works best for highly microtonals (31+); 17- and 19-TET takes nearest-harmonic pretty well, while 22-TET seem to only benefit from the nearest-note</O>
|
||||||
|
`
|
||||||
|
|
||||||
|
let helpProjectFlags = `<c>MIXER FLAGS</c>
|
||||||
|
<c>\u00B7${'\u00B8'.repeat(11)}\u00B9</c>
|
||||||
|
Mixer flags define how should the mixer behave.
|
||||||
|
|
||||||
|
<b> TONE MODE</b>
|
||||||
|
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||||
|
&bul;Linear pitch : <O>pitch shift effects operate on linear pitch scale. The default and recommended setting for a new project</O>
|
||||||
|
&bul;Amiga pitch : <O>pitch shift effects operate on Amiga period scale. Backwards compatible setting for MOD/S3M/XM/IT formats</O>
|
||||||
|
&bul;Linear freq : <O>pitch shift effects operate on linear frequency scale. Backwards compatible setting for MONOTONE format</O>
|
||||||
|
|
||||||
|
<b> INTERPOLATION</b>
|
||||||
|
<b>\u00B7${'\u00B8'.repeat(13)}\u00B9</b>
|
||||||
|
&bul;Default : <O>three-tap fast sinc interpolation. The default and recommended setting for a new project</O>
|
||||||
|
&bul;None : <O>zeroth-order hold</O>
|
||||||
|
&bul;A500 : <O>emulates what Paula chip of Amiga 500 does. <b>S 0x00</b> effects only work with this and Amiga 1200 mode</O>
|
||||||
|
&bul;A1200 : <O>emulates what Paula chip of Amiga 1200 does</O>
|
||||||
|
&bul;SNES : <O>four-tap gaussian interpolation used by SNES</O>
|
||||||
|
&bul;DPCM : <O>simulates Differential Pulse Code Modulation used by NES</O>
|
||||||
`
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// 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
|
||||||
[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE),
|
/* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE),
|
||||||
[helpCommon, helpNotation].join(HRULE), // placeholder
|
/* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join(HRULE), // placeholder
|
/* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join(HRULE), // placeholder
|
/* Samples */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join(HRULE), // placeholder
|
/* Instruments */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join(HRULE), // placeholder
|
/* Project */[helpProjectFlags, helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join(HRULE), // placeholder
|
/* File */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
]
|
]
|
||||||
|
|
||||||
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/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*
|
||||||
1129
assets/disk0/tvdos/include/fs.mjs
Normal file
1129
assets/disk0/tvdos/include/fs.mjs
Normal file
File diff suppressed because it is too large
Load Diff
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 }
|
||||||
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)
|
||||||
|
|||||||
621
assets/disk0/tvdos/include/tbas.mjs
Normal file
621
assets/disk0/tvdos/include/tbas.mjs
Normal file
@@ -0,0 +1,621 @@
|
|||||||
|
// Terran BASIC runtime helper for compiled programs
|
||||||
|
// Compiled-by: assets/disk0/tbas/compile.js
|
||||||
|
// Loaded at runtime by `let bS = require("tbas")`
|
||||||
|
//
|
||||||
|
// Contract with compiler:
|
||||||
|
// - The compiler has lowered every BASIC expression to a JS expression
|
||||||
|
// that produces the *raw* JS value (number, string, array, ForGen,
|
||||||
|
// function, BasicMemoMonad, …). Builtins take such raw values, NOT
|
||||||
|
// SyntaxTreeReturnObj wrappers.
|
||||||
|
// - Variable reads: bS.__state.vars.X (key always uppercased)
|
||||||
|
// - Variable writes: bS.__state.vars.X = v
|
||||||
|
// - Control flow (GOTO/GOSUB/RETURN/FOR/NEXT/IF/ON/END/READ/RESTORE/LABEL/DATA)
|
||||||
|
// is *not* exposed here — the compiler emits inline JS that updates the
|
||||||
|
// `pc` and `gosubStack` directly.
|
||||||
|
//
|
||||||
|
// Naming: BASIC builtins exposed under their UPPERCASE name (bS.PRINT,
|
||||||
|
// bS.PLOT, bS.SIN). Compiler-only helpers prefixed with __.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types & helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isNumable(s) {
|
||||||
|
if (Array.isArray(s)) return false
|
||||||
|
if (s === undefined) return false
|
||||||
|
if (typeof s.trim == "function" && s.trim().length == 0) return false
|
||||||
|
return !isNaN(s)
|
||||||
|
}
|
||||||
|
const tonum = (t) => t * 1.0
|
||||||
|
|
||||||
|
function ForGen(s, e, t) {
|
||||||
|
this.start = s
|
||||||
|
this.end = e
|
||||||
|
this.step = t || 1
|
||||||
|
this.current = this.start
|
||||||
|
this.stepsgn = (this.step > 0) ? 1 : -1
|
||||||
|
}
|
||||||
|
const isGenerator = (o) =>
|
||||||
|
o !== undefined && o !== null &&
|
||||||
|
o.start !== undefined && o.end !== undefined &&
|
||||||
|
o.step !== undefined && o.stepsgn !== undefined
|
||||||
|
const genToArray = (gen) => {
|
||||||
|
let a = []
|
||||||
|
let cur = gen.start
|
||||||
|
while (cur * gen.stepsgn + gen.step * gen.stepsgn <= (gen.end + gen.step) * gen.stepsgn) {
|
||||||
|
a.push(cur)
|
||||||
|
cur += gen.step
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
const genHasNext = (o) => o.current * o.stepsgn + o.step * o.stepsgn <= (o.end + o.step) * o.stepsgn
|
||||||
|
const genGetNext = (gen, mutated) => {
|
||||||
|
if (mutated !== undefined) gen.current = tonum(mutated)
|
||||||
|
gen.current += gen.step
|
||||||
|
return genHasNext(gen) ? gen.current : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function BasicMemoMonad(m) { this.mType = "value"; this.mVal = m }
|
||||||
|
function BasicListMonad(m) { this.mType = "list"; this.mVal = [m] }
|
||||||
|
function BasicFunSeq(f) { this.mType = "funseq"; this.mVal = f }
|
||||||
|
const isMonad = (o) => o !== undefined && o !== null && o.mType !== undefined
|
||||||
|
|
||||||
|
function arrayToString(a) {
|
||||||
|
let acc = ""
|
||||||
|
for (let k = 0; k < a.length; k++) {
|
||||||
|
if (k > 0) acc += ","
|
||||||
|
acc += (Array.isArray(a[k])) ? arrayToString(a[k]) : a[k]
|
||||||
|
}
|
||||||
|
return "{" + acc + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _initialConsts = () => ({
|
||||||
|
NIL: [],
|
||||||
|
PI: Math.PI,
|
||||||
|
TAU: Math.PI * 2,
|
||||||
|
EULER: Math.E,
|
||||||
|
UNDEFINED: undefined,
|
||||||
|
TRUE: true,
|
||||||
|
FALSE: false,
|
||||||
|
// ID is identity-function: emitted as JS arrow when needed
|
||||||
|
ID: (x) => x,
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
vars: _initialConsts(),
|
||||||
|
indexBase: 0,
|
||||||
|
dataConsts: [],
|
||||||
|
dataCursor: 0,
|
||||||
|
gotoLabels: {}, // labelName -> [lnum, stmt]
|
||||||
|
lineList: [], // sorted ascending list of existing source lines (for GOTO snap)
|
||||||
|
rnd: Math.random(),
|
||||||
|
forVar: {}, // varname -> generator|array (the iterable we still owe to FOR/FOREACH)
|
||||||
|
forLnums: {}, // varname -> [lnum, stmt of the FOR/FOREACH header]
|
||||||
|
forStack: [],
|
||||||
|
trace: false,
|
||||||
|
debug: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function __reset() {
|
||||||
|
state.vars = _initialConsts()
|
||||||
|
state.indexBase = 0
|
||||||
|
state.dataConsts = []
|
||||||
|
state.dataCursor = 0
|
||||||
|
state.gotoLabels = {}
|
||||||
|
state.lineList = []
|
||||||
|
state.rnd = Math.random()
|
||||||
|
state.forVar = {}
|
||||||
|
state.forLnums = {}
|
||||||
|
state.forStack = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function __data(values) { state.dataConsts = values.slice() }
|
||||||
|
function __labels(map) { state.gotoLabels = Object.assign({}, map) }
|
||||||
|
function __setLines(arr) { state.lineList = arr.slice() }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compiler-emitted operator helpers (need behaviour not directly expressible
|
||||||
|
// in raw JS without losing semantics)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function __add(lh, rh) {
|
||||||
|
return (!isNaN(lh) && !isNaN(rh)) ? (tonum(lh) + tonum(rh)) : (lh + rh)
|
||||||
|
}
|
||||||
|
function __div(lh, rh) { if (rh == 0) throw Error("Division by zero"); return lh / rh }
|
||||||
|
function __intdiv(lh, rh) { if (rh == 0) throw Error("Division by zero"); return (lh / rh) | 0 }
|
||||||
|
function __mod(lh, rh) { if (rh == 0) throw Error("Division by zero"); return lh % rh }
|
||||||
|
function __pow(lh, rh) {
|
||||||
|
let r = Math.pow(lh, rh)
|
||||||
|
if (isNaN(r)) throw Error("Illegal function call")
|
||||||
|
if (!isFinite(r)) throw Error("Division by zero")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
function __test(v) { return !!v } // matches builtin TEST: string "false" is truthy
|
||||||
|
|
||||||
|
function __dim(dims) {
|
||||||
|
let revdims = dims.slice().reverse()
|
||||||
|
let inner = new Array(revdims[0]).fill(0)
|
||||||
|
for (let k = 1; k < revdims.length; k++) {
|
||||||
|
const sz = revdims[k]
|
||||||
|
const prev = inner
|
||||||
|
inner = new Array(sz).fill(0).map(_ => JSON.parse(JSON.stringify(prev)))
|
||||||
|
}
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
|
||||||
|
function __subscriptError(idx, dim) {
|
||||||
|
return Error("Subscript out of range (index " + idx + ", dim " + dim + ")")
|
||||||
|
}
|
||||||
|
function __arrGet(arr, idx) {
|
||||||
|
let v = arr
|
||||||
|
for (let i = 0; i < idx.length; i++) {
|
||||||
|
if (v === undefined || v === null) throw __subscriptError(idx[i], i)
|
||||||
|
v = v[idx[i] - state.indexBase]
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
function __arrSet(arr, idx, value) {
|
||||||
|
let v = arr
|
||||||
|
for (let i = 0; i < idx.length - 1; i++) {
|
||||||
|
if (v === undefined || v === null) throw __subscriptError(idx[i], i)
|
||||||
|
v = v[idx[i] - state.indexBase]
|
||||||
|
}
|
||||||
|
if (v === undefined || v === null) throw __subscriptError(idx[idx.length - 1], idx.length - 1)
|
||||||
|
v[idx[idx.length - 1] - state.indexBase] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// FOR / FOREACH setup. Lowered as:
|
||||||
|
// __forSetup(varname, iterable, bodyLnum, bodyStmt)
|
||||||
|
// where iterable is a ForGen (FOR…TO…STEP) OR an Array (FOREACH IN…), and
|
||||||
|
// (bodyLnum, bodyStmt) is the PC of the statement immediately following the
|
||||||
|
// FOR header — i.e. where NEXT should jump back to. The compiler supplies
|
||||||
|
// this directly so the state machine doesn't rely on fall-through.
|
||||||
|
function __forSetup(varname, iterable, bodyLnum, bodyStmt) {
|
||||||
|
const v = varname.toUpperCase()
|
||||||
|
if (isGenerator(iterable)) {
|
||||||
|
state.vars[v] = iterable.start
|
||||||
|
state.forVar[v] = iterable
|
||||||
|
} else if (Array.isArray(iterable)) {
|
||||||
|
state.vars[v] = iterable[0]
|
||||||
|
state.forVar[v] = iterable.slice(1) // remainder
|
||||||
|
} else {
|
||||||
|
throw Error("FOR: not a generator or array")
|
||||||
|
}
|
||||||
|
state.forLnums[v] = [bodyLnum, bodyStmt]
|
||||||
|
state.forStack.push(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEXT [varname]. Without varname, pops the most recent.
|
||||||
|
// Returns [lnum, stmt] to jump back to (just-after the FOR header) if more
|
||||||
|
// iterations remain, or undefined if the loop is exhausted (caller falls
|
||||||
|
// through).
|
||||||
|
function __forNext(varname) {
|
||||||
|
let v
|
||||||
|
if (varname === undefined || varname === null) {
|
||||||
|
v = state.forStack.pop()
|
||||||
|
} else {
|
||||||
|
v = varname.toUpperCase()
|
||||||
|
// remove this varname from the stack
|
||||||
|
const idx = state.forStack.lastIndexOf(v)
|
||||||
|
if (idx >= 0) state.forStack.splice(idx, 1)
|
||||||
|
}
|
||||||
|
if (v === undefined) throw Error("NEXT without FOR")
|
||||||
|
|
||||||
|
const it = state.forVar[v]
|
||||||
|
let nextVal
|
||||||
|
if (isGenerator(it)) {
|
||||||
|
nextVal = genGetNext(it, state.vars[v])
|
||||||
|
} else {
|
||||||
|
nextVal = it.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextVal !== undefined) {
|
||||||
|
state.vars[v] = nextVal
|
||||||
|
state.forStack.push(v)
|
||||||
|
return state.forLnums[v] // already the PC of the loop body
|
||||||
|
} else {
|
||||||
|
if (isGenerator(it)) state.vars[v] = it.current
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function __readData() {
|
||||||
|
const r = state.dataConsts[state.dataCursor++]
|
||||||
|
if (r === undefined) throw Error("Out of DATA")
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a GOTO/GOSUB target — accepts numeric line, label string, or
|
||||||
|
// already-evaluated expression. For numeric targets that don't match an
|
||||||
|
// existing source line, snap upward to the next one (matches the
|
||||||
|
// interpreter's behaviour, where the main loop simply increments lnum until
|
||||||
|
// it finds a populated cmdbuf entry).
|
||||||
|
function __resolveTarget(t) {
|
||||||
|
if (typeof t === "string" && state.gotoLabels[t] !== undefined) {
|
||||||
|
return state.gotoLabels[t]
|
||||||
|
}
|
||||||
|
let target
|
||||||
|
if (typeof t === "number") target = t
|
||||||
|
else if (isNumable(t)) target = tonum(t)
|
||||||
|
else throw Error("Invalid jump target: " + t)
|
||||||
|
|
||||||
|
const lines = state.lineList
|
||||||
|
if (lines.length === 0) return [target, 0]
|
||||||
|
// linear scan is fine for the line counts BASIC programs reach
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i] >= target) return [lines[i], 0]
|
||||||
|
}
|
||||||
|
return [Infinity, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke a usrdefun (compiled to a JS function), or — when the parser
|
||||||
|
// couldn't tell array-indexing apart from function-call (e.g. `A(5)` for an
|
||||||
|
// unknown identifier) — index into an array. Used by MAP/FOLD/FILTER, monad
|
||||||
|
// operators, and the compiler's default `function` lowering.
|
||||||
|
function __runFn(fn, args) {
|
||||||
|
if (typeof fn === "function") return fn.apply(null, args)
|
||||||
|
if (Array.isArray(fn)) return __arrGet(fn, args)
|
||||||
|
if (isMonad(fn) && fn.mType === "funseq") {
|
||||||
|
let arg = args[0]
|
||||||
|
for (let i = 0; i < fn.mVal.length; i++) arg = __runFn(fn.mVal[i], [arg])
|
||||||
|
return arg
|
||||||
|
}
|
||||||
|
throw Error("Not a callable: " + fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Operator builtins (where JS doesn't already do the right thing)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _AND(a, b) { if (typeof a !== "boolean" || typeof b !== "boolean") throw Error("Type mismatch"); return a && b }
|
||||||
|
function _OR (a, b) { if (typeof a !== "boolean" || typeof b !== "boolean") throw Error("Type mismatch"); return a || b }
|
||||||
|
function _NOT(a) { return !a }
|
||||||
|
|
||||||
|
function _CONS(lh, rh) { // !
|
||||||
|
if (Array.isArray(rh)) return [lh].concat(rh)
|
||||||
|
if (rh && rh.mType === "list") { rh.mVal = [lh].concat(rh.mVal); return rh }
|
||||||
|
throw Error("Type mismatch")
|
||||||
|
}
|
||||||
|
function _PUSH(lh, rh) { // ~
|
||||||
|
if (Array.isArray(lh)) return lh.concat([rh])
|
||||||
|
if (lh && lh.mType === "list") { lh.mVal = [lh.mVal].concat([rh]); return lh }
|
||||||
|
throw Error("Type mismatch")
|
||||||
|
}
|
||||||
|
function _CONCAT(lh, rh) { // #
|
||||||
|
if (Array.isArray(lh) && Array.isArray(rh)) return lh.concat(rh)
|
||||||
|
if (lh && rh && lh.mType === "list" && rh.mType === "list") return new BasicListMonad(lh.mVal.concat(rh.mVal))
|
||||||
|
throw Error("Type mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
function _TO(from, to) { return new ForGen(from, to, 1) }
|
||||||
|
function _STEP(gen, step) {
|
||||||
|
if (!isGenerator(gen)) throw Error("Type mismatch (STEP)")
|
||||||
|
return new ForGen(gen.start, gen.end, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// I/O builtins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// PRINT(values, seps) — values: array of resolved JS values; seps: array of
|
||||||
|
// length values.length-1 with "," | ";" between each consecutive pair.
|
||||||
|
// Trailing semicolon? The compiler signals "no newline" by passing a final
|
||||||
|
// `null` element in `values` and "noNewline" flag — we use the convention
|
||||||
|
// that the LAST entry of `values` being a marker `__noNewline` suppresses
|
||||||
|
// the newline (matches basic.js trailing-null behaviour).
|
||||||
|
const __PRINT_NONL = Symbol("PRINT_NONL")
|
||||||
|
function PRINT(values, seps) {
|
||||||
|
seps = seps || []
|
||||||
|
if (values.length === 0) {
|
||||||
|
println()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let suppressNewline = false
|
||||||
|
let realLen = values.length
|
||||||
|
if (values[realLen - 1] === __PRINT_NONL) {
|
||||||
|
suppressNewline = true
|
||||||
|
realLen -= 1
|
||||||
|
}
|
||||||
|
for (let i = 0; i < realLen; i++) {
|
||||||
|
if (i >= 1 && seps[i - 1] === ",") print("\t")
|
||||||
|
const v = values[i]
|
||||||
|
let s
|
||||||
|
if (Array.isArray(v)) s = arrayToString(v)
|
||||||
|
else if (v === undefined || v === "") s = ""
|
||||||
|
else if (v.toString !== undefined) s = v.toString()
|
||||||
|
else s = v
|
||||||
|
print(s)
|
||||||
|
}
|
||||||
|
if (!suppressNewline) println()
|
||||||
|
}
|
||||||
|
function EMIT(values, seps) {
|
||||||
|
seps = seps || []
|
||||||
|
if (values.length === 0) { println(); return }
|
||||||
|
let suppressNewline = false
|
||||||
|
let realLen = values.length
|
||||||
|
if (values[realLen - 1] === __PRINT_NONL) { suppressNewline = true; realLen -= 1 }
|
||||||
|
for (let i = 0; i < realLen; i++) {
|
||||||
|
if (i >= 1 && seps[i - 1] === ",") print("\t")
|
||||||
|
const v = values[i]
|
||||||
|
if (v === undefined) print("")
|
||||||
|
else if (isNumable(v)) {
|
||||||
|
const c = con.getyx()
|
||||||
|
con.addch(tonum(v))
|
||||||
|
con.move(c[0], c[1] + 1)
|
||||||
|
} else if (v.toString !== undefined) print(v.toString())
|
||||||
|
else print(v)
|
||||||
|
}
|
||||||
|
if (!suppressNewline) println()
|
||||||
|
}
|
||||||
|
|
||||||
|
function INPUT(promptOrVarname) {
|
||||||
|
print("? ")
|
||||||
|
let r = sys.read().trim()
|
||||||
|
if (!isNaN(r)) r = tonum(r)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
function CIN() { return sys.read().trim() }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Numeric builtins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _num = (f) => (x) => { if (!isNumable(x)) throw Error("Type mismatch"); return f(tonum(x)) }
|
||||||
|
const _num2 = (f) => (a, b) => {
|
||||||
|
if (!isNumable(a) || !isNumable(b)) throw Error("Type mismatch")
|
||||||
|
return f(tonum(a), tonum(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABS = _num(Math.abs)
|
||||||
|
const SGN = _num(x => x > 0 ? 1 : x < 0 ? -1 : 0)
|
||||||
|
const INT = _num(Math.floor)
|
||||||
|
const FLOOR = _num(Math.floor)
|
||||||
|
const CEIL = _num(Math.ceil)
|
||||||
|
const FIX = _num(x => x | 0)
|
||||||
|
const ROUND = _num(Math.round)
|
||||||
|
const SQR = _num(Math.sqrt)
|
||||||
|
const CBR = _num(Math.cbrt)
|
||||||
|
const SIN = _num(Math.sin)
|
||||||
|
const COS = _num(Math.cos)
|
||||||
|
const TAN = _num(Math.tan)
|
||||||
|
const ASN = _num(Math.asin)
|
||||||
|
const ACO = _num(Math.acos)
|
||||||
|
const ATN = _num(Math.atan)
|
||||||
|
const SINH = _num(Math.sinh)
|
||||||
|
const COSH = _num(Math.cosh)
|
||||||
|
const TANH = _num(Math.tanh)
|
||||||
|
const EXP = _num(Math.exp)
|
||||||
|
const LOG = _num(Math.log)
|
||||||
|
const MIN = _num2((a,b) => a > b ? b : a)
|
||||||
|
const MAX = _num2((a,b) => a < b ? b : a)
|
||||||
|
|
||||||
|
function RND(x) {
|
||||||
|
// matches basic.js:1199 — only re-roll when arg !== 0
|
||||||
|
if (!(x === 0)) state.rnd = Math.random()
|
||||||
|
return state.rnd
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// String builtins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function SPC(n) { return " ".repeat(n) }
|
||||||
|
function LEFT(s, n) { return String(s).substring(0, n) }
|
||||||
|
function RIGHT(s, n) { return String(s).substring(String(s).length - n) }
|
||||||
|
function MID(s, start, len) { return String(s).substring(start - state.indexBase, start - state.indexBase + len) }
|
||||||
|
function CHR(n) { return String.fromCharCode(n) }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// List builtins
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LEN(x) { if (x === undefined || x.length === undefined) throw Error("Type mismatch"); return x.length }
|
||||||
|
function HEAD(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x[0] }
|
||||||
|
function TAIL(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x.slice(1) }
|
||||||
|
function INIT(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x.slice(0, x.length - 1) }
|
||||||
|
function LAST(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x[x.length - 1] }
|
||||||
|
|
||||||
|
function MAP(fn, functor) {
|
||||||
|
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("MAP: not a function")
|
||||||
|
if (isGenerator(functor)) functor = genToArray(functor)
|
||||||
|
if (!Array.isArray(functor)) throw Error("MAP: not iterable")
|
||||||
|
return functor.map(it => __runFn(fn, [it]))
|
||||||
|
}
|
||||||
|
function FOLD(fn, init, functor) {
|
||||||
|
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("FOLD: not a function")
|
||||||
|
if (isGenerator(functor)) functor = genToArray(functor)
|
||||||
|
if (!Array.isArray(functor)) throw Error("FOLD: not iterable")
|
||||||
|
let akku = init
|
||||||
|
for (let i = 0; i < functor.length; i++) akku = __runFn(fn, [akku, functor[i]])
|
||||||
|
return akku
|
||||||
|
}
|
||||||
|
function FILTER(fn, functor) {
|
||||||
|
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("FILTER: not a function")
|
||||||
|
if (isGenerator(functor)) functor = genToArray(functor)
|
||||||
|
if (!Array.isArray(functor)) throw Error("FILTER: not iterable")
|
||||||
|
return functor.filter(it => __runFn(fn, [it]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array literal constructor — emitted by the compiler for `[a,b,c]` syntax
|
||||||
|
function ARRAY() { return Array.prototype.slice.call(arguments) }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Graphics / system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function CLS() { con.clear() }
|
||||||
|
function CLPX() { graphics.clearPixels(255) }
|
||||||
|
function PLOT(x, y, c) { graphics.plotPixel(x, y, c) }
|
||||||
|
function GOTOYX(y, x) { con.move(y + (1 - state.indexBase), x + (1 - state.indexBase)) }
|
||||||
|
function TEXTFORE(c) { print(String.fromCharCode(27, 91) + "38;5;" + (c | 0) + "m") }
|
||||||
|
function TEXTBACK(c) { print(String.fromCharCode(27, 91) + "48;5;" + (c | 0) + "m") }
|
||||||
|
function POKE(addr, v) { sys.poke(addr, v) }
|
||||||
|
function PEEK(addr) { return sys.peek(addr) }
|
||||||
|
function GETKEYSDOWN() {
|
||||||
|
const keys = []
|
||||||
|
sys.poke(-40, 255)
|
||||||
|
for (let k = -41; k >= -48; k--) keys.push(sys.peek(k))
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
function CPUT(devnum, msg) { com.sendMessage(devnum, msg); return com.getStatusCode(devnum) }
|
||||||
|
function CGET(devnum, ptr) {
|
||||||
|
const msg = com.pullMessage(devnum)
|
||||||
|
const len = msg.length | 0
|
||||||
|
for (let i = 0; i < len; i++) sys.poke(ptr + i, msg.charCodeAt(i))
|
||||||
|
return len
|
||||||
|
}
|
||||||
|
function CSTA(devnum) { return com.getStatusCode(devnum) }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Type / debug
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TYPEOF(v) {
|
||||||
|
if (v === undefined) return "null"
|
||||||
|
if (typeof v === "boolean") return "bool"
|
||||||
|
if (Array.isArray(v)) return "array"
|
||||||
|
if (isGenerator(v)) return "generator"
|
||||||
|
if (isMonad(v)) return v.mType + "-monad"
|
||||||
|
if (typeof v === "function") return "usrdefun"
|
||||||
|
if (isNumable(v)) return "num"
|
||||||
|
if (typeof v === "string") return "string"
|
||||||
|
return typeof v
|
||||||
|
}
|
||||||
|
|
||||||
|
function OPTIONBASE(n) {
|
||||||
|
if (n != 0 && n != 1) throw Error("Syntax error: OPTIONBASE")
|
||||||
|
state.indexBase = n | 0
|
||||||
|
}
|
||||||
|
function OPTIONDEBUG(n) { state.debug = (n | 0) === 1 }
|
||||||
|
function OPTIONTRACE(n) { state.trace = (n | 0) === 1 }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Monad / functional ops (best-effort port)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function MRET(v) { return new BasicMemoMonad(v) }
|
||||||
|
function MLIST(v) { return new BasicListMonad(v) }
|
||||||
|
function MJOIN(m) { if (!isMonad(m)) throw Error("Type mismatch"); return m.mVal }
|
||||||
|
|
||||||
|
function _BIND(ma, fn) { // >>=
|
||||||
|
if (!isMonad(ma)) throw Error(">>=: left is not a monad")
|
||||||
|
if (typeof fn !== "function") throw Error(">>=: right is not a function")
|
||||||
|
const mb = __runFn(fn, [ma.mVal])
|
||||||
|
if (!isMonad(mb)) throw Error(">>=: function did not return a monad")
|
||||||
|
return mb
|
||||||
|
}
|
||||||
|
function _SEQ(ma, mb) { // >>~
|
||||||
|
if (!isMonad(ma) || !isMonad(mb)) throw Error("Type mismatch")
|
||||||
|
return mb
|
||||||
|
}
|
||||||
|
function _COMPOSE(fa, fb) { // .
|
||||||
|
const ma = (typeof fa === "function") ? [fa] : fa.mVal
|
||||||
|
const mb = (typeof fb === "function") ? [fb] : fb.mVal
|
||||||
|
return new BasicFunSeq(mb.concat(ma))
|
||||||
|
}
|
||||||
|
function _APPLY(fn, value) { // $
|
||||||
|
return __runFn(fn, [value])
|
||||||
|
}
|
||||||
|
function _PIPE(value, fn) { // &
|
||||||
|
return _APPLY(fn, value)
|
||||||
|
}
|
||||||
|
function _CURRY(fn, value) { // ~<
|
||||||
|
if (typeof fn !== "function") throw Error("~<: left is not a function")
|
||||||
|
return function() {
|
||||||
|
const rest = Array.prototype.slice.call(arguments)
|
||||||
|
return fn.apply(null, [value].concat(rest))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _SEQAPP(fns, functor) { // <*>
|
||||||
|
if (!Array.isArray(fns)) throw Error("<*>: first arg must be an array of functions")
|
||||||
|
if (isGenerator(functor)) functor = genToArray(functor)
|
||||||
|
if (!Array.isArray(functor)) throw Error("<*>: not iterable")
|
||||||
|
let ret = []
|
||||||
|
for (let i = 0; i < fns.length; i++) ret = ret.concat(functor.map(it => __runFn(fns[i], [it])))
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
function _SEQCURRYMAP(fns, functor) { // <~>
|
||||||
|
if (typeof fns === "function") fns = [fns]
|
||||||
|
if (!Array.isArray(fns)) throw Error("<~>: first arg must be a function or array of functions")
|
||||||
|
if (isGenerator(functor)) functor = genToArray(functor)
|
||||||
|
if (!Array.isArray(functor)) throw Error("<~>: not iterable")
|
||||||
|
let ret = []
|
||||||
|
for (let i = 0; i < fns.length; i++) ret = ret.concat(functor.map(it => _CURRY(fns[i], it)))
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Exports
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
// state & introspection
|
||||||
|
__state: state, __reset, __data, __labels, __setLines,
|
||||||
|
__PRINT_NONL,
|
||||||
|
|
||||||
|
// operator helpers
|
||||||
|
__add, __div, __intdiv, __mod, __pow, __test,
|
||||||
|
__dim, __arrGet, __arrSet,
|
||||||
|
__forSetup, __forNext, __readData, __resolveTarget,
|
||||||
|
__runFn,
|
||||||
|
|
||||||
|
// type ctors
|
||||||
|
__ForGen: ForGen, __isGenerator: isGenerator, __genToArray: genToArray,
|
||||||
|
__isMonad: isMonad,
|
||||||
|
|
||||||
|
// operators
|
||||||
|
AND: _AND, OR: _OR, NOT: _NOT,
|
||||||
|
UNARYLOGICNOT: _NOT,
|
||||||
|
UNARYBNOT: (a) => ~a,
|
||||||
|
UNARYMINUS: (a) => -a,
|
||||||
|
UNARYPLUS: (a) => +a,
|
||||||
|
BAND: (a,b)=>a&b, BOR: (a,b)=>a|b, BXOR: (a,b)=>a^b,
|
||||||
|
"<<": (a,b)=>a<<b, ">>": (a,b)=>a>>>b,
|
||||||
|
"!": _CONS, "~": _PUSH, "#": _CONCAT,
|
||||||
|
TO: _TO, STEP: _STEP,
|
||||||
|
|
||||||
|
// i/o
|
||||||
|
PRINT, EMIT, INPUT, CIN,
|
||||||
|
|
||||||
|
// numeric
|
||||||
|
ABS, SGN, INT, FLOOR, CEIL, FIX, ROUND, SQR, CBR,
|
||||||
|
SIN, COS, TAN, ASN, ACO, ATN, SINH, COSH, TANH,
|
||||||
|
EXP, LOG, MIN, MAX, RND,
|
||||||
|
|
||||||
|
// strings
|
||||||
|
SPC, LEFT, RIGHT, MID, CHR,
|
||||||
|
|
||||||
|
// lists
|
||||||
|
LEN, HEAD, TAIL, INIT, LAST, MAP, FOLD, FILTER,
|
||||||
|
ARRAY,
|
||||||
|
|
||||||
|
// graphics / system
|
||||||
|
CLS, CLPX, PLOT, GOTOYX, TEXTFORE, TEXTBACK,
|
||||||
|
POKE, PEEK, GETKEYSDOWN, CPUT, CGET, CSTA,
|
||||||
|
|
||||||
|
// type / option
|
||||||
|
TYPEOF, OPTIONBASE, OPTIONDEBUG, OPTIONTRACE,
|
||||||
|
|
||||||
|
// monads / functional
|
||||||
|
MRET, MLIST, MJOIN,
|
||||||
|
">>=": _BIND, ">>~": _SEQ,
|
||||||
|
".": _COMPOSE, "$": _APPLY, "&": _PIPE, "~<": _CURRY,
|
||||||
|
"<*>": _SEQAPP, "<$>": MAP, "<~>": _SEQCURRYMAP,
|
||||||
|
|
||||||
|
// misc
|
||||||
|
DO: function() { return arguments[arguments.length - 1] },
|
||||||
|
CLEAR: function() { state.vars = _initialConsts() },
|
||||||
|
END: function() { /* compiler emits pc=[Infinity,0] */ },
|
||||||
|
LABEL: function() { /* harvested at compile time */ },
|
||||||
|
DATA: function() { /* harvested at compile time */ },
|
||||||
|
// DIM as an expression (e.g. `WS = DIM(H, V)`): allocate and return a
|
||||||
|
// freshly zero-filled N-D array. The statement form `DIM A(H, V)` is
|
||||||
|
// compiled inline and never reaches this entry.
|
||||||
|
DIM: function() { return __dim(Array.prototype.slice.call(arguments)) },
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
@@ -65,12 +65,12 @@ class WindowObject {
|
|||||||
}
|
}
|
||||||
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 - 2)
|
||||||
print(`\x84${charset[4]}u`)
|
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`)
|
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -180,4 +180,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.
@@ -1,5 +1,7 @@
|
|||||||
\begin{itemlist}
|
\begin{itemlist}
|
||||||
\item Song, Minjae. 2021. ``Terran BASIC Reference Manual for Language Version 1.2, Third Edition''.
|
\item Song, Minjae. 2021. ``Terran BASIC Reference Manual for Language Version 1.2, Third Edition''.
|
||||||
|
\item Bradner, S. 1997. ``Key words for use in RFCs to Indicate Requirement Levels.'' RFC 2119. \url{https://www.rfc-editor.org/rfc/rfc2119}.
|
||||||
|
\item Leiba, B. 2017. ``Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words.'' RFC 8174. \url{https://www.rfc-editor.org/rfc/rfc8174}.
|
||||||
\item Wikipedia. ``List of DOS commands.'' Updated 2022-08-29 15:00. \url{https://en.wikipedia.org/wiki/List_of_DOS_commands}.
|
\item Wikipedia. ``List of DOS commands.'' Updated 2022-08-29 15:00. \url{https://en.wikipedia.org/wiki/List_of_DOS_commands}.
|
||||||
\item Wikipedia. ``Pipeline (software).'' Updated 2022-07-17 06:21. \url{https://en.wikipedia.org/wiki/Pipeline_(software)}.
|
\item Wikipedia. ``Pipeline (software).'' Updated 2022-07-17 06:21. \url{https://en.wikipedia.org/wiki/Pipeline_(software)}.
|
||||||
\end{itemlist}
|
\end{itemlist}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
\section{Specs}
|
\section{Specs}
|
||||||
|
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1 16 MB memory space with maximum 8 MB of scratchpad memory
|
\1 16 MB memory space with maximum 8 MB of core memory
|
||||||
\1 7 peripheral card slots, each can map 1 MB of memory to the memory space
|
\1 7 peripheral card slots, each can map 1 MB of memory to the memory space
|
||||||
\1 Standard graphics adapter on slot 1, with 256 simultaneous colours, 560\times448 pixels framebuffer and 80-column 32-row text buffer
|
\1 Standard graphics adapter on slot 1, with 256 simultaneous colours, 560\times448 pixels framebuffer and 80-column 32-row text buffer
|
||||||
\1 Built-in mouse input support
|
\1 Built-in mouse input support
|
||||||
\1 4 serial ports to connect disk drives, modems and other computers
|
\1 4 serial ports to connect disk drives, modems and other computers
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
There are three memories on the system: Hardware Memory (8 MB), Scratchpad Memory (up to 8 MB) and Program Memory (infinite!)
|
There are three memories on the system: Hardware Memory (8 MB), Core Memory (up to 8 MB) and Program Memory (infinite!)
|
||||||
|
|
||||||
Your Javascript program is stored into the Program Memory, and since its capacity is limitless, you can put a large graphics directly into your Javascript source code, but the Program Memory is the slowest of all three memories. For faster graphics, you need to store them onto the Scratchpad Memory then DMA-Copy them to the graphics adapter.
|
Your Javascript program is stored into the Program Memory, and since its capacity is limitless, you can put a large graphics directly into your Javascript source code, but the Program Memory is the slowest of all three memories. For faster graphics, you need to store them onto the Core Memory then DMA-Copy them to the graphics adapter.
|
||||||
|
|
||||||
\section{Javascript Extensions}
|
\section{Javascript Extensions}
|
||||||
|
|
||||||
@@ -36,7 +36,29 @@ Your Javascript program is stored into the Program Memory, and since its capacit
|
|||||||
|
|
||||||
\chapter{Libraries}
|
\chapter{Libraries}
|
||||||
|
|
||||||
|
\thismachine\ runs your program on an embedded ECMAScript engine. The language is standard Javascript; what makes a \thismachine\ program a \thismachine\ program is the set of \emph{host namespaces} the engine exposes --- global objects, available without any import, through which the program talks to the hardware.
|
||||||
|
|
||||||
|
\section{Namespaces}
|
||||||
|
|
||||||
|
\index{namespaces}The following namespaces are always present, whether or not an operating system is loaded:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{sys}{low-level system and memory access (\code{peek}, \code{poke}, \code{malloc}, timing, input).}
|
||||||
|
\1\inlinesynopsis{graphics}{the graphics adapter: pixels, palette, graphics modes, image decoding.}
|
||||||
|
\1\inlinesynopsis{audio}{the sound card: PCM playback and the tracker engine.}
|
||||||
|
\1\inlinesynopsis{com}{block (``serial'') communication with attached devices.}
|
||||||
|
\1\inlinesynopsis{dma}{bulk memory-to-memory and memory-to-device transfers.}
|
||||||
|
\1\inlinesynopsis{gzip}{compression and decompression (see the note on the name below).}
|
||||||
|
\1\inlinesynopsis{base64}{Base64 encoding and decoding.}
|
||||||
|
\1\inlinesynopsis{serial}{a debug text channel to the host; output appears on the host's console, not on the \thismachine\ screen.}
|
||||||
|
\1\inlinesynopsis{con}{screen text manipulation, built on top of the above.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The plain \code{print}, \code{println}, \code{printerr}, \code{printerrln} and \code{read} functions are also global.
|
||||||
|
|
||||||
|
A second set of namespaces --- \code{files}, \code{input}, \code{unicode}, \code{GL}, \code{require}, \code{exports} and the \code{\_G.shell} family --- is provided by \thedos\ and is only present once the operating system has booted. Those are documented in the \thedos\ part of this book.
|
||||||
|
|
||||||
|
\textbf{A note on \code{gzip}:} despite the name, the \code{gzip} namespace compresses and decompresses using the \emph{Zstandard} format. The decompressor recognises both Zstandard and the older gzip streams, so older files keep working. The name is historical.
|
||||||
|
|
||||||
\section{Standard Input and Output}
|
\section{Standard Input and Output}
|
||||||
|
|
||||||
@@ -96,6 +118,8 @@ Functions:
|
|||||||
\1\formalsynopsis{color\_fore}{code: Int}{Defines the foreground colour of the text. 0 -- black, 1 -- red, 2 -- green, 3 -- yellow, 4 -- blue, 5 -- magenta, 6 -- cyan, 7 -- white, -1 -- transparent}
|
\1\formalsynopsis{color\_fore}{code: Int}{Defines the foreground colour of the text. 0 -- black, 1 -- red, 2 -- green, 3 -- yellow, 4 -- blue, 5 -- magenta, 6 -- cyan, 7 -- white, -1 -- transparent}
|
||||||
\1\formalsynopsis{color\_back}{code: Int}{Defines the background colour of the text.}
|
\1\formalsynopsis{color\_back}{code: Int}{Defines the background colour of the text.}
|
||||||
\1\formalsynopsis{color\_pair}{fore: Int, back: Int}{Defines the foreground and background colour of the text. Colour code for this function differs from the \code{color\_back} and \code{color\_fore}; please refer to the \ref{colourpalette}.}
|
\1\formalsynopsis{color\_pair}{fore: Int, back: Int}{Defines the foreground and background colour of the text. Colour code for this function differs from the \code{color\_back} and \code{color\_fore}; please refer to the \ref{colourpalette}.}
|
||||||
|
\1\formalsynopsis{get\_color\_fore}{}[Int]{Returns the current text foreground colour (palette index).}
|
||||||
|
\1\formalsynopsis{get\_color\_back}{}[Int]{Returns the current text background colour (palette index).}
|
||||||
\1\formalsynopsis{clear}{}{Clears the text buffer. The framebuffer (if any) will not be affected.}
|
\1\formalsynopsis{clear}{}{Clears the text buffer. The framebuffer (if any) will not be affected.}
|
||||||
\1\formalsynopsis{reset\_graphics}{}{Resets foreground and background colour to defaults and makes the cursor visible if it was hidden.}
|
\1\formalsynopsis{reset\_graphics}{}{Resets foreground and background colour to defaults and makes the cursor visible if it was hidden.}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
@@ -146,7 +170,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\formalsynopsis{poke}{address: Int, value: Int}{Puts a value into the memory of the specified address.}
|
\1\formalsynopsis{poke}{address: Int, value: Int}{Puts a value into the memory of the specified address.}
|
||||||
\1\formalsynopsis{peek}{address: Int}[Int]{Reads a value from the memory of the specified address.}
|
\1\formalsynopsis{peek}{address: Int}[Int]{Reads a value from the memory of the specified address.}
|
||||||
\1\formalsynopsis{malloc}{size: Int}[Int]{Allocates a space of the given size on the Scratchpad memory and returns its pointer (starting address)}
|
\1\formalsynopsis{malloc}{size: Int}[Int]{Allocates a space of the given size on the Core memory and returns its pointer (starting address)}
|
||||||
\1\formalsynopsis{free}{pointer: Int}{Frees the memory space previously \code{malloc}'d}
|
\1\formalsynopsis{free}{pointer: Int}{Frees the memory space previously \code{malloc}'d}
|
||||||
\1\formalsynopsis{memcpy}{from: Int, to: Int, length: Int}{Copies the memory block of the given length. From and To are pointers.}
|
\1\formalsynopsis{memcpy}{from: Int, to: Int, length: Int}{Copies the memory block of the given length. From and To are pointers.}
|
||||||
\1\formalsynopsis{mapRom}{romSlotNum: Int}{Maps the contents on the given ROM to the memory address {-\nobreak65537.\nobreak.-\nobreak131072}}
|
\1\formalsynopsis{mapRom}{romSlotNum: Int}{Maps the contents on the given ROM to the memory address {-\nobreak65537.\nobreak.-\nobreak131072}}
|
||||||
@@ -163,7 +187,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
\1\formalsynopsis{waitForMemChg}{address: Int, andMask: Int, xorMask: Int}[]{Do nothing until a memory value is changed. More specifically, this function will \code{spin} while:$$ (\mathrm{peek}(addr)\ \mathrm{xor}\ xorMask)\ \mathrm{and}\ andMask = 0 $$}
|
\1\formalsynopsis{waitForMemChg}{address: Int, andMask: Int, xorMask: Int}[]{Do nothing until a memory value is changed. More specifically, this function will \code{spin} while:$$ (\mathrm{peek}(addr)\ \mathrm{xor}\ xorMask)\ \mathrm{and}\ andMask = 0 $$}
|
||||||
\1\formalsynopsis{getSysRq}{}[Boolean]{Returns true if System Request key is down.}
|
\1\formalsynopsis{getSysRq}{}[Boolean]{Returns true if System Request key is down.}
|
||||||
\1\formalsynopsis{unsetSysRq}{}{After using the System Request key, call this function to `release' the key so that other programs can use it.}
|
\1\formalsynopsis{unsetSysRq}{}{After using the System Request key, call this function to `release' the key so that other programs can use it.}
|
||||||
\1\formalsynopsis{maxmem}{}[Int]{returns the size of the Scratchpad Memory in bytes.}
|
\1\formalsynopsis{maxmem}{}[Int]{returns the size of the Core Memory in bytes.}
|
||||||
\1\formalsynopsis{getUsedMem}{}[Int]{Returns how many memories can be \code{malloc}'d.}
|
\1\formalsynopsis{getUsedMem}{}[Int]{Returns how many memories can be \code{malloc}'d.}
|
||||||
\1\formalsynopsis{getMallocStatus}{}[IntArray(2)]{Returns the \code{malloc} status in following order:$$ [\mathrm{Malloc\ unit\ size,\ allocated\ block\ counts}] $$}
|
\1\formalsynopsis{getMallocStatus}{}[IntArray(2)]{Returns the \code{malloc} status in following order:$$ [\mathrm{Malloc\ unit\ size,\ allocated\ block\ counts}] $$}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
@@ -172,6 +196,37 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\section{DMA}
|
||||||
|
|
||||||
|
\index{dma (library)}The \thismachine\ can copy blocks of memory directly, without the program shuffling individual bytes. This is far faster than a \code{peek}/\code{poke} loop and is the usual way of moving graphics into the framebuffer.
|
||||||
|
|
||||||
|
\namespaceis{DMA}{dma}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{ramToRam}{from: Int, to: Int, length: Int}{Copies a block of memory from one address to another.}
|
||||||
|
\1\formalsynopsis{ramToFrame}{from: Int, to: Int, length: Int}{Copies a block of memory into the framebuffer of the first graphics adapter.}
|
||||||
|
\1\formalsynopsis{ramToFrame}{from: Int, devnum: Int, offset: Int, length: Int}{Copies a block of memory into the framebuffer of the graphics adapter in the given slot, starting at the given offset.}
|
||||||
|
\1\formalsynopsis{frameToRam}{from: Int, to: Int, length: Int}{Copies a block of framebuffer memory back into ordinary memory.}
|
||||||
|
\1\formalsynopsis{frameToRam}{from: Int, to: Int, devnum: Int, length: Int}{As above, sourcing the framebuffer of the graphics adapter in the given slot.}
|
||||||
|
\1\formalsynopsis{comToRam}{portNo: Int, srcOff: Int, destOff: Int, length: Int}{Copies bytes from a serial port's receive block into memory.}
|
||||||
|
\1\formalsynopsis{ramToCom}{srcOff: Int, portNo: Int, length: Int}{Copies bytes from memory into a serial port's send block.}
|
||||||
|
\1\formalsynopsis{strToRam}{str: String, to: Int, srcOff: Int, length: Int}{Writes the characters of a Javascript string into memory as bytes.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
\section{Serial Debugger}
|
||||||
|
|
||||||
|
\index{serial (library)}The \code{serial} namespace prints to a debug channel that appears on the \emph{host} machine's console, never on the \thismachine\ screen. It is invaluable for tracing a program without disturbing what the user sees.
|
||||||
|
|
||||||
|
\namespaceis{Serial}{serial}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{print}[Anything]{prints a value to the host debug console.}
|
||||||
|
\1\inlinesynopsis{println}[Anything]{prints a value followed by a new line to the host debug console.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\chapter{Serial Communication}
|
\chapter{Serial Communication}
|
||||||
|
|
||||||
Some peripherals such as disk drives are connected through the ``Serial Communication''.
|
Some peripherals such as disk drives are connected through the ``Serial Communication''.
|
||||||
@@ -328,7 +383,13 @@ The com-port will behave differently if you're writing to or reading from the ad
|
|||||||
|
|
||||||
\section{Keyboard}
|
\section{Keyboard}
|
||||||
|
|
||||||
TODO
|
\index{keyboard}The keyboard is part of the main hardware (the slot-zero IO device) and is read through MMIO. It offers two distinct views of the same physical keyboard, and a program chooses whichever suits it.
|
||||||
|
|
||||||
|
\textbf{The input stream (TTY view).} For ordinary line- and character-oriented input, the program opens the text input stream by writing a non-zero value to MMIO \code{-39}. Keystrokes are then translated to characters and pushed onto the \emph{keyboard input buffer}; the head of that buffer is read from MMIO \code{-38}, and reading it shifts the buffer along. This is the mechanism behind \code{con.getch}, \code{read} and the \code{sys.read}/\code{sys.readKey} family. Opening the stream clears the buffer, so it must be opened exactly once.
|
||||||
|
|
||||||
|
\textbf{The raw view.} For interactive programs that need to know which keys are physically held down --- games, editors --- the program latches the current input by writing to MMIO \code{-40} and then reads the \emph{list of pressed keys} from \code{-41..-48}. This buffer holds up to eight simultaneous keys (``8-key rollover''), sorted, with zero filling the unused slots. \code{con.poll\_keys} exposes this directly. Latching freezes the values so that a multi-key read is consistent.
|
||||||
|
|
||||||
|
The codes in the raw view are the engine keycodes listed below; the codes in the stream view are character codes (with a handful of control values, e.g.\ \code{3} for Ctrl-C, \code{8} for Backspace, \code{13} for Return, and \code{19}--\code{22} for the arrow keys). \thedos\ builds its event-driven \code{input} library (see the \thedos\ part) on top of the raw view.
|
||||||
|
|
||||||
\subsection{Keycodes}
|
\subsection{Keycodes}
|
||||||
|
|
||||||
@@ -451,7 +512,14 @@ F12 & 142 \\
|
|||||||
|
|
||||||
\section{Mouse}
|
\section{Mouse}
|
||||||
|
|
||||||
TODO
|
\index{mouse}The mouse is read through the same slot-zero IO device. Its position and button state are memory-mapped:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1the cursor's X position is a 16-bit value at MMIO \code{-33..-34}, and the Y position at \code{-35..-36}. Like the keyboard's raw view, both are latched by a write to \code{-40} so a coordinate pair can be read consistently.
|
||||||
|
\1the button state is a single byte at \code{-37}. It is a bit-field: bit 0 is the left button, bit 1 the right, bit 2 the middle. Bits 6 and 7 report a wheel notch up and down respectively; these wheel bits latch in hardware and clear when read, so a single notch is reported exactly once.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Most programs do not poke these addresses directly. Under \thedos, the \code{input} library turns mouse motion, clicks and wheel notches into the \textbf{mouse\_down}, \textbf{mouse\_up}, \textbf{mouse\_move} and \textbf{mouse\_wheel} events described in the \thedos\ part of this book.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Peripherals and Memory Mapping}
|
\chapter{Peripherals and Memory Mapping}
|
||||||
@@ -490,7 +558,7 @@ The memory map of \thismachine\ is illustrated as following:
|
|||||||
\draw(0,12.5) node[anchor=north east] {Start};
|
\draw(0,12.5) node[anchor=north east] {Start};
|
||||||
\draw(6,12.5) node[anchor=north west] {End};
|
\draw(6,12.5) node[anchor=north west] {End};
|
||||||
|
|
||||||
\draw(3,10) node[anchor=mid] {\memlabel{Scratchpad Memory}};
|
\draw(3,10) node[anchor=mid] {\memlabel{Core Memory}};
|
||||||
\draw(3,7.5) node[anchor=mid] {\memlabel{MMIO Area}};
|
\draw(3,7.5) node[anchor=mid] {\memlabel{MMIO Area}};
|
||||||
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
||||||
\draw(3,5.5) node[anchor=mid] {\memlabel{Peripheral Memory \#2}};
|
\draw(3,5.5) node[anchor=mid] {\memlabel{Peripheral Memory \#2}};
|
||||||
@@ -559,7 +627,7 @@ Address & RW & Description \\
|
|||||||
-41..-48 & RO & List of pressed keys (latched by -40) \\
|
-41..-48 & RO & List of pressed keys (latched by -40) \\
|
||||||
-49 & RO & System Flags A (\code{0b r000 000t}, where r: RESET button held, t: STOP button held) \\
|
-49 & RO & System Flags A (\code{0b r000 000t}, where r: RESET button held, t: STOP button held) \\
|
||||||
-50..-52 & RO & Unused System Flags \\
|
-50..-52 & RO & Unused System Flags \\
|
||||||
-65..-68 & RO & Size of the Scratchpad Memory \\
|
-65..-68 & RO & Size of the Core Memory \\
|
||||||
-69 & WO & Counter Latch (\code{0b01}--Uptime, \code{0b10}--RTC) \\
|
-69 & WO & Counter Latch (\code{0b01}--Uptime, \code{0b10}--RTC) \\
|
||||||
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
||||||
-81..-88 & RO & RTC in nanoseconds (latched by -69) \\
|
-81..-88 & RO & RTC in nanoseconds (latched by -69) \\
|
||||||
@@ -578,7 +646,14 @@ Address & RW & Description \\
|
|||||||
|
|
||||||
\chapter{Text and Graphics Display}
|
\chapter{Text and Graphics Display}
|
||||||
|
|
||||||
TODO: Textbuf, pixelbuf, graphics mode, chart of the draw order
|
\index{display model}The reference graphics adapter drives the screen from two superimposed surfaces:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1the \textbf{framebuffer} (also called the pixel buffer), a grid of pixels whose size and colour depth depend on the current graphics mode; and
|
||||||
|
\1the \textbf{text buffer}, a grid of character cells, each with its own foreground and background colour.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
These are composited every frame in a fixed order: first the screen background (border) colour, then the framebuffer, then the text on top. The text is therefore always visible over whatever the framebuffer holds, and a text cell whose background uses the transparent palette entry lets the framebuffer show through. A program that wants a purely graphical display simply leaves the text buffer blank; one that wants a classic terminal leaves the framebuffer cleared.
|
||||||
|
|
||||||
While the graphics adapters can be plugged into any peripheral slot, it is highly recommended they occupy the 1st slot. The Memory address charts for the graphics adapter on this documentation will assume as such.
|
While the graphics adapters can be plugged into any peripheral slot, it is highly recommended they occupy the 1st slot. The Memory address charts for the graphics adapter on this documentation will assume as such.
|
||||||
|
|
||||||
@@ -634,7 +709,35 @@ Sequence & Description \\
|
|||||||
|
|
||||||
\section{Frame Buffer}
|
\section{Frame Buffer}
|
||||||
|
|
||||||
TODO
|
\index{frame buffer}The framebuffer holds the pixel image. In the default mode it is $560\times448$ pixels, one byte per pixel, where each byte is an index into the 256-entry colour palette. Pixels are written either with the \code{graphics} library (\code{plotPixel}, \code{plotRect}, \dots), or far more quickly by \code{dma}-copying a prepared image into the framebuffer region of the adapter's memory.
|
||||||
|
|
||||||
|
\subsection{Graphics Modes}
|
||||||
|
|
||||||
|
\index{graphics mode}The adapter can be reconfigured into several modes that trade resolution, colour depth and the number of \emph{layers} (independent pixel planes that the adapter composites together). The mode is selected with \code{graphics.setGraphicsMode}, or by poking the Current Graphics Mode register. The higher modes need extra video-memory banks to be fitted; if the required banks are absent, the mode change is ignored.
|
||||||
|
|
||||||
|
\begin{center}
|
||||||
|
\begin{tabulary}{\textwidth}{clcl}
|
||||||
|
Mode & Resolution & Colours & Layers / requirement \\
|
||||||
|
\hline
|
||||||
|
0 & $560\times448$ & 256 & 1 layer (default) \\
|
||||||
|
1 & $280\times224$ & 256 & 4 layers \\
|
||||||
|
2 & $280\times224$ & 4096 & 2 layers \\
|
||||||
|
3 & $560\times448$ & 256 & 2 layers (needs bank 2) \\
|
||||||
|
4 & $560\times448$ & 4096 & 1 layer (needs bank 2) \\
|
||||||
|
5 & $560\times448$ & 15-bit & 1 layer (needs bank 2) \\
|
||||||
|
8 & $560\times448$ & 24-bit & 1 layer (needs banks 3 \& 4) \\
|
||||||
|
\end{tabulary}
|
||||||
|
\end{center}
|
||||||
|
|
||||||
|
A graphics adapter ships with one 256\,kB memory bank and can hold up to four. The number of installed banks is reported by the adapter and limits which modes are available.
|
||||||
|
|
||||||
|
\subsection{Direct Colour}
|
||||||
|
|
||||||
|
\index{direct colour}The 4096-colour (``direct colour'') modes do not use the palette. Instead two layers are paired to form one frame: the low layer carries the red and green channels (\code{0b RRRR GGGG}) and the high layer carries the blue channel and a 4-bit transparency (\code{0b BBBB AAAA}), giving 4096 colours each with 16 levels of transparency.
|
||||||
|
|
||||||
|
\subsection{Layers and Scrolling}
|
||||||
|
|
||||||
|
\index{layers}When a mode provides more than one layer, the \emph{layer arrangement} register chooses the order in which the layers are stacked, so a program can swap front and back planes without moving any pixels. The whole framebuffer can also be panned: the horizontal and vertical framebuffer-scroll registers shift the entire image, and a per-scanline horizontal offset table allows each scanline to be displaced independently --- the basis of split-screen and wavy ``raster'' effects.
|
||||||
|
|
||||||
\subsection{Colour Palette}
|
\subsection{Colour Palette}
|
||||||
\label{colourpalette}
|
\label{colourpalette}
|
||||||
@@ -940,14 +1043,55 @@ TODO
|
|||||||
|
|
||||||
\section{The Graphics Library}
|
\section{The Graphics Library}
|
||||||
|
|
||||||
\index{graphics (library)}Graphics library provides basic functions to communicate and manipulate the graphics adapter.
|
\index{graphics (library)}Graphics library provides basic functions to communicate and manipulate the graphics adapter. Coordinates are in pixels with the origin at the top-left; colours are palette indices unless the adapter is in a direct-colour mode.
|
||||||
|
|
||||||
\namespaceis{Graphics}{graphics}
|
\namespaceis{Graphics}{graphics}
|
||||||
|
|
||||||
|
Drawing:
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\formalsynopsis{TODO}{to be added.}
|
\1\formalsynopsis{plotPixel}{x: Int, y: Int, colour: Int}{Plots a single pixel on the first framebuffer.}
|
||||||
|
\1\formalsynopsis{plotPixel2}{x: Int, y: Int, colour: Int}{Plots a single pixel on the second framebuffer.}
|
||||||
|
\1\formalsynopsis{plotRect}{x: Int, y: Int, w: Int, h: Int, colour: Int}{Fills a rectangle on the first framebuffer. An optional sixth argument selects a blending effect.}
|
||||||
|
\1\formalsynopsis{plotRect2}{x: Int, y: Int, w: Int, h: Int, colour: Int}{As \code{plotRect}, on the second framebuffer.}
|
||||||
|
\1\formalsynopsis{clearPixels}{col: Int}{Fills the entire framebuffer with the given colour.}
|
||||||
|
\1\formalsynopsis{clearText}{}{Blanks the text buffer.}
|
||||||
|
\1\formalsynopsis{getGpuMemBase}{}[Int]{Returns the base address of the graphics adapter's memory, for direct access via \code{peek}/\code{poke} or \code{dma}.}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
Colour:
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{setBackground}{r: Int, g: Int, b: Int}{Sets the screen background (border) colour, 8 bits per channel.}
|
||||||
|
\1\formalsynopsis{resetPalette}{}{Restores the default colour palette.}
|
||||||
|
\1\formalsynopsis{setPalette}{index: Int, r: Int, g: Int, b: Int, a: Int}{Sets one palette entry. Channels are 4-bit (0--15); \code{a} (alpha) defaults to 15 (opaque).}
|
||||||
|
\1\formalsynopsis{setTextFore}{b: Int}{Sets the current text foreground colour.}
|
||||||
|
\1\formalsynopsis{setTextBack}{b: Int}{Sets the current text background colour.}
|
||||||
|
\1\formalsynopsis{getTextFore}{}[Int]{Returns the current text foreground colour.}
|
||||||
|
\1\formalsynopsis{getTextBack}{}[Int]{Returns the current text background colour.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Mode and geometry:
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{setGraphicsMode}{mode: Int}{Switches the graphics mode (see \emph{Graphics Modes}).}
|
||||||
|
\1\formalsynopsis{getGraphicsMode}{}[Int]{Returns the current graphics mode.}
|
||||||
|
\1\formalsynopsis{getPixelDimension}{}[IntArray(2)]{Returns the framebuffer size as \code{[width, height]}.}
|
||||||
|
\1\formalsynopsis{getTermDimension}{}[IntArray(2)]{Returns the text grid size as \code{[columns, rows]}.}
|
||||||
|
\1\formalsynopsis{setFramebufferScroll}{x: Int, y: Int}{Pans the whole framebuffer to the given offset.}
|
||||||
|
\1\formalsynopsis{getFramebufferScroll}{}[IntArray(2)]{Returns the current framebuffer scroll offset.}
|
||||||
|
\1\formalsynopsis{scrollFrame}{xdelta: Int, ydelta: Int}{Pans the framebuffer by a relative amount.}
|
||||||
|
\1\formalsynopsis{setLineOffset}{line: Int, offset: Int}{Sets the per-scanline horizontal offset (raster effects).}
|
||||||
|
\1\formalsynopsis{getLineOffset}{line: Int}[Int]{Returns the per-scanline horizontal offset.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Text cursor and symbols (these talk to the adapter directly; under \thedos\ prefer the \code{con} library):
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{getCursorYX}{}[IntArray(2)]{Returns the text cursor position as \code{[row, column]}.}
|
||||||
|
\1\formalsynopsis{setCursorYX}{cy: Int, cx: Int}{Moves the text cursor.}
|
||||||
|
\1\formalsynopsis{putSymbol}{c: Int}{Writes the character of the given code at the cursor, without advancing it.}
|
||||||
|
\1\formalsynopsis{putSymbolAt}{cy: Int, cx: Int, c: Int}{Writes a character at a specific cell.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Image decoding: the adapter can decode compressed still images straight into memory. \code{decodeImage(srcFilePtr, srcFileLen)} decodes an image (JPEG, PNG, TGA, \dots) whose bytes are already in memory and returns its dimensions; \code{decodeImageTo} writes the result to a given buffer, and \code{decodeImageResample} scales it on the way. These, together with the hardware decoders for the iPF, TEV and TAV formats, are what the \thedos\ media players are built on; the formats themselves are described in the \thedos\ part.
|
||||||
|
|
||||||
|
|
||||||
\section{MMIO and Memory Mapping}
|
\section{MMIO and Memory Mapping}
|
||||||
|
|
||||||
@@ -987,3 +1131,43 @@ Address & RW & Description \\
|
|||||||
-1310209..-1310720 & RW & Palettes in This Pattern: {\ttfamily 0b RRRR GGGG; 0b BBBB AAAA} \\
|
-1310209..-1310720 & RW & Palettes in This Pattern: {\ttfamily 0b RRRR GGGG; 0b BBBB AAAA} \\
|
||||||
-1310721..-1561600 & RW & Second Framebuffer \\
|
-1310721..-1561600 & RW & Second Framebuffer \\
|
||||||
\end{tabulary}
|
\end{tabulary}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Audio}
|
||||||
|
|
||||||
|
\index{audio adapter}The \thismachine\ sound card is built from four independent \textbf{playheads}. Each playhead can play either a stream of PCM samples or a tracker track, and the four are mixed together to the card's native output of 32\,kHz stereo.
|
||||||
|
|
||||||
|
The playheads are not locked to one another, so timing between them is not guaranteed. A single piece of music should therefore live on a single playhead; the remaining playheads are best used for sound effects or left idle.
|
||||||
|
|
||||||
|
\section{PCM Playback}
|
||||||
|
|
||||||
|
\index{PCM playback}In PCM mode a playhead is fed a queue of raw samples, which the card plays back at the hardware rate. This is the path used by the \thedos\ players for raw PCM, WAV/ADPCM, MP2 and \thedos's own compressed audio --- a player decodes the file into samples and pushes them into a playhead's queue. The relevant format details are covered, from the listener's point of view, in the \thedos\ part.
|
||||||
|
|
||||||
|
\section{Tracker Playback}
|
||||||
|
|
||||||
|
\index{tracker engine}In tracker mode a playhead is driven by the card's built-in tracker engine. The engine keeps a pool of digitised \emph{samples}, a set of \emph{instruments} built from them, and the \emph{patterns} and cue sheet that sequence the notes; once these are loaded the playhead renders the song autonomously. This is how \thismachine\ plays its native \textbf{Taud} music.
|
||||||
|
|
||||||
|
This guide deliberately stops at that overview. The Taud file format, the instrument and envelope model, the effect commands, and the \emph{Microtone} tracker used to author Taud music are large topics with a manual of their own, and are out of scope here.
|
||||||
|
|
||||||
|
\section{The Audio Library}
|
||||||
|
|
||||||
|
\index{audio (library)}The \code{audio} namespace controls the playheads. The functions below cover everyday playback; the engine also exposes a large number of tracker-control and voice-inspection calls (pattern and cue upload, tempo, per-voice queries, \dots) that belong with the Taud documentation.
|
||||||
|
|
||||||
|
\namespaceis{Audio}{audio}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{setPcmMode}{playhead: Int}{Puts a playhead (0--3) into PCM mode.}
|
||||||
|
\1\formalsynopsis{setTrackerMode}{playhead: Int}{Puts a playhead into tracker mode.}
|
||||||
|
\1\formalsynopsis{play}{playhead: Int}{Starts the playhead.}
|
||||||
|
\1\formalsynopsis{stop}{playhead: Int}{Stops the playhead.}
|
||||||
|
\1\formalsynopsis{isPlaying}{playhead: Int}[Boolean]{Whether the playhead is currently playing.}
|
||||||
|
\1\formalsynopsis{getPosition}{playhead: Int}[Int]{Current playback position of the playhead.}
|
||||||
|
\1\formalsynopsis{setMasterVolume}{playhead: Int, volume: Int}{Sets the playhead's output volume.}
|
||||||
|
\1\formalsynopsis{setMasterPan}{playhead: Int, pan: Int}{Sets the playhead's stereo pan.}
|
||||||
|
\1\formalsynopsis{putPcmDataByPtr}{playhead: Int, ptr: Int, length: Int, destOffset: Int}{Copies PCM samples from memory into the playhead's buffer.}
|
||||||
|
\1\formalsynopsis{purgeQueue}{playhead: Int}{Empties the playhead's pending sample queue.}
|
||||||
|
\1\formalsynopsis{resetParams}{playhead: Int}{Resets the playhead to default parameters.}
|
||||||
|
\1\formalsynopsis{getMemAddr}{}[Int]{Returns the base address of the audio adapter's memory space.}
|
||||||
|
\1\formalsynopsis{getBaseAddr}{}[Int]{Returns the base address of the audio adapter's MMIO area.}
|
||||||
|
\end{outline}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
\thismachine\ is a virtual machine programmable using mainly, but not limited to, Javascript, and can have 7 virtual peripherals that can be communicated using MMIOs exclusively. \thismachine\ has default graphics of 80-column 32-rows text mode with one 560\times448 pixels framebuffer with 256 palette colours with 4096 colours to choose from.
|
\thismachine\ is a virtual machine that imitates the architecture of an 8-bit era home computer while being programmed, mainly but not exclusively, in Javascript. A \thismachine\ system is built around a flat memory space into which both the core memory and the hardware peripherals are mapped, so that every device --- the graphics adapter, the sound card, the disk drives --- is reached through the same \code{peek} and \code{poke} operations that touch ordinary memory.
|
||||||
|
|
||||||
This is the documentation for \thismachine\ \tsvmver.
|
Out of the box, \thismachine\ presents an 80-column, 32-row text display backed by a $560\times448$-pixel framebuffer with 256 simultaneous colours chosen from a palette of 4096, built-in keyboard and mouse input, and four serial ports for attaching disk drives, modems and other machines. Up to seven expansion cards may be fitted, each mapping a megabyte of its own memory into the address space.
|
||||||
|
|
||||||
|
This guide is one book in two parts. The first part, \emph{\thismachine}, documents the virtual machine itself: its memory map, the Javascript runtime and its built-in libraries, the way peripherals are addressed, and the text, graphics and audio hardware. The second part, \emph{\thedos}, documents the disk operating system that is usually shipped with the machine: how it boots, the commands and applications it provides, how it plays back media, the format in which commands describe themselves, and the libraries it offers to programs of your own.
|
||||||
|
|
||||||
|
This is the documentation for \thismachine\ version \tsvmver\ and the \thedos\ that accompanies it.
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ Copyrighted under the terms of MIT License
|
|||||||
|
|
||||||
\begin{center}
|
\begin{center}
|
||||||
\begin{tabulary}{\textwidth}{ll}
|
\begin{tabulary}{\textwidth}{ll}
|
||||||
Zeroth Edition (for version 1.0): & \thepublishingdate
|
\theedition\ (for version \tsvmver): & \thepublishingdate
|
||||||
\end{tabulary}
|
\end{tabulary}
|
||||||
\end{center}
|
\end{center}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
\newcommand{\codeline}[1]{%
|
\newcommand{\codeline}[1]{%
|
||||||
\colorbox{lgrey}{%
|
\colorbox{lgrey}{%
|
||||||
\begin{tabular*}{\textwidth}{l}%
|
\begin{tabular*}{\textwidth}{l}%
|
||||||
\monofont #1 \\% TODO fill the cell with \hl colour
|
\monofont #1 \\% cell background is provided by the enclosing \colorbox
|
||||||
\end{tabular*}%
|
\end{tabular*}%
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
\newcommand{\thismachine}{TSVM}
|
\newcommand{\thismachine}{TSVM}
|
||||||
\newcommand{\thedos}{TVDOS}
|
\newcommand{\thedos}{TVDOS}
|
||||||
\newcommand{\tsvmver}{1.2}
|
\newcommand{\tsvmver}{1.2}
|
||||||
\newcommand{\theedition}{Zeroth Edition}
|
\newcommand{\theedition}{First Edition}
|
||||||
\newcommand{\thepublishingdate}{0000-00-00}
|
\newcommand{\thepublishingdate}{2026-06-06}
|
||||||
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
||||||
|
|
||||||
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
||||||
@@ -247,10 +247,10 @@
|
|||||||
|
|
||||||
% \input{changesmeta}
|
% \input{changesmeta}
|
||||||
|
|
||||||
\part{The Virtual Machine}
|
\part{\thismachine: The Virtual Machine}
|
||||||
\input{implementation}
|
\input{implementation}
|
||||||
|
|
||||||
\part{The DOS}
|
\part{\thedos: The Disk Operating System}
|
||||||
\input{tvdos}
|
\input{tvdos}
|
||||||
|
|
||||||
\part*{Bibliography}
|
\part*{Bibliography}
|
||||||
|
|||||||
677
doc/tvdos.tex
677
doc/tvdos.tex
@@ -7,16 +7,16 @@ All \thedos-related features requires the DOS to be fully loaded.
|
|||||||
|
|
||||||
\chapter{Bootstrapping}
|
\chapter{Bootstrapping}
|
||||||
|
|
||||||
\index{boot process}\thedos\ goes through follwing progress to deliver the \code{A:\rs} prompt:
|
\index{boot process}\thedos\ goes through the following progress to deliver the \code{A:\rs} prompt:
|
||||||
|
|
||||||
\section{Probing Bootable Devices}
|
\section{Probing Bootable Devices}
|
||||||
The BIOS will probe serial devices to find first bootable drive. If found, port number of the driver is written to the \code{\_BIOS} object, then attempts to load and run the bootloader.
|
The BIOS will probe serial devices to find the first bootable drive. If found, the port number of the drive is written to the \code{\_BIOS} object, then it attempts to load and run the bootloader.
|
||||||
|
|
||||||
\section{The Bootloader}
|
\section{The Bootloader}
|
||||||
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
||||||
|
|
||||||
\section{TVDOS.SYS}
|
\section{TVDOS.SYS}
|
||||||
\thedos.SYS will load system libraries and variables and then will try to run the boot script by executing \code{A:\rs{}AUTOEXEC.BAT}
|
\thedos.SYS loads the system libraries and variables, installs the filesystem and input drivers, and then runs the boot script.
|
||||||
|
|
||||||
Boot Procedure:
|
Boot Procedure:
|
||||||
|
|
||||||
@@ -26,21 +26,58 @@ Boot Procedure:
|
|||||||
\item initialise DOS variables
|
\item initialise DOS variables
|
||||||
\item install filesystem drivers
|
\item install filesystem drivers
|
||||||
\item install input device drivers
|
\item install input device drivers
|
||||||
\item install GL using the external file
|
\item install \code{GL} using the external file
|
||||||
\item execute \code{AUTOEXEC.BAT}
|
\item run \code{\rs{}commandrc} to set up the environment
|
||||||
\begin{enumerate}
|
\item hand the screen to the virtual-console manager, \code{\rs{}tvdos\rs{}VTMGR.SYS}
|
||||||
\item execute \code{command.js} with proper arguments
|
\item when the manager exits, run \code{\rs{}AUTOEXEC.BAT} as a bare fallback shell
|
||||||
\item \code{command.js} to initialise \code{shell.*} functions (this includes coreutils and patched version of \code{require})
|
|
||||||
\item \code{command.js} to parse and run \code{AUTOEXEC.BAT}
|
|
||||||
\end{enumerate}
|
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
|
|
||||||
\section{AUTOEXEC.BAT}
|
Steps 7--9 are run through \code{command.js}: \thedos.SYS loads \code{command.js}, which initialises the \code{shell.*} functions (the coreutils and a patched \code{require}), and then parses and runs the requested script.
|
||||||
|
|
||||||
AUTOEXEC can setup user-specific variables (e.g. keyboard layout) and launch the command shell of your choice, \code{COMMAND} is the most common shell.
|
\section{The Boot Configuration: commandrc and AUTOEXEC.BAT}
|
||||||
|
|
||||||
Variables can be set or changed using \textbf{SET} commands.
|
\index{commandrc}\index{AUTOEXEC.BAT}The boot configuration is split across two files with deliberately different jobs. The split exists because \thedos\ runs several independent shell sessions at once (see \emph{Virtual Consoles}): the environment must be established in \emph{every} session, but applications should be launched only \emph{once per session}.
|
||||||
|
|
||||||
|
\code{\rs{}commandrc} is the \textbf{environment} file. It holds \code{set} commands and nothing else, and \thedos.SYS runs it in every context --- the boot console and every virtual-console pane. Because it has no \code{.BAT} extension, the boot block runs it line by line. A typical \code{commandrc} configures the search paths and the keyboard layout:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
set PATH=\tbas;\hopper\bin;$PATH
|
||||||
|
set INCLPATH=\hopper\include;$INCLPATH
|
||||||
|
set HELPPATH=\hopper\help;$HELPPATH
|
||||||
|
set KEYBOARD=us_qwerty
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\code{\rs{}AUTOEXEC.BAT} is the \textbf{per-console launch} script. It is run once for each console: by each virtual-console pane as it starts, and by the boot console as the fallback once the virtual-console manager has exited. It performs work that must happen per session --- registering the input method, then starting the interactive shell:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
command -fancy
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Variables are set or changed with the \textbf{set} command. A value may refer to the previous value of a variable with \code{\$NAME}, as the \code{\$PATH} above does, which appends to rather than replaces the existing search path.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Virtual Consoles}
|
||||||
|
|
||||||
|
\index{virtual consoles}\thedos\ runs up to six independent shell sessions at once, called \textbf{virtual consoles}. Only one is shown on the physical screen at a time; the others keep running in the background. This is managed by \code{VTMGR.SYS}, which the boot process starts automatically (see \emph{Bootstrapping}).
|
||||||
|
|
||||||
|
\section{Switching Consoles}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\textbf{Alt-1} through \textbf{Alt-6} switch to that console. A console is created the first time it is selected, and re-created if its shell has exited.
|
||||||
|
\1the \code{chvt} \emph{N} command switches to console \emph{N} from within a script or a running shell.
|
||||||
|
\1\textbf{Alt-0} shuts the virtual-console manager down entirely, after which the boot console's \code{AUTOEXEC.BAT} runs as a single bare shell.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Console 1 is created at boot; consoles 2--6 are created on first use. Each console runs its own \code{command -fancy} shell, with its own working directory and screen, but they all share the same environment set up by \code{commandrc}. The prompt of consoles 2--6 is prefixed with \code{[\emph{N}]} so the active console is always identifiable.
|
||||||
|
|
||||||
|
\section{Concurrency}
|
||||||
|
|
||||||
|
Switching is truly concurrent: a console keeps running even while it is not on screen, and you can switch away from a long-running command and back again without interrupting it. A console that is waiting for keyboard input is parked until it is brought to the foreground.
|
||||||
|
|
||||||
|
\section{Well-behaved Applications}
|
||||||
|
|
||||||
|
Most programs need no special handling to run inside a virtual console, because they draw through the \code{con} library and the \code{print} family, which \thedos\ routes to the correct console automatically. The one exception is a program that writes to the text area \emph{directly} through \code{graphics.getGpuMemBase()}: such a program would paint the physical screen and bleed into whichever console is visible. Applications that need direct text-area access must resolve their writes through a console-aware base address; the bundled applications that do this are already adapted.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Coreutils}
|
\chapter{Coreutils}
|
||||||
@@ -50,6 +87,7 @@ Variables can be set or changed using \textbf{SET} commands.
|
|||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\dossynopsis{cat}[file]{Reads a file and pipes its contents to the pipe, or to the console if no pipes are specified.}
|
\1\dossynopsis{cat}[file]{Reads a file and pipes its contents to the pipe, or to the console if no pipes are specified.}
|
||||||
\1\dossynopsis{cd}[dir]{Change the current working directory. Alias: chdir}
|
\1\dossynopsis{cd}[dir]{Change the current working directory. Alias: chdir}
|
||||||
|
\1\dossynopsis{chvt}[N]{Switches to virtual console \emph{N} (1--6). Only meaningful inside a virtual console. See \emph{Virtual Consoles}.}
|
||||||
\1\dossynopsis{cls}{Clears the text buffer and the framebuffer if available.}
|
\1\dossynopsis{cls}{Clears the text buffer and the framebuffer if available.}
|
||||||
\1\dossynopsis{cp}[from to]{Make copies of the specified file. The source file must not be a directory. Alias: copy}
|
\1\dossynopsis{cp}[from to]{Make copies of the specified file. The source file must not be a directory. Alias: copy}
|
||||||
\1\dossynopsis{date}{Prints the system date. Alias: time}
|
\1\dossynopsis{date}{Prints the system date. Alias: time}
|
||||||
@@ -59,42 +97,130 @@ Variables can be set or changed using \textbf{SET} commands.
|
|||||||
\1\dossynopsis{exit}{Exits the current command processor.}
|
\1\dossynopsis{exit}{Exits the current command processor.}
|
||||||
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
||||||
\1\dossynopsis{mv}[from to]{Moves or renames the file. Aliase: move}
|
\1\dossynopsis{mv}[from to]{Moves or renames the file. Aliase: move}
|
||||||
|
\1\dossynopsis{panic}{Deliberately raises an error in the command processor. A diagnostic aid.}
|
||||||
\1\dossynopsis{rem}{Comment-out the line.}
|
\1\dossynopsis{rem}{Comment-out the line.}
|
||||||
\1\dossynopsis{set}[key=value]{Sets the global variable \code{key} to \code{value}, or displays the list of global variables if no arguments were given.}
|
\1\dossynopsis{set}[key=value]{Sets the global variable \code{key} to \code{value}, or displays the list of global variables if no arguments were given.}
|
||||||
\1\dossynopsis{ver}{Prints the version of \thedos.}
|
\1\dossynopsis{ver}{Prints the version of \thedos.}
|
||||||
|
\1\dossynopsis{which}[program]{Reports how a name would resolve: as a shell built-in, or as the full path of the executable found on the \code{PATH}. Alias: where}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\chapter{Built-in Apps}
|
\chapter{Built-in Apps}
|
||||||
|
|
||||||
\index{built-in apps (DOS)}Built-in Applications are the programs shipped with the standard distribution of \thedos\ that is written for users' convenience.
|
\index{built-in apps (DOS)}Built-in Applications are the programs shipped with the standard distribution of \thedos\ that are written for users' convenience.
|
||||||
|
|
||||||
This chapter will only briefly list and describe the applications.
|
This chapter lists the general-purpose applications. The applications for playing and converting media have a chapter of their own, \emph{Media Playback and Formats}.
|
||||||
|
|
||||||
|
\section{Shells and Languages}
|
||||||
|
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\dossynopsis{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC rom is present, nothing will be done.}
|
\1\dossynopsis{command}{The default text-based \thedos\ shell. Call with \code{command -fancy} for a more \ae sthetically pleasing look.}
|
||||||
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, this command will invoke the BASIC interpreter stored in the disk.}
|
\1\dossynopsis{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC ROM is present, nothing is done.}
|
||||||
\1\dossynopsis{color}{Changes the background and the foreground of the active session.}
|
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, invokes the BASIC interpreter stored on the disk.}
|
||||||
\1\dossynopsis{command}{The default text-based DOS shell. Call with \code{command -fancy} for more \ae sthetically pleasing looks.}
|
|
||||||
\1\dossynopsis{decodeipf}[file]{Decodes the IPF-formatted image to the framebuffer using the graphics processor.}
|
|
||||||
\1\dossynopsis{drives}{Shows the list of the connected and mounted disk drives.}
|
|
||||||
\1\dossynopsis{edit}[file]{The interactive full-screen text editor.}
|
|
||||||
\1\dossynopsis{encodeipf}[1/2 imagefile ipffile]{Encodes the given image file (.jpg, .png, .bmp, .tga) to the IPF format using the graphics hardware.}
|
|
||||||
\1\dossynopsis{false}{Returns errorlevel 1 upon execution.}
|
|
||||||
\1\dossynopsis{geturl}[url]{Reads contents on the web address and store it to the disk. Requires Internet adapter.}
|
|
||||||
\1\dossynopsis{hexdump}[file]{Prints out the contents of a file in hexadecimal view. Supports pipe.}
|
|
||||||
\1\dossynopsis{less}[file]{Allows user to read the long text, even if they are wider and/or taller than the screen. Supports pipe.}
|
|
||||||
\1\dossynopsis{playmov}[file]{Plays tsvmmov-formatted video. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{playmp2}[file]{Plays MP2 (MPEG-1 Audio Layer II) formatted audio. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{playpcm}[file]{Plays raw PCM audio. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{playwav}[file]{Plays linear PCM/ADPCM audio. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{printfile}[file]{Prints out the contents of a textfile with line numbers. Useful for making descriptive screenshots.}
|
|
||||||
\1\dossynopsis{touch}[file]{Updates a file's modification date. New file will be created if the specified file does not exist.}
|
|
||||||
\1\dossynopsis{true}{Returns errorlevel 0 upon execution.}
|
|
||||||
\1\dossynopsis{zfm}{Z File Manager. A two-panel graphical user interface to navigate the system using arrow keys. Hit Z to switch panels.}
|
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Files and Text}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{edit}[file]{The interactive full-screen text editor.}
|
||||||
|
\1\dossynopsis{zfm}{Z File Manager. A two-panel graphical interface for navigating the system with the arrow keys. Press Z to switch panels.}
|
||||||
|
\1\dossynopsis{less}[file]{Lets the user read long text, even when it is wider and/or taller than the screen. Supports pipes.}
|
||||||
|
\1\dossynopsis{hexdump}[file]{Prints the contents of a file in a hexadecimal view. Supports pipes.}
|
||||||
|
\1\dossynopsis{printfile}[file]{Prints the contents of a text file with line numbers. Useful for making descriptive screenshots.}
|
||||||
|
\1\dossynopsis{touch}[file]{Updates a file's modification date. The file is created if it does not exist.}
|
||||||
|
\1\dossynopsis{tee}[file]{In a pipe, copies the incoming stream to a file \emph{and} passes it on to the next command.}
|
||||||
|
\1\dossynopsis{writeto}[file]{In a pipe, writes the incoming stream to a file.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Storage and Archives}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{drives}{Lists the connected and mounted disk drives.}
|
||||||
|
\1\dossynopsis{defrag}{Defragments the current drive.}
|
||||||
|
\1\dossynopsis{gzip}[file]{Compresses a file in place, or decompresses it with \code{-d}, or writes to standard output with \code{-c}. As with the \code{gzip} library, the actual format used is Zstandard.}
|
||||||
|
\1\dossynopsis{lfs}{Creates and extracts \thedos\ Linear File Strip (\code{.lfs}) archives --- a simple way to bundle a directory tree into one file.}
|
||||||
|
\1\dossynopsis{autorun}[file]{Runs a program or plays a media file directly from a sequential (tape) device.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Networking and Packages}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{geturl}[url]{Reads the contents at a web address and stores it to the disk. Requires an Internet adapter.}
|
||||||
|
\1\dossynopsis{telcom}[port]{A terminal program for talking to a device or modem on a serial port.}
|
||||||
|
\1\dossynopsis{hopper}{The \thedos\ package manager, for installing and managing optional software. Alias: hop.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Miscellaneous}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{synopsis}[program]{Prints a command's one-line summary together with an auto-generated synopsis --- its usage line, arguments, options and constraints --- read from the command's \code{.synopsis} description. With no \code{program} it describes itself. Alias: help.}
|
||||||
|
\1\dossynopsis{color}{Changes the background and foreground colour of the active session.}
|
||||||
|
\1\dossynopsis{true}{Returns errorlevel 0 upon execution.}
|
||||||
|
\1\dossynopsis{false}{Returns errorlevel 1 upon execution.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The music tracker and editor (invoked as \code{microtone}) is also bundled, but it --- together with the music format it authors --- is large enough to warrant its own manual and is not covered here.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Media Playback and Formats}
|
||||||
|
|
||||||
|
\index{media playback (DOS)}\thedos\ can display images, play video and play audio, in several formats. Decoding is hardware-accelerated by the graphics and audio adapters, so even the more sophisticated formats play back smoothly. This chapter is written for someone who wants to \emph{use} these formats; it describes what each is for and which command plays it, not how the codecs work internally.
|
||||||
|
|
||||||
|
Most players accept the \code{-i} flag, which turns on \textbf{interactive mode}: an on-screen progress bar, a visualiser where appropriate, and playback control. Without it, the player simply plays the file and exits. Across the players, holding \textbf{Backspace} stops playback.
|
||||||
|
|
||||||
|
\section{Still Images}
|
||||||
|
|
||||||
|
\index{iPF}The machine's native still-image format is \textbf{iPF} (Interchangeable Picture Format). The graphics hardware can also decode ordinary JPEG, PNG and TGA images directly.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{decodeipf}[file]{Decodes an iPF image onto the framebuffer.}
|
||||||
|
\1\dossynopsis{encodeipf}[1/2 imagefile ipffile]{Encodes an image file (\code{.jpg}, \code{.png}, \code{.bmp}, \code{.tga}) into iPF, using the graphics hardware. The first argument selects the iPF type (1 or 2).}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The TAV video format below also have still-picture variants, used for high-fidelity images.
|
||||||
|
|
||||||
|
\section{Video}
|
||||||
|
|
||||||
|
\thedos\ supports three families of video, in rough order of age and sophistication:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\textbf{MV1} --- the legacy movie format, carrying iPF (or plain palette) frames with MP2 audio. Created on the machine with \code{encodemov} / \code{encodemov2}.
|
||||||
|
\1\textbf{TEV} (\thismachine\ Enhanced Video) --- a modern format with markedly better compression than iPF movies, taking full advantage of the 4096-colour hardware.
|
||||||
|
\1\textbf{TAV} (\thismachine\ Advanced Video) --- the current format and successor to TEV, offering the best compression and image quality.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
TEV and TAV files are prepared on a host computer and copied to the disk for playback; MOV and iPF content can also be produced on the machine itself. Both TEV and TAV are encoded at a chosen \emph{quality level} when they are made, trading file size against fidelity --- as a viewer you simply play the result.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{playmv1}[file]{Plays a MV1-format movie.}
|
||||||
|
\1\dossynopsis{playtev}[file]{Plays a TEV-format video.}
|
||||||
|
\1\dossynopsis{playtav}[file]{Plays a TAV-format video.}
|
||||||
|
\1\dossynopsis{movprobe}[file]{Prints a movie's properties (dimensions, frame rate, audio, \dots) without playing it.}
|
||||||
|
\1\dossynopsis{playucf}[file]{Plays a chaptered movie (UCF), presenting a chapter selector.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Audio}
|
||||||
|
|
||||||
|
\index{MP2}\index{TAD}For sound, \thedos\ plays standard \textbf{MP2} (MPEG-1 Audio Layer II), raw and wave-wrapped PCM, and its own compressed format, \textbf{TAD} (\thismachine\ Advanced Audio). All audio plays back at the hardware's 32\,kHz stereo. As with video, TAD files are prepared on a host computer; MP2 is a widely interchangeable format, and PCM/WAV are uncompressed.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{playmp2}[file]{Plays MP2 (MPEG-1 Audio Layer II) audio.}
|
||||||
|
\1\dossynopsis{playtad}[file]{Plays TAD audio.}
|
||||||
|
\1\dossynopsis{playpcm}[file]{Plays raw PCM audio.}
|
||||||
|
\1\dossynopsis{playwav}[file]{Plays linear-PCM or ADPCM \code{.wav} audio.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Music}
|
||||||
|
|
||||||
|
\index{Taud}\thismachine\ has a native tracker music format, \textbf{Taud}, played by the built-in tracker engine (see the \emph{Audio} chapter in the \thismachine\ part). Unlike the streamed audio formats above, a Taud file is a compact \emph{score} --- samples plus the patterns that sequence them --- so a whole piece of music occupies very little space.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{playtaud}[file]{Plays a Taud module, with a text-mode visualiser. An optional second argument selects which song within the file to play. Hold Backspace to exit.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Authoring Taud music, and the format itself, are the subject of a separate manual and are not described here.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Writing Your Own Apps}
|
\chapter{Writing Your Own Apps}
|
||||||
@@ -110,6 +236,15 @@ The command line arguments are given via the array of strings named `exec\_args`
|
|||||||
Index zero holds the name used to invoke the app, and the rest hold the actual arguments.
|
Index zero holds the name used to invoke the app, and the rest hold the actual arguments.
|
||||||
|
|
||||||
|
|
||||||
|
\section{Describing Your App: the Synopsis File}
|
||||||
|
|
||||||
|
\index{synopsis file}Every command should ship a machine-readable description of its own command-line interface, written in the \thedos\ Synopsis Format (TSF) and described in full in the \emph{Command Synopsis Format} chapter.
|
||||||
|
|
||||||
|
The rule is simple: the synopsis lives \emph{next to} the program, with the program's full filename plus a \code{.synopsis} extension. An app installed as \code{cp.js} in \code{\rs{}tvdos\rs{}bin} therefore ships alongside it as \code{\rs{}tvdos\rs{}bin\rs{}cp.js.synopsis}. \thedos\ uses these files to generate help text and tab-completion, so providing one makes your app a first-class citizen of the system.
|
||||||
|
|
||||||
|
Built-in coreutils have no on-disk executable to sit beside, so their synopses live together in \code{\rs{}tvdos\rs{}synopsis}, named for the command (for example \code{\rs{}tvdos\rs{}synopsis\rs{}cp.synopsis}). Either way, once a synopsis is in place the \code{synopsis} command --- and its \code{help} alias --- will display it, and the shell will tab-complete against it.
|
||||||
|
|
||||||
|
|
||||||
\section{Invoking Coreutils on the user Apps}
|
\section{Invoking Coreutils on the user Apps}
|
||||||
|
|
||||||
DOS coreutils and some of the internal functions can be used on Javascript program.
|
DOS coreutils and some of the internal functions can be used on Javascript program.
|
||||||
@@ -135,6 +270,254 @@ Due to the non-preemptive nature of the virtual machine, the termination\footnot
|
|||||||
While- and For-loops are always have such checks injected, but the `read()` is not checked for the termination.
|
While- and For-loops are always have such checks injected, but the `read()` is not checked for the termination.
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Command Synopsis Format}
|
||||||
|
\label{ch:tsf}
|
||||||
|
|
||||||
|
\index{TSF}\index{synopsis format}The \thedos\ Synopsis Format (TSF) is the language in which a command describes its own command-line interface. Every command is expected to ship a TSF document --- a file with the command's full name plus a \code{.synopsis} extension, stored in the same directory as the program. The command \code{cp.js} in \code{\rs{}tvdos\rs{}bin}, for instance, is accompanied by \code{cp.js.synopsis} in that same directory. \thedos\ reads these documents to drive shell completion, help generation, and argument validation.
|
||||||
|
|
||||||
|
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY and OPTIONAL in this chapter are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.
|
||||||
|
|
||||||
|
\section{Scope}
|
||||||
|
|
||||||
|
A TSF document describes a command's grammar: its options and flags, positional arguments, subcommands, argument types, completion sources and validation constraints.
|
||||||
|
|
||||||
|
A TSF document MUST be valid JSON, and MUST be encoded so that its byte stream contains only ASCII characters: any character outside the ASCII range (U+0000--U+007F) MUST be written as a JSON \texttt{\textbackslash u} escape (a backslash, \texttt{u}, and four hexadecimal digits) rather than as a literal multibyte character. Consumers MUST decode such escapes per the JSON specification.
|
||||||
|
|
||||||
|
\section{Design Goals}
|
||||||
|
|
||||||
|
TSF SHALL be machine-readable, human-authorable, and able to support automatic shell completion, automatic help generation, parser generation and GUI generation.
|
||||||
|
|
||||||
|
The structured synopsis grammar SHALL be the sole normative description of command syntax; every other representation, including human-readable usage strings, is treated as output generated from it.
|
||||||
|
|
||||||
|
\section{Document Structure}
|
||||||
|
|
||||||
|
A TSF document SHALL contain one JSON object.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cp",
|
||||||
|
"summary": "Copy files and directories",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": {}
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Its fields are:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lclL}
|
||||||
|
Field & Req. & Type & Notes \\
|
||||||
|
\hline
|
||||||
|
tsfVersion & yes & string & Version of TSF this document targets. \\
|
||||||
|
name & yes & string & Command name as invoked. \\
|
||||||
|
summary & yes & string & One-line description. \\
|
||||||
|
symbols & yes & object & The symbol table. \\
|
||||||
|
synopsis & yes & object & The synopsis grammar root node. \\
|
||||||
|
description & no & string & Free-form long description for help generation. \\
|
||||||
|
constraints & no & array & Constraint objects. \\
|
||||||
|
metadata & no & object & Free-form, non-normative data for authors and hosts. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
\section{The Symbol Table}
|
||||||
|
|
||||||
|
All command elements SHALL be declared in the symbol table, and the synopsis grammar SHALL reference them by identifier.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"symbols": {
|
||||||
|
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||||
|
"source": { "kind": "positional", "type": "path" }
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Every symbol has a \code{kind}, one of \code{option}, \code{positional}, \code{subcommand} or \code{group}.
|
||||||
|
|
||||||
|
\section{Argument Descriptors}
|
||||||
|
|
||||||
|
An \emph{argument descriptor} describes a single consumed value. The same shape is used in two places: directly on a \code{positional} symbol, and as the \code{value} of an \code{option} symbol.
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lclL}
|
||||||
|
Field & Req. & Type & Notes \\
|
||||||
|
\hline
|
||||||
|
type & no & string & One of the built-in types. Defaults to \code{string}. \\
|
||||||
|
name & no & string & Metavar shown in generated usage (e.g.\ FILE, WHEN). \\
|
||||||
|
values & cond. & array & Permitted values; REQUIRED when \code{type} is \code{enum}. \\
|
||||||
|
default & no & any & Default value, for help and GUI prefill. \\
|
||||||
|
validation & no & object & Value-level validation (below). \\
|
||||||
|
completion & no & object & Completion override. \\
|
||||||
|
summary & no & string & Short description of the value. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
Each entry in \code{values} SHALL be either a bare JSON value or an object \code{\{ "value": v, "summary": s \}}; the optional per-value summary feeds completion hints and help.
|
||||||
|
|
||||||
|
\subsection{Validation}
|
||||||
|
|
||||||
|
The \code{validation} object expresses checks the grammar cannot:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lL}
|
||||||
|
Field & Notes \\
|
||||||
|
\hline
|
||||||
|
pattern & A regular expression the value MUST match (string-like types). \\
|
||||||
|
minimum & Inclusive lower bound (numeric types). \\
|
||||||
|
maximum & Inclusive upper bound (numeric types). \\
|
||||||
|
minLength & Minimum length in characters (string-like types). \\
|
||||||
|
maxLength & Maximum length in characters (string-like types). \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
The regular-expression flavour for \code{pattern} is implementation-defined; authors are RECOMMENDED to keep to a portable subset. As the document is ASCII-only, any non-ASCII character within a pattern MUST be escaped.
|
||||||
|
|
||||||
|
\section{Option Symbols}
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"output": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--output",
|
||||||
|
"short": "-o",
|
||||||
|
"summary": "Write output to FILE",
|
||||||
|
"value": { "name": "FILE", "type": "file", "required": true }
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lclL}
|
||||||
|
Field & Req. & Type & Notes \\
|
||||||
|
\hline
|
||||||
|
kind & yes & string & \code{option}. \\
|
||||||
|
long & cond. & string & Long form, e.g.\ \code{--recursive}. \\
|
||||||
|
short & cond. & string & Short form, e.g.\ \code{-r}. \\
|
||||||
|
summary & no & string & One-line description. \\
|
||||||
|
value & no & object & Argument descriptor. Omit for a bare flag. \\
|
||||||
|
negatable & no & boolean & If true, a \code{--no-<long>} form is also accepted. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
At least one of \code{long} or \code{short} SHALL exist. The \code{value} object MAY carry a \code{required} field (default true): when false, the argument is optional, as in \code{--color} with or without \code{=WHEN}. How often an option may repeat is expressed in the grammar (via \code{repeat} or \code{oneOrMore}), not by a field on the symbol.
|
||||||
|
|
||||||
|
\section{Positional Symbols}
|
||||||
|
|
||||||
|
A positional symbol is an argument descriptor plus its \code{kind}.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"source": { "kind": "positional", "type": "path", "summary": "Source file" }
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Its fields are \code{kind} (\code{positional}) followed by the argument-descriptor fields (\code{type}, \code{name}, \code{values}, \code{default}, \code{validation}, \code{completion}, \code{summary}). Whether a positional is required or optional is expressed in the grammar by wrapping it in \code{optional} or not, keeping a single source of truth.
|
||||||
|
|
||||||
|
\section{Subcommand Symbols}
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"clone": { "kind": "subcommand", "summary": "Clone repository", "tsf": "git.clone" }
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
A subcommand MAY reference an embedded or an external TSF document through its \code{tsf} field; resolution of that reference is implementation-specific.
|
||||||
|
|
||||||
|
\section{Group Symbols}
|
||||||
|
|
||||||
|
A group collects related symbols --- typically options --- so the grammar can refer to them collectively. A group is what backs the conventional \code{[OPTION...]} slot.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"commonOptions": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Common options",
|
||||||
|
"members": ["recursive", "force", "verbose"]
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
A group has \code{kind} (\code{group}), a \code{members} array of symbol identifiers, and an optional \code{summary}. A reference to a group is equivalent to a \code{choice} over its members; wrapping that reference in \code{repeat} yields the familiar ``any number of these options, in any order'' behaviour.
|
||||||
|
|
||||||
|
\section{The Synopsis Grammar}
|
||||||
|
|
||||||
|
The \code{synopsis} object describes valid invocations as a tree of nodes. Every node has a \code{type}:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lL}
|
||||||
|
Node & Meaning \\
|
||||||
|
\hline
|
||||||
|
sequence & All children appear in order. Has a \code{children} array. (\code{A B C}) \\
|
||||||
|
choice & Exactly one child appears. Has a \code{children} array. (\code{(A | B | C)}) \\
|
||||||
|
optional & The single \code{child} appears zero or one time. (\code{[A]}) \\
|
||||||
|
repeat & The single \code{child} appears zero or more times. (\code{A...}) \\
|
||||||
|
oneOrMore & The single \code{child} appears at least once; sugar for \code{sequence[A, repeat[A]]}. (\code{A [A...]}) \\
|
||||||
|
reference & References a \code{symbol} by identifier. A reference to a group expands to a \code{choice} over its members. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
\section{A Complete Example}
|
||||||
|
|
||||||
|
The human-readable form \code{cp [OPTION...] SOURCE DEST} is expressed as:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"symbols": {
|
||||||
|
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||||
|
"force": { "kind": "option", "long": "--force", "short": "-f" },
|
||||||
|
"options": { "kind": "group", "members": ["recursive", "force"] },
|
||||||
|
"source": { "kind": "positional", "type": "path", "name": "SOURCE" },
|
||||||
|
"destination": { "kind": "positional", "type": "path", "name": "DEST" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "source" },
|
||||||
|
{ "type": "reference", "symbol": "destination" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Because \code{options} is a declared symbol, the \code{[OPTION...]} slot satisfies the rule that every referenced element exists in the symbol table.
|
||||||
|
|
||||||
|
\section{Argument Types}
|
||||||
|
|
||||||
|
The built-in primitive types are \code{string}, \code{integer}, \code{float}, \code{boolean}, \code{path}, \code{file}, \code{directory}, \code{url}, \code{hostname}, \code{user}, \code{group}, \code{command} and \code{enum}.
|
||||||
|
|
||||||
|
A descriptor whose \code{type} is \code{enum} SHALL provide a \code{values} array. The \code{values} array MAY also be supplied for non-\code{enum} types as a soft suggestion list that informs completion without restricting valid input. Unknown types SHALL be interpreted as \code{string}; implementations are RECOMMENDED to emit a diagnostic when they do, since an unknown type is usually an authoring error. Each type carries a default completion behaviour --- \code{path}, \code{file} and \code{directory} complete against the filesystem, \code{user} and \code{group} against the account databases, \code{enum} against its \code{values} --- which a \code{completion} block overrides.
|
||||||
|
|
||||||
|
\section{Completion}
|
||||||
|
|
||||||
|
If a descriptor has no \code{completion} block, completion is derived automatically from its \code{type}. A block overrides that default and names a \code{method}:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lL}
|
||||||
|
method & Notes \\
|
||||||
|
\hline
|
||||||
|
type & Use the default completion implied by the \code{type}. (Implicit when no block is present.) \\
|
||||||
|
enum & Complete from the descriptor's \code{values}. (Implicit when \code{type} is \code{enum}.) \\
|
||||||
|
internal & Use a named provider resolved by the host (a \code{provider} field names it). \\
|
||||||
|
command & Run a command whose output supplies the candidates. \\
|
||||||
|
list & Offer a static inline list of suggestions, without restricting input. \\
|
||||||
|
none & Suppress completion for this value. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
\section{Constraints}
|
||||||
|
|
||||||
|
Constraints describe relationships not expressible in the grammar, and are listed in the root \code{constraints} array. Symmetric constraints use a \code{symbols} field; asymmetric constraints use \code{subject} and \code{targets}.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{conflicts}{symmetric --- the listed \code{symbols} are mutually exclusive.}
|
||||||
|
\1\inlinesynopsis{requires}{asymmetric --- if \code{subject} is present, every symbol in \code{targets} MUST also be present.}
|
||||||
|
\1\inlinesynopsis{implies}{asymmetric derivation --- if \code{subject} is present, every symbol in \code{targets} is implicitly set (a side effect, not a rejection).}
|
||||||
|
\1\inlinesynopsis{cardinality}{symmetric --- constrains how many of the listed \code{symbols} may appear, via \code{minimum} and \code{maximum}.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Three of these (\code{conflicts}, \code{requires}, \code{cardinality}) are validation predicates that decide whether an invocation is well-formed; \code{implies} instead sets a value.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"constraints": [
|
||||||
|
{ "type": "conflicts", "symbols": ["stdout", "output"] },
|
||||||
|
{ "type": "cardinality", "symbols": ["create", "extract", "list"],
|
||||||
|
"minimum": 1, "maximum": 1 }
|
||||||
|
]
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{Generated Usage and Compatibility}
|
||||||
|
|
||||||
|
Implementations SHOULD generate human-readable usage text from the synopsis grammar; that text is non-authoritative output, the grammar remaining the sole normative description.
|
||||||
|
|
||||||
|
TSF distinguishes additive content, which may be ignored safely, from structural content, which may not. Unknown \emph{fields} on otherwise-valid objects SHALL be ignored, so future minor versions MAY add fields without breaking existing documents or consumers. Unknown \emph{grammar node types} and unknown \emph{symbol kinds} SHALL cause the document to be rejected or to enter an explicitly defined degraded mode, because ignoring them would silently change the set of accepted invocations.
|
||||||
|
|
||||||
|
Authors and hosts that need to attach implementation-specific data SHOULD do so inside the root \code{metadata} object or under field names prefixed with \code{x-}; names without that prefix are reserved for future versions of this specification. Consumers SHOULD report the highest \code{tsfVersion} they support so producers can downgrade gracefully.
|
||||||
|
|
||||||
|
\section{How \thedos\ Uses Synopsis Documents}
|
||||||
|
|
||||||
|
A synopsis document is put to work in three ways. The shell uses it for \emph{tab-completion}: when you press Tab part-way through an argument, the grammar tells the shell whether to offer option flags, a fixed set of \code{enum} values, a subcommand name, or filesystem entries (for the \code{path}, \code{file} and \code{directory} types). The \code{synopsis} command --- and its \code{help} alias --- uses it for \emph{help}, printing the summary, a generated usage line, and the arguments, options and constraints. Authors may further use the \code{validation} and \code{constraints} sections to check an invocation before acting on it.
|
||||||
|
|
||||||
|
\thedos\ finds a command's document the same way it finds the command itself: an app's synopsis sits next to its executable (\code{geturl.js} beside \code{geturl.js.synopsis}), while the built-in coreutils, having no file of their own, keep theirs in \code{\rs{}tvdos\rs{}synopsis}. Aliases resolve to the canonical command's document, so \code{ls} is described by \code{dir.synopsis}. Parsed documents are cached under \code{\rs{}tvdos\rs{}cache\rs{}synopsis} so that repeated completions and help lookups stay fast; the cache is rebuilt on demand and may be deleted at any time.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Pipes}
|
\chapter{Pipes}
|
||||||
|
|
||||||
\index{pipe (DOS)}Pipe is a way to chain the IO of the one program/command into the different programs/commands in series.
|
\index{pipe (DOS)}Pipe is a way to chain the IO of the one program/command into the different programs/commands in series.
|
||||||
@@ -230,6 +613,7 @@ Functions:
|
|||||||
\2\argsynopsis{sread}{returns an empty string}
|
\2\argsynopsis{sread}{returns an empty string}
|
||||||
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
||||||
\2\argsynopsis{pread}{returns the specified number of zeros}
|
\2\argsynopsis{pread}{returns the specified number of zeros}
|
||||||
|
\1\inlinesynopsis{TMP}{a scratch area for temporary files, addressed as \code{\$:\rs{}TMP\rs{}\dots}. Files written here are not expected to persist.}
|
||||||
\1\inlinesynopsis{CON}{manipulates the screen text buffer, disregarding the colours}
|
\1\inlinesynopsis{CON}{manipulates the screen text buffer, disregarding the colours}
|
||||||
\2\argsynopsis{pread}{reads the texts as bytes.}
|
\2\argsynopsis{pread}{reads the texts as bytes.}
|
||||||
\2\argsynopsis{bread}{reads the texts as bytes.}
|
\2\argsynopsis{bread}{reads the texts as bytes.}
|
||||||
@@ -256,24 +640,34 @@ Functions:
|
|||||||
|
|
||||||
\subsection{Input Events}
|
\subsection{Input Events}
|
||||||
|
|
||||||
Input events are Javascript array of: $$ [\mathrm{event\ name,\ arg_1,\ arg_2 \cdots arg_n}] $$, where:
|
An input event is a Javascript array of the form $$ [\mathrm{event\ name,\ arg_1,\ arg_2 \cdots arg_n}] $$ where the event name is one of \textbf{key\_down}, \textbf{key\_change}, \textbf{mouse\_down}, \textbf{mouse\_up}, \textbf{mouse\_move} or \textbf{mouse\_wheel}. Every event ends with the current key-press buffer (\code{keycode0} through \code{keycode7}), so a handler can detect modifier keys held during a mouse action.
|
||||||
|
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1event name --- one of following: \textbf{key\_down}, \textbf{mouse\_down}, \textbf{mouse\_move}
|
|
||||||
\1arguments for \textbf{key\_down}:
|
\1arguments for \textbf{key\_down}:
|
||||||
\2\argsynopsis{\argN{1}}{Key Symbol (string) of the head key}
|
\2\argsynopsis{\argN{1}}{Key symbol (string) of the head key}
|
||||||
\2\argsynopsis{\argN{2}}{Repeat count of the key event}
|
\2\argsynopsis{\argN{2}}{Repeat count of the key event}
|
||||||
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the pressed keys}
|
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the pressed keys}
|
||||||
\1arguments for \textbf{mouse\_down}:
|
\1arguments for \textbf{key\_change} (a key was released):
|
||||||
|
\2\argsynopsis{\argN{1}}{Key symbol (string) of the key that went up}
|
||||||
|
\2\argsynopsis{\argN{2}}{0}
|
||||||
|
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the keys still held down}
|
||||||
|
\1arguments for \textbf{mouse\_down} / \textbf{mouse\_up}:
|
||||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{3}}{Always the integer 1.}
|
\2\argsynopsis{\argN{3}}{Button mask: 1 = left, 2 = right, 4 = middle (for \textbf{mouse\_up}, the button that was released)}
|
||||||
|
\2\argsynopsis{\argN{4}..}{The key-press buffer}
|
||||||
\1arguments for \textbf{mouse\_move}:
|
\1arguments for \textbf{mouse\_move}:
|
||||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{3}}{1 if the mouse button is held down (i.e. dragging), 0 otherwise}
|
\2\argsynopsis{\argN{3}}{Currently-held button mask (non-zero while dragging)}
|
||||||
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame (previous V-blank of the screen)}
|
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame}
|
||||||
\2\argsynopsis{\argN{5}}{Y-position of the mouse cursor on the previous frame}
|
\2\argsynopsis{\argN{5}}{Y-position of the mouse cursor on the previous frame}
|
||||||
|
\2\argsynopsis{\argN{6}..}{The key-press buffer}
|
||||||
|
\1arguments for \textbf{mouse\_wheel}:
|
||||||
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
|
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||||
|
\2\argsynopsis{\argN{3}}{$-1$ for a wheel notch up, $+1$ for a notch down}
|
||||||
|
\2\argsynopsis{\argN{4}..}{The key-press buffer}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
@@ -328,7 +722,8 @@ External libraries are packaged codes with the intention of being re-used by oth
|
|||||||
External libraries can be stored in following locations:
|
External libraries can be stored in following locations:
|
||||||
|
|
||||||
\begin{enumerate}
|
\begin{enumerate}
|
||||||
\item \code{A:\rs{}tvdos\rs{}include}
|
\item \code{A:\rs{}tvdos\rs{}include}, the home of the libraries bundled with \thedos
|
||||||
|
\item any directory listed in the \code{INCLPATH} variable (configured in \code{commandrc})
|
||||||
\item a path relative to the user program
|
\item a path relative to the user program
|
||||||
\item an absolute path that can be anywhere
|
\item an absolute path that can be anywhere
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -336,7 +731,7 @@ External libraries can be stored in following locations:
|
|||||||
and can be loaded by:
|
and can be loaded by:
|
||||||
|
|
||||||
\begin{enumerate}
|
\begin{enumerate}
|
||||||
\item \code{let name = require(libraryname)} // no .mjs extension
|
\item \code{let name = require(libraryname)} // no .mjs extension; searches the include directory and INCLPATH
|
||||||
\item \code{let name = require(./libraryname)} // the relative path must start with a dot-slash
|
\item \code{let name = require(./libraryname)} // the relative path must start with a dot-slash
|
||||||
\item \code{let name = require(A:/path/to/library.mjs)} // full path WITH the .mjs extension
|
\item \code{let name = require(A:/path/to/library.mjs)} // full path WITH the .mjs extension
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -357,3 +752,197 @@ const BAR = 127
|
|||||||
// following line exports the function and the variable
|
// following line exports the function and the variable
|
||||||
exports = { foo, BAR }
|
exports = { foo, BAR }
|
||||||
\end{lstlisting}
|
\end{lstlisting}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Bundled Libraries}
|
||||||
|
|
||||||
|
\index{bundled libraries (DOS)}\thedos\ ships a set of ready-made libraries in \code{A:\rs{}tvdos\rs{}include}. Each is loaded with \code{require} using its base name --- \code{require("fs")}, \code{require("psg")}, and so on --- and returns the object the library exports. This chapter documents each in turn.
|
||||||
|
|
||||||
|
\section{fs --- Filesystem (NodeJS-compatible)}
|
||||||
|
|
||||||
|
\index{fs (library)}\code{fs} wraps the \thedos\ file interface in the familiar NodeJS \code{fs} API. Synchronous (\code{*Sync}) calls, callback-style asynchronous calls, and a \code{promises} namespace are all provided; because the machine has no real concurrency, the ``asynchronous'' calls execute immediately and then invoke the callback or resolve the promise. Binary data is exchanged as \code{Uint8Array}; supplying an encoding (\code{"utf8"}, \code{"binary"}, \dots) exchanges strings instead.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let fs = require("fs")
|
||||||
|
let txt = fs.readFileSync("A:/etc/motd", "utf8")
|
||||||
|
fs.writeFileSync("A:/tmp/hello.txt", "hi", "utf8")
|
||||||
|
fs.readdirSync("A:/").forEach(println)
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Frequently used members:
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{readFileSync}[path, options]{reads an entire file.}
|
||||||
|
\1\inlinesynopsis{writeFileSync}[path, data, options]{writes (and truncates) a file.}
|
||||||
|
\1\inlinesynopsis{appendFileSync}[path, data, options]{appends to a file.}
|
||||||
|
\1\inlinesynopsis{existsSync}[path]{whether a path exists.}
|
||||||
|
\1\inlinesynopsis{statSync}[path]{returns a \code{Stats} object.}
|
||||||
|
\1\inlinesynopsis{readdirSync}[path]{lists a directory.}
|
||||||
|
\1\inlinesynopsis{mkdirSync}[path]{creates a directory.}
|
||||||
|
\1\inlinesynopsis{unlinkSync, rmSync, rmdirSync}[path]{remove files / directories.}
|
||||||
|
\1\inlinesynopsis{renameSync, copyFileSync, cpSync}[src, dest]{rename and copy.}
|
||||||
|
\1\inlinesynopsis{openSync, readSync, writeSync, closeSync}{the low-level descriptor calls.}
|
||||||
|
\end{outline}
|
||||||
|
The corresponding callback forms (\code{readFile}, \code{writeFile}, \dots), the \code{promises} namespace, the \code{Stats} and \code{Dirent} classes, and the usual \code{constants} are also exported.
|
||||||
|
|
||||||
|
\section{getopt --- Option Parsing}
|
||||||
|
|
||||||
|
\index{getopt (library)}\code{getopt} is a port of the POSIX \code{getopt()} routine. Construct a \code{BasicParser} from an option string and the argument vector, then call \code{getopt()} repeatedly; each call returns an object \code{\{ option, optarg \}} for the next option, or \code{undefined} at the end. A colon after a letter in the option string means that option takes an argument; long aliases are written in parentheses.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let getopt = require("getopt")
|
||||||
|
let parser = new getopt.BasicParser("r(recursive)o:(output)", exec_args)
|
||||||
|
let o
|
||||||
|
while ((o = parser.getopt()) !== undefined) {
|
||||||
|
switch (o.option) {
|
||||||
|
case 'r': /* recursive */ break
|
||||||
|
case 'o': let target = o.optarg; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{gl --- Graphics Library}
|
||||||
|
|
||||||
|
\index{gl (library)}\code{gl} is the same graphics-drawing library installed at boot as the \code{GL} namespace; \code{require("gl")} returns the same set of textures, sprite-sheets and drawing functions. It is documented in full in the \emph{The Graphics Library} chapter.
|
||||||
|
|
||||||
|
\section{net --- Internet Text Fetch}
|
||||||
|
|
||||||
|
\index{net (library)}\code{net} fetches text over the network through an attached HTTP modem, translating ordinary URLs into the form the modem expects.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{isAvailable}[]{true if an HTTP modem is attached.}
|
||||||
|
\1\inlinesynopsis{getHttpDrive}[]{the drive letter bound to the HTTP modem, or null.}
|
||||||
|
\1\inlinesynopsis{fetchText}[url]{fetches a URL and returns the body as a string, or null on failure.}
|
||||||
|
\1\inlinesynopsis{fetchTextOrThrow}[url]{as \code{fetchText}, but throws on failure.}
|
||||||
|
\1\inlinesynopsis{open}[url]{returns a file descriptor backed by the modem, whose \code{sread}/\code{bread} trigger the fetch.}
|
||||||
|
\1\inlinesynopsis{toModemPath}[url]{returns the \code{<drive>:\rs{}<url>} path the fetch would use.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{pcm --- PCM and ADPCM Decoding}
|
||||||
|
|
||||||
|
\index{pcm (library)}\code{pcm} decodes linear PCM and Microsoft ADPCM into the audio hardware's 8-bit format, resampling to the 32\,kHz hardware rate as needed. It underpins the \code{playpcm} and \code{playwav} players.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{decodeLPCM}[inPtr, outPtr, inputLen, config]{decodes linear PCM. \code{config} gives \code{nChannels}, \code{bitsPerSample}, \code{samplingRate} and \code{blockSize}.}
|
||||||
|
\1\inlinesynopsis{decodeMS\_ADPCM}[inPtr, outPtr, blockSize, config]{decodes one Microsoft-ADPCM block.}
|
||||||
|
\1\inlinesynopsis{HW\_SAMPLING\_RATE}{the hardware sampling rate, 32000.}
|
||||||
|
\end{outline}
|
||||||
|
The sample-conversion helpers \code{s8Tou8}, \code{s16Tou8}, \code{u16Tos16} and \code{randomRound} are exported too.
|
||||||
|
|
||||||
|
\section{psg --- Programmable Sound Generator}
|
||||||
|
|
||||||
|
\index{psg (library)}\code{psg} is a software sound generator: it synthesises classic chiptune waveforms into a buffer and sends the result to a playhead as PCM. It is handy for sound effects and simple music without authoring a full tracker module.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{makeBuffer}[length]{allocates a mixing buffer; \code{makeBufferNative} / \code{freeBufferNative} use native memory.}
|
||||||
|
\1\inlinesynopsis{clearBuffer}[buf, offsetSec, lengthSec]{silences part of a buffer.}
|
||||||
|
\1\inlinesynopsis{makeSquare}{writes a square wave (with duty cycle) into a buffer.}
|
||||||
|
\1\inlinesynopsis{makeTriangle, makeAliasedTriangle, makeAliasedTriangleNES}{triangle-wave variants.}
|
||||||
|
\1\inlinesynopsis{makeNoise}{writes an LFSR noise waveform.}
|
||||||
|
\1\inlinesynopsis{sendBuffer, sendBufferFast}[buf, playhead, offsetSec, lengthSec]{uploads a buffer to a playhead for playback.}
|
||||||
|
\end{outline}
|
||||||
|
The wave generators take an offset, frequency, amplitude, pan and a mixing operation, so several voices can be layered into one buffer.
|
||||||
|
|
||||||
|
\section{taud --- Tracker Module Loading}
|
||||||
|
|
||||||
|
\index{taud (library)}\code{taud} moves Taud music between disk and the tracker hardware. It is intentionally small; authoring Taud music belongs to the separate Taud manual.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{uploadTaudFile}[inFile, songIndex, playhead]{loads one song from a Taud file into the tracker hardware and prepares the given playhead to play it.}
|
||||||
|
\1\inlinesynopsis{captureTrackerDataToFile}[outFile]{writes the current tracker state (samples, instruments, patterns and cue sheet) out to a single-song Taud file.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{typesetter --- Rich-text Layout}
|
||||||
|
|
||||||
|
\index{typesetter (library)}\code{typesetter} wraps, aligns and justifies text for the console using a small markup language. It returns an array of strings, each padded to the requested width, ready to print.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{typeset}[text, width, opts]{lays out text to the given width (default: the rest of the current row). \code{opts.defaultAlign} is one of \code{l}, \code{c}, \code{r} or \code{j} (justified).}
|
||||||
|
\1\inlinesynopsis{typesetText}[text, width, defaultAlign]{the same, with the width always given explicitly.}
|
||||||
|
\1\inlinesynopsis{expandEntities}[s]{expands the named entities (glyphs, accidentals, arrows, \code{\ }, \dots).}
|
||||||
|
\end{outline}
|
||||||
|
The markup understands \code{<b>}/\code{<s>} for emphasis, \code{<c>}/\code{<r>}/\code{<l>} for per-line alignment, and \code{<o>} for a hanging-indent box anchored at the cursor column.
|
||||||
|
|
||||||
|
\section{wintex --- TUI Windows}
|
||||||
|
|
||||||
|
\index{wintex (library)}\code{wintex} provides a small text-mode window toolkit: framed, titled windows with their own input and drawing handlers, plus scrolling helpers and a ready-made dialog box.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{WindowObject}[x, y, w, h, inputProcessor, drawContents, title, drawFrame]{constructs a window; the two handler functions receive input events and redraw the contents.}
|
||||||
|
\1\inlinesynopsis{showDialog}[opts]{displays a modal dialog (message, buttons, \dots) and returns the user's choice.}
|
||||||
|
\1\inlinesynopsis{scrollVert, scrollHorz}{compute new cursor and scroll positions for scrollable lists and fields.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{lfs --- Archive Extraction}
|
||||||
|
|
||||||
|
\index{lfs (library)}\code{lfs} extracts \thedos\ Linear File Strip (\code{.lfs}) archives programmatically, transparently inflating any compressed entries.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{extractOne}[archive, filename]{extracts a single named entry and returns its file descriptor (under \code{\$:\rs{}TMP}).}
|
||||||
|
\1\inlinesynopsis{extractAll}[archive]{unpacks the whole archive and returns the directory descriptor.}
|
||||||
|
\end{outline}
|
||||||
|
Both take an optional \code{autoDecompress} flag (default true) and require a relative-path archive.
|
||||||
|
|
||||||
|
\section{mload --- Bulk File Loading}
|
||||||
|
|
||||||
|
\index{mload (library)}\code{mload} is a single function for packaged apps that need to pre-load resources into memory. Given an array of absolute paths, it loads each into memory and returns an array of the corresponding pointers (or \code{null} where a file could not be loaded).
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let mload = require("mload")
|
||||||
|
let [fontPtr, sheetPtr] = mload(["A:/app/font.bin", "A:/app/sheet.bin"])
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{seqread --- Sequential Disk Reading}
|
||||||
|
|
||||||
|
\index{seqread (library)}\code{seqread} reads a file from a serial-connected disk drive as a stream, which is more efficient than random access for whole-file consumption (media players, archive readers).
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{prepare}[fullPath]{opens a file for sequential reading.}
|
||||||
|
\1\inlinesynopsis{readBytes}[length, ptr]{reads bytes into memory (allocating a buffer if no pointer is given).}
|
||||||
|
\1\inlinesynopsis{readInt, readShort, readOneByte, readFourCC, readString}{read typed values from the stream.}
|
||||||
|
\1\inlinesynopsis{skip}[n]{skips ahead; \code{seek}/\code{rewind} reposition the stream.}
|
||||||
|
\1\inlinesynopsis{getReadCount}[]{the number of bytes read so far.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{seqreadtape --- Sequential Tape Reading}
|
||||||
|
|
||||||
|
\index{seqreadtape (library)}\code{seqreadtape} is the counterpart to \code{seqread} for high-speed tape devices, addressed as \code{\$:\rs{}TAPE0}\dots\code{\$:\rs{}TAPE3}. It reads in large chunks rather than being limited to the serial block size, and exposes the same reading interface (\code{prepare}, \code{readBytes}, \code{readInt}, \dots, \code{skip}, \code{seek}, \code{rewind}) plus \code{close}, \code{isReady} and \code{getCurrentTapeDevice}.
|
||||||
|
|
||||||
|
\section{font --- Character ROM}
|
||||||
|
|
||||||
|
\index{font (library)}\code{font} replaces the displayed character set by uploading a font file into the graphics adapter's character ROM.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{setLowRom}[fullPath]{loads characters 0--127 from a font file.}
|
||||||
|
\1\inlinesynopsis{setHighRom}[fullPath]{loads characters 128--255 from a font file.}
|
||||||
|
\1\inlinesynopsis{resetLowRom, resetHighRom}[]{restore the default character set.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{keysym --- Key Symbol Constants}
|
||||||
|
|
||||||
|
\index{keysym (library)}\code{keysym} is a table of named key codes (\code{A}, \code{ENTER}, \code{UP}, \code{SHIFT\_LEFT}, \dots) for use with the \code{input} library's events. Note that these are the keycodes carried by \code{input.withEvent} events, which differ from the character codes returned by \code{con.getch}.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let key = require("keysym")
|
||||||
|
input.withEvent((e) => {
|
||||||
|
if (e[0] === "key_down" && e.includes(key.ESCAPE)) quit()
|
||||||
|
})
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{tbas --- Terran BASIC Runtime}
|
||||||
|
|
||||||
|
\index{tbas (library)}\code{tbas} is the runtime support library for compiled Terran BASIC programs. It supplies the BASIC built-in functions (\code{PRINT}, \code{SIN}, \code{LEFT}, \dots) and the helpers the BASIC compiler emits. It is loaded automatically by a compiled program and is not normally used by hand; it is documented here only for completeness.
|
||||||
|
|
||||||
|
\section{synopsis --- Command Synopsis Loading}
|
||||||
|
|
||||||
|
\index{synopsis (library)}\code{synopsis} loads and caches TSF \code{.synopsis} documents (see the \emph{Command Synopsis Format} chapter) and answers the questions the shell and the \code{synopsis} command ask of them. It resolves a command name to its document --- an app's sits beside the executable, a coreutil's in \code{A:\rs{}tvdos\rs{}synopsis} --- parses it into a model, and caches the result both in memory and on disk.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{getCompletion}[command, priorArgs, word]{returns the completion candidates for the word being typed: option flags, enum values, subcommand names, and whether the shell should additionally offer filesystem entries.}
|
||||||
|
\1\inlinesynopsis{getModel}[command]{the parsed model --- summary, options, positional arguments, subcommands and constraints --- or null when the command has no synopsis.}
|
||||||
|
\1\inlinesynopsis{getSummary}[command]{the one-line summary, or null.}
|
||||||
|
\1\inlinesynopsis{getUsage}[command]{a generated usage string, such as \code{cp SOURCE DEST}.}
|
||||||
|
\1\inlinesynopsis{resolveSynopsisPath}[command]{the full path of the command's synopsis document, or null.}
|
||||||
|
\1\inlinesynopsis{registerProvider}[name, fn]{registers an \code{internal} completion provider that \code{fn(word)} supplies candidates for; \code{commands} and \code{envvars} are built in.}
|
||||||
|
\1\inlinesynopsis{clearCache}[]{drops the in-memory caches.}
|
||||||
|
\end{outline}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user