mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 05:28:31 +09:00
Compare commits
14 Commits
10e577699f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27a01dca6 | ||
|
|
35263eeaa4 | ||
|
|
d223adda25 | ||
|
|
a9d095e3cb | ||
|
|
dad345c027 | ||
|
|
2045da0286 | ||
|
|
3362a6b732 | ||
|
|
038db60b59 | ||
|
|
1d3b5ce8aa | ||
|
|
9e8af96c32 | ||
|
|
43e5baadf4 | ||
|
|
f863f6230d | ||
|
|
d8ac08162c | ||
|
|
e24870ce07 |
117
CLAUDE.md
117
CLAUDE.md
@@ -165,6 +165,14 @@ Peripheral memories can be accessed using `vm.peek()` and `vm.poke()` functions,
|
||||
|
||||
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
|
||||
|
||||
## 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 Movie Formats
|
||||
@@ -428,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
|
||||
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
||||
- Incorrect stereo imaging and extreme side channel distortion
|
||||
- Severe frequency response errors that manifest as "clipping-like" distortion
|
||||
|
||||
## Virtual Consoles (vtmgr)
|
||||
|
||||
Linux-style virtual consoles for TVDOS: up to 6 independent shell sessions,
|
||||
switched with **Alt-1..Alt-6** or the **`chvt N`** builtin, **Alt-0** to exit.
|
||||
Implemented entirely in JS — **no tsvm_core changes**.
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Dispatcher**: `assets/disk0/tvdos/sbin/vtmgr.js`. Launched directly by the
|
||||
`TVDOS.SYS` boot block (only when `!_TVDOS_IS_VT_PANE`); when it exits (Alt-0)
|
||||
the boot block runs `AUTOEXEC.BAT` as the bare fallback shell. Owns the
|
||||
physical keyboard and screen. Each VT runs in its own GraalVM context/thread
|
||||
via the existing `parallel.spawnNewContext` / `attachProgram` / `launch` API
|
||||
(see `VMJSR223Delegate.kt` `class Parallel`). VT 1 spawns at boot; VT 2-6 are
|
||||
lazy-spawned on first switch and re-spawned if their shell exits.
|
||||
- **Concurrency model**: truly concurrent — switching works mid-command, not
|
||||
just at the prompt. Background panes keep running (no `Thread.suspend`; it is
|
||||
unusable on JDK 21). A cooperative gate inside the shimmed `con.getch` parks
|
||||
panes blocked on input; CPU-bound background panes are allowed to run.
|
||||
- **Shared memory**: one `sys.malloc` region holds a control block (active VT,
|
||||
switch request, debounce, spawned-bits) plus, per VT, an input ring buffer and
|
||||
a 7682-byte text-plane buffer mirroring the GPU text-area layout
|
||||
(cursor 2 + fore 2560 + back 2560 + char 2560).
|
||||
- **Compositor** (30 Hz): blits the active VT's text plane to the physical GPU
|
||||
text area via `sys.memcpy`, and pushes that VT's cursor-visibility into the GPU
|
||||
blink bit (MMIO attribute byte 6, addressed at `-1 - (131072*gpuSlot + 6)`).
|
||||
- **Boot config split (`commandrc` + `AUTOEXEC.BAT`)**: environment setup and
|
||||
app-launch are split into two files so panes can replay one without the other.
|
||||
`\commandrc` holds the `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD) and is
|
||||
run by the `TVDOS.SYS` boot block in **every** context (boot and pane) — it has
|
||||
no `.BAT` extension, so the boot block runs it line-by-line (`set` mutates the
|
||||
shared `_TVDOS.variables`, so the effect persists). `\AUTOEXEC.BAT` is the
|
||||
**per-console launch** script (Korean IME `tvdos/i18n/korean`, then
|
||||
`command -fancy`); it is run once per console — by each pane's bootstrap, and
|
||||
by the boot block as the post-vtmgr fallback. No env snapshot/replay anymore;
|
||||
each pane gets PATH/KEYBOARD/etc. natively from `commandrc`, and Korean IME
|
||||
(a per-context `unicode.uniprint` handler) now registers in every pane.
|
||||
- **Per-pane bootstrap**: each pane re-evals `TVDOS.SYS` (with `_TVDOS_IS_VT_PANE`
|
||||
set — which makes the boot block run `commandrc` but skip the vtmgr/AUTOEXEC
|
||||
launch — and a `_BIOS` stub captured live from the main context) then runs
|
||||
`command -c \AUTOEXEC.BAT`, all in ONE direct `eval` so the launcher shares
|
||||
scope with `_TVDOS`/`files`/`execApp`.
|
||||
|
||||
### Output/input shimming (in the pane bootstrap)
|
||||
|
||||
`con` and the global `print`/`println` family are plain JS, so the bootstrap
|
||||
overrides them to read/write the per-VT shared-memory buffers instead of the
|
||||
physical GPU. **`sys` and `graphics` are host objects and CANNOT be overridden
|
||||
from JS** — this is the key constraint that shapes everything below.
|
||||
|
||||
- The shimmed `print` is a faithful JS port of the GPU's TTY interpreter
|
||||
(`GlassTty.acceptChar` + `GraphicsAdapter` handlers): control bytes, the
|
||||
`\x84<decimal>u` "emit char by code" escape (used by `con.prnch`), CSI cursor
|
||||
moves / erase / SGR colours, and the `?25` cursor-visibility private sequence.
|
||||
A swallow-only parser is NOT enough — TVDOS apps drive the screen through
|
||||
these `print` escapes.
|
||||
- `con.move`/`con.getyx` are **1-based** (mirroring `graphics.setCursorYX`'s
|
||||
`cx-1` and `getCursorYX`'s `cx+1`); `con.addch` does NOT advance the cursor
|
||||
(matches `graphics.putSymbol`), while `con.prnch` DOES.
|
||||
- `command.js`'s `shell.execute` reassigns the global print family to
|
||||
`shell.stdio.out.*`, which call `sys.print` (→ physical GPU). `shell.stdio.out`
|
||||
was made to delegate to a `globalThis.__VT_OUT` hook when present (set by the
|
||||
bootstrap); outside a VT the hook is absent and the path is byte-identical.
|
||||
|
||||
### Direct-VRAM apps need a VT-aware base (the `vaddr` pattern)
|
||||
|
||||
Apps that write the text area directly via `graphics.getGpuMemBase()` (rather
|
||||
than `con.*`/`print`) bypass the shims and paint the physical screen, invading
|
||||
whatever VT is visible. They must resolve text-area byte `m` through a
|
||||
VT-aware base:
|
||||
|
||||
```js
|
||||
// physical: backward (byte m at gpuBase - m) — getDev inverts to forward-native
|
||||
// VT pane: forward (byte m at VT_TEXT_PLANE + m, the pane buffer the compositor blits)
|
||||
const VT = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||
const VRAM_BASE = VT ? globalThis.VT_TEXT_PLANE : (graphics.getGpuMemBase() - 253950)
|
||||
const VRAM_SGN = VT ? 1 : -1
|
||||
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
|
||||
```
|
||||
|
||||
`sys.memcpy`/`sys.pokeBytes` copy forward in the resolved native memory, so this
|
||||
works for both directions. The physical branch is identical to the original
|
||||
arithmetic (no regression outside vtmgr). Applied so far in
|
||||
`assets/disk0/tvdos/bin/taut.js` and `assets/disk0/hopper/include/aa.mjs`
|
||||
(used by `bb.js`). Any future direct-VRAM app needs the same one-line `vaddr`.
|
||||
|
||||
### Files
|
||||
|
||||
- New: `assets/disk0/tvdos/sbin/vtmgr.js` (dispatcher + per-pane bootstrap)
|
||||
- `assets/disk0/tvdos/bin/command.js`: `chvt` builtin, `[N]` prompt prefix for
|
||||
VT 2-6, `shell.stdio.out` → `__VT_OUT` delegation
|
||||
- `assets/disk0/tvdos/TVDOS.SYS`: boot block runs `\commandrc` (env) in every
|
||||
context, then — only when `!_TVDOS_IS_VT_PANE` — launches `tvdos/sbin/vtmgr`
|
||||
and, on its exit, `\AUTOEXEC.BAT` as the fallback shell
|
||||
- `assets/disk0/commandrc`: env-only `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD)
|
||||
- `assets/disk0/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`)
|
||||
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
|
||||
`vaddr` VT-aware direct-VRAM addressing
|
||||
|
||||
### Gotcha: injectIntChk vs. embedded source
|
||||
|
||||
`execApp`/`require` run a program's source through `injectIntChk` (TVDOS.SYS),
|
||||
which sed-rewrites the **first** `while`/`for`/`do` of each kind to call a
|
||||
per-exec `tvdosSIGTERM_<hash>()` SIGTERM check. When vtmgr embeds the pane
|
||||
bootstrap as a string literal, one of those rewrites can land inside the literal
|
||||
— and the pane context has no such symbol. vtmgr strips them from the bootstrap
|
||||
string with `raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')`. Any future
|
||||
code that builds executable source as a string literal must do the same.
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
# Taud Tracker Effect Command Reference
|
||||
|
||||
Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine must implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers.
|
||||
Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine **MUST** implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers.
|
||||
|
||||
## Conformance language
|
||||
|
||||
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and **OPTIONAL** in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals and bold. Lowercase uses of these words carry their ordinary English meaning and impose no normative requirement.
|
||||
|
||||
In short:
|
||||
|
||||
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
|
||||
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
|
||||
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
|
||||
|
||||
The "Plain" paragraph of each effect description is non-normative tutorial text; the **Compatibility** and **Implementation** paragraphs carry the normative requirements, expressed through the keywords above.
|
||||
|
||||
---
|
||||
|
||||
## 0. Tracker Terminologies
|
||||
## 0. Tracker terminologies
|
||||
|
||||
This manual extensively uses "tracker lingo" that may not sound intuitive to the modern DAW users. This section covers some of the tracker lingo to get the concepts better understood for those who have never used trackers.
|
||||
|
||||
@@ -16,7 +28,7 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
|
||||
|
||||
* **Row.** One horizontal slot within a pattern, at most one note event per channel. A row's duration is `speed × tick_duration` — see Speed and Tempo below.
|
||||
|
||||
* **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one ticks for operation, and **they will not get applied when tick rate is set to 1.**
|
||||
* **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one tick for operation, and **MUST NOT** be applied when the tick rate is set to 1.
|
||||
|
||||
* **Speed vs. Tempo.** Two independent timing knobs. **Speed** (effect A) is the number of ticks per row; **tempo** (effect T) sets the duration of one tick, conventionally expressed as BPM. To slow the song globally without changing how often per-tick effects update, lower the tempo. To give per-tick effects more iterations per row (denser vibrato, longer slides per row), raise the speed. The default is speed 6, tempo $64 → 125 BPM → 50 Hz tick rate → 120 ms per row.
|
||||
|
||||
@@ -34,7 +46,7 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
|
||||
|
||||
* **Note off, note cut, note fade.** Three distinct ways a note ends. **Note cut** (`^^^` or S$Cx) silences instantly. **Note off** (`===` or an NNA = NoteOff) releases the sustain loop and lets the volume envelope's release segment play out, then fades. **Note fade** keeps the sustain loop running but begins the fadeout decay — for soft tail-offs that still sound sustained.
|
||||
|
||||
* **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background **ghost** voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips.
|
||||
* **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background *ghost* voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips.
|
||||
|
||||
* **Portamento.** Automatic pitch glide toward a target note (effect G). A row carrying both a note *and* a G does **not** re-trigger the sample; instead the note becomes the target and the already-sounding sample slides into it. Distinct from generic pitch slides (E/F), which move pitch by a fixed amount per tick with no target.
|
||||
|
||||
@@ -52,15 +64,15 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
|
||||
|
||||
## 1. Sound device
|
||||
|
||||
- **Bit depth:** 8-bit unsigned throughout, including the final mixdown.
|
||||
- **Sample rate:** fixed at 32000 Hz.
|
||||
- **Output channels:** strictly stereo; the mix bus always produces a two-channel frame even for mono-source samples.
|
||||
- **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. 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 **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
|
||||
|
||||
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)
|
||||
@@ -83,13 +95,13 @@ Commonly used intervals in Taud units are listed below; all are rounded to the n
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
@@ -127,7 +139,7 @@ Most effects recall their last non-zero argument when re-issued with $0000. Unli
|
||||
|
||||
Every other memory-carrying effect (D, I, J, K, L, N, O, P, Q, and others) has a private slot.
|
||||
|
||||
**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
|
||||
|
||||
@@ -143,7 +155,7 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
||||
|
||||
**Compatibility.** ST3 `Axx` maps one-to-one: Taud `A $xx00`. ST3 `A00` is a no-op; Taud `A $0000` is likewise ignored. ProTracker `Fxx` with `xx < $20` maps to Taud `A $xx00`; `Fxx` with `xx ≥ $20` maps to T instead (see T).
|
||||
|
||||
**Implementation.** If the high byte is non-zero, write it to `ticks_per_row`; the low byte is reserved and must be zero. The change takes effect from the row on which the A command appears. There is no memory for A.
|
||||
**Implementation.** If the high byte is non-zero, the engine **MUST** write it to `ticks_per_row`; the low byte is reserved and **MUST** be zero. The change takes effect from the row on which the A command appears. There is no memory for A.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,11 +163,11 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
||||
|
||||
**Plain.** Finishes the current row, then continues playback at row 0 of the pattern at cue position $xxyy. Use this to create song-level jumps, loops, or branching structures.
|
||||
|
||||
**Compatibility.** ST3 `Bxx` jumps to an 8-bit cue and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs may have up to $10000 cue entries.
|
||||
**Compatibility.** ST3 `Bxx` jumps to an 8-bit cue and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs **MAY** have up to $10000 cue entries.
|
||||
|
||||
**Implementation.** On the last tick of the current row, set the next cue index to the argument and the next row to 0. If the argument exceeds the song length, wrap to the song's defined restart position (cue $0000 by default). Jumps are detected by a visited `(cue, row)` set so that pathological loops do not prevent song-length computation, though they do not interrupt actual playback. There is no memory for B.
|
||||
**Implementation.** On the last tick of the current row, the engine **MUST** set the next cue index to the argument and the next row to 0. If the argument exceeds the song length, the engine **MUST** wrap to the song's defined restart position (cue $0000 by default). Jumps **SHOULD** be detected by a visited `(cue, row)` set so that pathological loops do not prevent song-length computation, though they **MUST NOT** interrupt actual playback. There is no memory for B.
|
||||
|
||||
**Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both fire: B chooses the cue, C chooses the row within that cue. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column takes precedence.
|
||||
**Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both **MUST** fire: B chooses the cue, C chooses the row within that cue. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column **MUST** take precedence.
|
||||
|
||||
---
|
||||
|
||||
@@ -163,9 +175,9 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
|
||||
|
||||
**Plain.** Finishes the current row, then skips ahead to row $xxyy of the **next** pattern in the cue sequence.
|
||||
|
||||
**Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes should clamp to row 0 on import. When exporting back to ST3, encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63.
|
||||
**Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, converters **MUST** decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes **SHOULD** clamp to row 0 on import. When exporting back to ST3, converters **MUST** encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63.
|
||||
|
||||
**Implementation.** On the last tick of the current row, advance the cue index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, start the destination pattern at row 0. There is no memory for C.
|
||||
**Implementation.** On the last tick of the current row, the engine **MUST** advance the cue index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, the engine **MUST** start the destination pattern at row 0. There is no memory for C.
|
||||
|
||||
---
|
||||
|
||||
@@ -237,9 +249,9 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
|
||||
- **MONOTONE source** (Taud `ff = 2`):
|
||||
- MONOTONE `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:
|
||||
|
||||
@@ -299,10 +311,10 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
|
||||
|
||||
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
|
||||
|
||||
- `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).
|
||||
|
||||
**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.**
|
||||
|
||||
@@ -464,9 +476,9 @@ The `tick_within_row mod 3` counter resets every row start (so every row begins
|
||||
|
||||
## K $xy00 — Dual: vibrato continuation and volume slide $xy
|
||||
|
||||
**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:
|
||||
|
||||
@@ -500,9 +512,9 @@ The slide writes the per-note axis (same as D); `channel_vol` is untouched. K ha
|
||||
|
||||
## L $xy00 — Dual: tone portamento continuation and volume slide $xy
|
||||
|
||||
**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:
|
||||
|
||||
@@ -535,7 +547,7 @@ The slide writes the per-note axis (same as D); `channel_vol` is untouched. L ha
|
||||
|
||||
**Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would.
|
||||
|
||||
**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.**
|
||||
|
||||
@@ -651,9 +663,9 @@ The volume modifier table, **computed with arithmetic (no LUT)**, is:
|
||||
| 6 | vol × 2 / 3 | E | vol × 3 / 2 |
|
||||
| 7 | vol × 1 / 2 | F | vol × 2 |
|
||||
|
||||
Multiplicative cases use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results clip to $00..$3F after.
|
||||
Multiplicative cases **MUST** use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results **MUST** clip to $00..$3F after.
|
||||
|
||||
A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule.
|
||||
A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) **MUST NOT** be retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule.
|
||||
|
||||
---
|
||||
|
||||
@@ -661,7 +673,7 @@ A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not r
|
||||
|
||||
**Plain.** Modulates volume with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $4x.
|
||||
|
||||
**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:
|
||||
|
||||
@@ -691,7 +703,7 @@ Taud splits T by which byte carries the value:
|
||||
|
||||
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $19`: Taud byte $00 → 25 BPM, $64 → 125 BPM (default), $FF → 280 BPM.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -701,7 +713,7 @@ ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx − $19)00`; `Fxx` with
|
||||
|
||||
**Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops.
|
||||
|
||||
**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.**
|
||||
|
||||
@@ -731,9 +743,9 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
|
||||
|
||||
**Plain.** Sets the global mix bus volume (0..$FF). $00 is silence; $FF is full. The default is $80.
|
||||
|
||||
**Compatibility.** ST3's global volume is 0..$40; convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40. IT's global volume is 0..$80; convert with `taud_v = it_v × 2`, clamped at $FF. On IT, the very first `V 00` command must be resolved as the song's initial global volume.
|
||||
**Compatibility.** ST3's global volume is 0..$40; converters **MUST** convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40. IT's global volume is 0..$80; converters **MUST** convert with `taud_v = it_v × 2`, clamped at $FF. On IT, the very first `V 00` command **MUST** be resolved as the song's initial global volume.
|
||||
|
||||
**Implementation.** Write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud follows suit, but new Taud compositions should avoid muting channels that carry global effects.
|
||||
**Implementation.** The engine **MUST** write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud **MUST** follow suit, but new Taud compositions **SHOULD NOT** mute channels that carry global effects.
|
||||
|
||||
---
|
||||
|
||||
@@ -761,7 +773,7 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
|
||||
|
||||
**Plain.** Modulates panning with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $5x.
|
||||
|
||||
**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:
|
||||
|
||||
@@ -792,7 +804,7 @@ Boundary rules:
|
||||
- The block stops at the end of the pattern: a ditto whose nominal span would overflow the pattern's row count clips silently at the final row.
|
||||
- `$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.
|
||||
- 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.
|
||||
|
||||
@@ -872,7 +884,7 @@ Effect dispatch sees the synthesised effect, never the literal `7` opcode of the
|
||||
- `8 $0000` disables both stages and resets the shared clipping mode to clamp.
|
||||
- `8 $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:
|
||||
|
||||
@@ -931,7 +943,7 @@ The voice-FX state is preserved verbatim by the NNA-ghost copier, so the post-NN
|
||||
|
||||
## 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.
|
||||
- **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
|
||||
@@ -975,7 +987,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
||||
|
||||
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects that modifies global behaviour)
|
||||
|
||||
**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.
|
||||
|
||||
@@ -983,7 +995,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
||||
|
||||
## S $1x00 — PT/ST3/IT Glissando control
|
||||
|
||||
**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.
|
||||
|
||||
@@ -995,7 +1007,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
|
||||
|
||||
**Plain.** Overrides the current note's fine-tune by applying a fixed 4096-TET offset. The index `$x` selects one of sixteen predefined pitch offsets, following ScreamTracker 3's Hz-based fine-tune table but expressed directly in Taud units. This command is implemented for ST3 compatibility.
|
||||
|
||||
**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 |
|
||||
|---|---|---|
|
||||
@@ -1140,7 +1152,7 @@ The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or it
|
||||
|
||||
**Compatibility.** ST3 `SBx` maps directly. ProTracker `E6x` maps to Taud `S $Bx00`.
|
||||
|
||||
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).
|
||||
|
||||
@@ -1162,7 +1174,7 @@ on row event (S $Bx00):
|
||||
on pattern change: loop_start_row = 0; loop_count = 0
|
||||
```
|
||||
|
||||
The crucial bug fix relative to ST3: the loop-counter decrement happens **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine treats the whole delay group as a single visit. Implement this by gating the SBx decrement on `pattern_delay_repetition == 0`.
|
||||
The crucial bug fix relative to ST3: the loop-counter decrement **MUST** happen **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine **MUST** treat the whole delay group as a single visit. Engines **SHOULD** implement this by gating the SBx decrement on `pattern_delay_repetition == 0`.
|
||||
|
||||
---
|
||||
|
||||
@@ -1172,7 +1184,7 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
|
||||
|
||||
**Compatibility.** ST3 `SCx` maps directly. ProTracker `ECx` also maps directly. ST3 ignores `SC0` (treats it as no cut at all); Taud preserves this.
|
||||
|
||||
**Implementation.** On tick `$x`, set `output_volume = 0` but leave `base_volume` unchanged. If `$x ≥ speed`, the cut never fires. If `$x == 0`, the command is ignored. Set the `note_was_cut` flag so a later Q retrigger on the same row is suppressed.
|
||||
**Implementation.** On tick `$x`, the engine **MUST** set `output_volume = 0` but **MUST** leave `base_volume` unchanged. If `$x ≥ speed`, the cut **MUST NOT** fire. If `$x == 0`, the command **MUST** be ignored. The engine **MUST** set the `note_was_cut` flag so that a later Q retrigger on the same row is suppressed.
|
||||
|
||||
---
|
||||
|
||||
@@ -1180,9 +1192,9 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
|
||||
|
||||
**Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues.
|
||||
|
||||
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed). Taud discards those notes. If such note events have been encountered during conversion, they must be corrected on the converter.
|
||||
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note **MUST NOT** play on this row and **MUST NOT** carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed); Taud **MUST** discard those notes. If such note events have been encountered during conversion, they **MUST** be corrected by the converter.
|
||||
|
||||
**Implementation.** On row parse, defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger re-fires at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than current tick speed, the note must be discarded (see compatibility notes above)
|
||||
**Implementation.** On row parse, the engine **MUST** defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, the engine **MUST** execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger **MUST** re-fire at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than the current tick speed, the note **MUST** be discarded (see compatibility notes above).
|
||||
|
||||
---
|
||||
|
||||
@@ -1190,7 +1202,7 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
|
||||
|
||||
**Plain.** Repeats the current row `$x` additional times (so `$x = 0` means no repeat and the row plays once; `$x = 3` means the row plays four times total). Notes do not retrigger across repetitions, but per-tick effects re-run and tick-0 events (fine slides, delayed notes) re-fire on each repetition.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -1202,7 +1214,7 @@ Q retrigger counters do **not** reset between SEx repetitions.
|
||||
|
||||
**Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -1224,7 +1236,7 @@ on sample byte read during loop playback:
|
||||
output_byte = raw_byte
|
||||
```
|
||||
|
||||
`S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` resets to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` persist across notes. The `funk_mask` itself is **only cleared on cue-start reset** (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay always reproduces the same audio without needing to reload the song from disk.
|
||||
`S $F000` **MUST** clear `funk_accumulator` but **MUST** leave `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` **MUST** reset to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` **MUST** persist across notes. The `funk_mask` itself **MUST** be cleared only on cue-start reset (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay **MUST** reproduce the same audio without needing to reload the song from disk.
|
||||
|
||||
---
|
||||
|
||||
@@ -1239,7 +1251,7 @@ Each cell carries a 6-bit value field plus a 2-bit selector field for the volume
|
||||
|
||||
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M).
|
||||
|
||||
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**
|
||||
|
||||
@@ -1254,7 +1266,7 @@ The panning column uses the same 6-bit value + 2-bit selector layout:
|
||||
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit).
|
||||
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
|
||||
|
||||
NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command takes precedence.
|
||||
NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command **MUST** take precedence.
|
||||
|
||||
---
|
||||
|
||||
@@ -1272,7 +1284,7 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
|
||||
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
|
||||
- ff = 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 = 2: Amiga 500 interpolation.
|
||||
- rrr = 3: Amiga 1200 interpolation.
|
||||
@@ -1326,30 +1338,30 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
|
||||
|
||||
These quirks of ST3 are worth preserving or flagging when importing S3M files into Taud:
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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:
|
||||
|
||||
- Clear `linear_slides`. Both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
|
||||
- G (tone portamento) is always converted with `round(× 64/3)` and treated as linear, regardless of mode.
|
||||
- G (tone portamento) **MUST** always be converted with `round(× 64/3)` and treated as linear, regardless of mode.
|
||||
|
||||
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters must remap on both import and export.
|
||||
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters **MUST** remap on both import and export.
|
||||
|
||||
---
|
||||
|
||||
@@ -1361,7 +1373,7 @@ This section documents important implementation details that are not covered by
|
||||
|
||||
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
|
||||
|
||||
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine maintains a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off:
|
||||
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine **MUST** maintain a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off **MUST**:
|
||||
|
||||
```
|
||||
fadeoutVolume -= storedFadeout / 1024.0
|
||||
@@ -1386,7 +1398,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
||||
- `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.
|
||||
**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
|
||||
@@ -1416,7 +1428,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
|
||||
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
|
||||
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
|
||||
|
||||
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it.
|
||||
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player **MUST** write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects **MAY** override it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
echo "Starting TVDOS..."
|
||||
|
||||
rem put set-xxx commands here:
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
||||
set INCLPATH=\hopper\include;$INCLPATH
|
||||
set HELPPATH=\hopper\help;$HELPPATH
|
||||
set KEYBOARD=us_colemak
|
||||
|
||||
rem this line specifies which shell to be presented after the boot precess:
|
||||
rem AUTOEXEC.BAT -- per-console launch script. Run once for every console:
|
||||
rem each virtual-console pane runs it (via vtmgr's bootstrap), and the boot
|
||||
rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
|
||||
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||
rem
|
||||
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||
rem run per-console here rather than once at boot.
|
||||
tvdos/i18n/korean
|
||||
zfm
|
||||
|
||||
rem The interactive shell for this console.
|
||||
command -fancy
|
||||
|
||||
9
assets/disk0/commandrc
Normal file
9
assets/disk0/commandrc
Normal file
@@ -0,0 +1,9 @@
|
||||
rem commandrc -- environment setup, run by TVDOS.SYS in EVERY context
|
||||
rem (the boot shell AND every virtual-console pane). Put `set` commands and
|
||||
rem other env-only configuration here. Do NOT launch apps from this file:
|
||||
rem app launches belong in AUTOEXEC.BAT (run per-console by vtmgr).
|
||||
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
||||
set INCLPATH=\hopper\include;$INCLPATH
|
||||
set HELPPATH=\hopper\help;$HELPPATH
|
||||
set KEYBOARD=us_colemak
|
||||
@@ -1471,9 +1471,40 @@ try {
|
||||
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
||||
}
|
||||
|
||||
// Boot script
|
||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||
// Boot script. The work is split across two files:
|
||||
// \commandrc -- environment (`set` commands); run in EVERY context.
|
||||
// \AUTOEXEC.BAT -- per-console launch (IME + interactive shell).
|
||||
// vtmgr re-evaluates TVDOS.SYS inside each per-VT pane; a pane sets
|
||||
// _TVDOS_IS_VT_PANE so it only replays the environment here and leaves the
|
||||
// AUTOEXEC launch to vtmgr's pane bootstrap (which avoids recursively
|
||||
// spawning vtmgr inside a pane).
|
||||
{
|
||||
let cmdsrc = files.open("A:/tvdos/bin/command.js").sread()
|
||||
let runBatch = (path) => eval(`var _BAT=function(exec_args){${cmdsrc}\n};_BAT`)(["", "-c", path])
|
||||
|
||||
let cmdfile = files.open("A:/tvdos/bin/command.js")
|
||||
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
|
||||
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
|
||||
// Environment first, boot and pane alike. Gives every pane the same
|
||||
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
|
||||
// \commandrc has no .BAT extension (so command.js's batch-file path,
|
||||
// which keys off the extension, won't pick it up); run it line-by-line.
|
||||
// `set` mutates the shared _TVDOS.variables, so the effect persists across
|
||||
// the per-line shell invocations. Skip blanks and `rem` comments.
|
||||
let rcFile = files.open("A:/commandrc")
|
||||
if (rcFile.exists) {
|
||||
rcFile.sread().split('\n').forEach((line) => {
|
||||
let t = line.trim()
|
||||
if (t.length > 0 && !/^rem(\s|$)/i.test(t)) runBatch(line)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof _TVDOS_IS_VT_PANE === "undefined" || !_TVDOS_IS_VT_PANE) {
|
||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||
// Boot console: hand the screen to the virtual-console multiplexer.
|
||||
// When it exits (Alt-0), fall through to AUTOEXEC so the console is
|
||||
// never left bare.
|
||||
runBatch("tvdos/sbin/vtmgr")
|
||||
runBatch("\\AUTOEXEC.BAT")
|
||||
}
|
||||
else {
|
||||
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,18 @@ function makeHash() {
|
||||
const shellID = makeHash()
|
||||
|
||||
function print_prompt_text() {
|
||||
// VT pane indicator: shown for VT 2..6, not VT 1 (the default) so the
|
||||
// unmodified prompt is what users see when they never touch virtual
|
||||
// consoles. VT_NUM is set by vtmgr's pane bootstrap.
|
||||
let vtPrefix = ""
|
||||
if (typeof VT_NUM !== "undefined" && VT_NUM > 1) vtPrefix = "[" + VT_NUM + "] "
|
||||
if (goFancy) {
|
||||
if (vtPrefix) {
|
||||
con.color_pair(161,253)
|
||||
print(`\u00DD${VT_NUM}`)
|
||||
con.color_pair(253,161)
|
||||
con.addch(16);con.curs_right()
|
||||
}
|
||||
con.color_pair(239,161)
|
||||
print(" "+CURRENT_DRIVE+":")
|
||||
con.color_pair(161,253)
|
||||
@@ -49,9 +60,9 @@ function print_prompt_text() {
|
||||
else {
|
||||
// con.color_pair(253,255)
|
||||
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
||||
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
||||
else
|
||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
||||
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,6 +631,21 @@ shell.coreutils = {
|
||||
},
|
||||
panic: function(args) {
|
||||
throw Error("Panicking command.js")
|
||||
},
|
||||
chvt: function(args) {
|
||||
// Request a switch to another virtual console. Only meaningful when
|
||||
// running inside a pane spawned by vtmgr (VT_CTRL_ADDR is set by the
|
||||
// pane bootstrap). Outside that environment this is a no-op error.
|
||||
if (args[1] === undefined) { printerrln("Usage: chvt N (1..6)"); return 1 }
|
||||
let n = parseInt(args[1])
|
||||
if (isNaN(n) || n < 1 || n > 6) { printerrln("chvt: N must be in 1..6"); return 1 }
|
||||
if (typeof VT_CTRL_ADDR === "undefined") {
|
||||
printerrln("chvt: not running under vtmgr (no VT context)"); return 1
|
||||
}
|
||||
// CTRL_SWITCH_REQUEST is byte +1 of the shared CTRL area. Dispatcher
|
||||
// picks this up on its next 30 Hz tick and performs the switch.
|
||||
sys.poke(VT_CTRL_ADDR + 1, n)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
// define command aliases here
|
||||
@@ -636,10 +662,14 @@ shell.coreutils.where = shell.coreutils.which
|
||||
Object.freeze(shell.coreutils)
|
||||
shell.stdio = {
|
||||
out: {
|
||||
print: function(s) { sys.print(s) },
|
||||
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
|
||||
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
|
||||
// When running inside a vtmgr virtual console, __VT_OUT routes output
|
||||
// to the pane's text-plane buffer instead of the physical GPU (which
|
||||
// the compositor would otherwise overwrite). Outside a VT the hook is
|
||||
// absent and these fall through to sys.print exactly as before.
|
||||
print: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.print(s); else sys.print(s) },
|
||||
println: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.println(s); else { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") } },
|
||||
printerr: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerr(s); else sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||
printerrln: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerrln(s); else { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") } },
|
||||
},
|
||||
pipe: {
|
||||
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
||||
@@ -955,6 +985,180 @@ shell.removePipe = function() {
|
||||
Object.freeze(shell)
|
||||
_G.shell = shell
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// TAB AUTOCOMPLETION
|
||||
//
|
||||
// Invoked by TAB at the interactive prompt. Only active when BOTH:
|
||||
// 1. wintex.mjs is available (provides the selection popup), AND
|
||||
// 2. goFancy == true.
|
||||
// One candidate -> expand immediately (no popup).
|
||||
// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to
|
||||
// discard. The popup over-draws the screen without saving
|
||||
// what was beneath it, so we snapshot the text plane before
|
||||
// and copy it back after (the shell can't just redraw like a
|
||||
// full-screen TUI — there's scrollback above the prompt).
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable.
|
||||
let _acWin = undefined
|
||||
function getAutocompleteWin() {
|
||||
if (_acWin !== undefined) return _acWin
|
||||
_acWin = null
|
||||
try {
|
||||
let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs)
|
||||
if (w && typeof w.showDialog === "function") _acWin = w
|
||||
} catch (e) {
|
||||
debugprintln("command.js > autocomplete: wintex unavailable: " + e)
|
||||
}
|
||||
return _acWin
|
||||
}
|
||||
|
||||
// List a directory's entries, swallowing any IO error.
|
||||
function _acListDir(fullPath) {
|
||||
try {
|
||||
let f = files.open(fullPath)
|
||||
if (!f.exists || !f.isDirectory) return []
|
||||
return f.list() || []
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
// Strip a trailing PATHEXT extension so command names show without ".js" etc.
|
||||
function _acStripExt(name) {
|
||||
let lower = name.toLowerCase()
|
||||
let exts = (_TVDOS.variables.PATHEXT || "").split(';').filter(function(e){ return e.length > 0 })
|
||||
for (let i = 0; i < exts.length; i++) {
|
||||
let e = exts[i].toLowerCase()
|
||||
if (lower.endsWith(e)) return name.substring(0, name.length - e.length)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Candidates for the command position (first word, no path separators):
|
||||
// shell built-ins + runnable files found along the current dir, drive root and PATH.
|
||||
function _acCommandCandidates(prefix) {
|
||||
let lower = prefix.toLowerCase()
|
||||
let seen = {}
|
||||
let out = []
|
||||
function add(name) {
|
||||
let k = name.toLowerCase()
|
||||
if (seen[k]) return
|
||||
seen[k] = true
|
||||
out.push({ label: name, value: name + ' ', isDir: false })
|
||||
}
|
||||
|
||||
// shell built-ins (and their aliases)
|
||||
Object.keys(shell.coreutils).forEach(function(k) {
|
||||
if (k.toLowerCase().startsWith(lower)) add(k)
|
||||
})
|
||||
|
||||
// runnable files: search the same places shell.execute does, in the same order
|
||||
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||
.filter(function(e){ return e.length > 0 }).map(function(e){ return e.toLowerCase() })
|
||||
let dirFulls = [shell.resolvePathInput('.').full] // current directory first
|
||||
_TVDOS.getPath().forEach(function(d) {
|
||||
dirFulls.push((d === '' || d === undefined) ? `${CURRENT_DRIVE}:\\` : shell.resolvePathInput(d).full)
|
||||
})
|
||||
dirFulls.forEach(function(full) {
|
||||
_acListDir(full).forEach(function(it) {
|
||||
if (it.isDirectory) return
|
||||
let nameLower = (it.name || '').toLowerCase()
|
||||
if (!exts.some(function(e){ return nameLower.endsWith(e) })) return // only runnables
|
||||
let stripped = _acStripExt(it.name)
|
||||
if (stripped.toLowerCase().startsWith(lower)) add(stripped)
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Candidates for a path argument. The word may carry a directory prefix
|
||||
// (kept verbatim) and a partial basename that we match against the directory.
|
||||
function _acPathCandidates(word) {
|
||||
let sepIdx = Math.max(word.lastIndexOf('\\'), word.lastIndexOf('/'))
|
||||
let dirPart, basePart, listArg
|
||||
if (sepIdx >= 0) {
|
||||
dirPart = word.substring(0, sepIdx + 1) // includes the trailing separator
|
||||
basePart = word.substring(sepIdx + 1)
|
||||
listArg = dirPart
|
||||
} else {
|
||||
dirPart = ''
|
||||
basePart = word
|
||||
listArg = '.'
|
||||
}
|
||||
let resolved = shell.resolvePathInput(listArg)
|
||||
if (resolved === undefined) return []
|
||||
let sep = (dirPart.length > 0 && dirPart.charAt(dirPart.length - 1) === '/') ? '/' : '\\'
|
||||
let lower = basePart.toLowerCase()
|
||||
let out = []
|
||||
_acListDir(resolved.full).forEach(function(it) {
|
||||
let name = it.name || ''
|
||||
if (!name.toLowerCase().startsWith(lower)) return
|
||||
out.push({
|
||||
// directories get a trailing separator so completion can continue into them;
|
||||
// files get a trailing space so the next argument can be typed straight away.
|
||||
label: name + (it.isDirectory ? '\\' : ''),
|
||||
value: dirPart + name + (it.isDirectory ? sep : ' '),
|
||||
isDir: it.isDirectory
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Work out what is being completed at `caret` within `line`.
|
||||
// Returns { wordStart, word, candidates } (candidates sorted by label).
|
||||
function computeCompletion(line, caret) {
|
||||
let wordStart = caret
|
||||
while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1
|
||||
let word = line.substring(wordStart, caret)
|
||||
let isFirstWord = (line.substring(0, wordStart).trim().length === 0)
|
||||
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||
let candidates = (isFirstWord && !hasPathSep) ? _acCommandCandidates(word) : _acPathCandidates(word)
|
||||
candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 })
|
||||
return { wordStart: wordStart, word: word, candidates: candidates }
|
||||
}
|
||||
|
||||
// --- text-plane snapshot/restore (so the popup leaves no artefacts) ---------
|
||||
// In a vtmgr pane the shimmed con/print draw into the pane buffer
|
||||
// (globalThis.VT_TEXT_PLANE, forward layout); on the physical console they
|
||||
// draw into the GPU text area (mapped at getGpuMemBase()-253950). vaddr(0) is
|
||||
// that base in either case; sys.memcpy reads/writes it forward-native.
|
||||
// NOTE: 7681, not the full 7682-byte text area: relPtrInDev() bounds-checks
|
||||
// `from+len` inclusively, so the final byte (bottom-right char cell, never
|
||||
// touched by a centred popup) is unreachable by a single memcpy.
|
||||
const _AC_TEXTAREA_BYTES = 7681
|
||||
let _acTextBase = null
|
||||
let _acScratchPtr = 0
|
||||
function _acTextAreaBase() {
|
||||
if (_acTextBase === null) {
|
||||
_acTextBase = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||
? globalThis.VT_TEXT_PLANE
|
||||
: (graphics.getGpuMemBase() - 253950)
|
||||
}
|
||||
return _acTextBase
|
||||
}
|
||||
function _acSnapshotScreen() {
|
||||
if (_acScratchPtr === 0) _acScratchPtr = sys.malloc(_AC_TEXTAREA_BYTES)
|
||||
sys.memcpy(_acTextAreaBase(), _acScratchPtr, _AC_TEXTAREA_BYTES)
|
||||
}
|
||||
function _acRestoreScreen() {
|
||||
if (_acScratchPtr === 0) return
|
||||
sys.memcpy(_acScratchPtr, _acTextAreaBase(), _AC_TEXTAREA_BYTES)
|
||||
}
|
||||
|
||||
// Modal popup of candidates. Returns the chosen item, or null if discarded.
|
||||
function _acShowPopup(win, candidates) {
|
||||
let res = win.showDialog({
|
||||
title: `Complete (${candidates.length})`,
|
||||
list: {
|
||||
items: candidates,
|
||||
height: Math.min(12, candidates.length),
|
||||
onActivate: function(item, idx, key) { return 'select' }
|
||||
},
|
||||
buttons: [{ label: 'Cancel', action: 'cancel' }]
|
||||
})
|
||||
if (res && res.action === 'select' && res.listItem) return res.listItem
|
||||
return null
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// ensure USERCONFIGPATH directory exists
|
||||
@@ -1012,23 +1216,133 @@ if (goInteractive) {
|
||||
print_prompt_text()
|
||||
|
||||
var cmdbuf = ""
|
||||
var caret = 0 // insertion point within cmdbuf, 0..cmdbuf.length
|
||||
|
||||
// Self-contained line editor with a movable caret (so command.js does
|
||||
// NOT depend on wintex being installed). The prompt has just been
|
||||
// printed, so the current cursor marks where the editable text begins.
|
||||
// We track that anchor and rebuild the on-screen line from it, decoding
|
||||
// line-wrap ourselves so the maths holds in both the physical console
|
||||
// and a vtmgr pane (whose con.move CLAMPS x instead of wrapping it).
|
||||
let [baseY, baseX] = con.getyx() // 1-based
|
||||
let termCols = con.getmaxyx()[1]
|
||||
|
||||
// absolute (y,x) on screen for caret index `idx`
|
||||
function caretPos(idx) {
|
||||
let abs = (baseX - 1) + idx
|
||||
return [baseY + ((abs / termCols) | 0), (abs % termCols) + 1]
|
||||
}
|
||||
function gotoCaret() {
|
||||
let [cy, cx] = caretPos(caret)
|
||||
con.move(cy, cx)
|
||||
}
|
||||
// reprint cmdbuf from index `from` to the end, optionally padding with
|
||||
// `clearTrail` blanks to wipe characters left over by a now-shorter
|
||||
// line, then park the hardware cursor back on the caret.
|
||||
function refresh(from, clearTrail) {
|
||||
let [py, px] = caretPos(from)
|
||||
con.move(py, px)
|
||||
print(cmdbuf.substring(from))
|
||||
for (let i = 0; i < clearTrail; i++) print(" ")
|
||||
gotoCaret()
|
||||
}
|
||||
// replace the whole buffer (used by history recall)
|
||||
function setBuf(next) {
|
||||
let oldLen = cmdbuf.length
|
||||
cmdbuf = next
|
||||
caret = cmdbuf.length
|
||||
refresh(0, Math.max(0, oldLen - cmdbuf.length))
|
||||
}
|
||||
|
||||
// Replace the word [wordStart, caret) with `value`, keeping any text to
|
||||
// the right of the caret, then reprint the line from `wordStart`.
|
||||
function applyCompletion(wordStart, value) {
|
||||
let oldLen = cmdbuf.length
|
||||
cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret)
|
||||
caret = wordStart + value.length
|
||||
con.color_pair(shell.usrcfg.textCol, 255)
|
||||
refresh(wordStart, Math.max(0, oldLen - cmdbuf.length))
|
||||
}
|
||||
|
||||
// TAB handler. No-op unless fancy mode is on and wintex is installed.
|
||||
function tryAutocomplete() {
|
||||
if (!goFancy) return
|
||||
let win = getAutocompleteWin()
|
||||
if (!win) return
|
||||
|
||||
let comp = computeCompletion(cmdbuf, caret)
|
||||
let cands = comp.candidates
|
||||
if (cands.length === 0) return
|
||||
if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return }
|
||||
|
||||
_acSnapshotScreen()
|
||||
let chosen = _acShowPopup(win, cands)
|
||||
_acRestoreScreen()
|
||||
|
||||
// The popup drives input through input.withEvent (physical held-key
|
||||
// state), which bypasses the buffer con.getch reads. Inside a vtmgr
|
||||
// pane the dispatcher keeps draining physical keystrokes into this
|
||||
// pane's input ring the whole time the popup is open, so the navigation
|
||||
// keys (and the closing Enter) would otherwise surface as phantom input
|
||||
// afterwards. Flush them. (On the physical console readKey self-clears,
|
||||
// so this is harmless there.)
|
||||
con.resetkeybuf()
|
||||
|
||||
// The popup hid the caret and clobbered colours; restore the prompt
|
||||
// editing state. The screen content is already back from the snapshot.
|
||||
con.curs_set(1)
|
||||
con.color_pair(shell.usrcfg.textCol, 255)
|
||||
gotoCaret()
|
||||
|
||||
if (chosen) applyCompletion(comp.wordStart, chosen.value)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
let key = con.getch()
|
||||
|
||||
// printable chars
|
||||
if (key >= 32 && key <= 126) {
|
||||
var s = String.fromCharCode(key)
|
||||
cmdbuf += s
|
||||
print(s)
|
||||
let s = String.fromCharCode(key)
|
||||
let atEnd = (caret === cmdbuf.length)
|
||||
cmdbuf = cmdbuf.substring(0, caret) + s + cmdbuf.substring(caret)
|
||||
caret += 1
|
||||
if (atEnd) print(s) // fast path: simple append
|
||||
else refresh(caret - 1, 0)
|
||||
}
|
||||
// backspace
|
||||
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
|
||||
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
|
||||
print(String.fromCharCode(key))
|
||||
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
|
||||
else if (key === con.KEY_TAB) {
|
||||
tryAutocomplete()
|
||||
}
|
||||
// backspace: delete the char to the left of the caret
|
||||
else if (key === con.KEY_BACKSPACE && caret > 0) {
|
||||
cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret)
|
||||
caret -= 1
|
||||
refresh(caret, 1)
|
||||
}
|
||||
// forward delete: delete the char under the caret
|
||||
else if (key === con.KEY_DELETE && caret < cmdbuf.length) {
|
||||
cmdbuf = cmdbuf.substring(0, caret) + cmdbuf.substring(caret + 1)
|
||||
refresh(caret, 1)
|
||||
}
|
||||
// caret left
|
||||
else if (key === con.KEY_LEFT) {
|
||||
if (caret > 0) { caret -= 1; gotoCaret() }
|
||||
}
|
||||
// caret right
|
||||
else if (key === con.KEY_RIGHT) {
|
||||
if (caret < cmdbuf.length) { caret += 1; gotoCaret() }
|
||||
}
|
||||
// jump to start of line
|
||||
else if (key === con.KEY_HOME) {
|
||||
caret = 0; gotoCaret()
|
||||
}
|
||||
// jump to end of line
|
||||
else if (key === con.KEY_END) {
|
||||
caret = cmdbuf.length; gotoCaret()
|
||||
}
|
||||
// enter
|
||||
else if (key === 10 || key === con.KEY_RETURN) {
|
||||
caret = cmdbuf.length; gotoCaret()
|
||||
println()
|
||||
|
||||
errorlevel = shell.execute(cmdbuf)
|
||||
@@ -1044,32 +1358,17 @@ if (goInteractive) {
|
||||
// up arrow
|
||||
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
||||
cmdHistoryScroll += 1
|
||||
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
||||
// re-type the new command
|
||||
print(cmdbuf)
|
||||
|
||||
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||
}
|
||||
// down arrow
|
||||
else if (key === con.KEY_DOWN) {
|
||||
if (cmdHistoryScroll > 0) {
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
||||
// re-type the new command
|
||||
print(cmdbuf)
|
||||
|
||||
if (cmdHistoryScroll > 1) {
|
||||
cmdHistoryScroll -= 1
|
||||
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||
}
|
||||
else {
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = ""
|
||||
else if (cmdHistoryScroll === 1) {
|
||||
cmdHistoryScroll = 0
|
||||
setBuf("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -332,12 +332,6 @@ const AG_BX_TL = 0xC9, AG_BX_TR = 0xBB, AG_BX_BL = 0xC8, AG_BX_BR = 0xBC
|
||||
const AG_BX_V = 0xBA, AG_BX_H = 0xCD
|
||||
const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6
|
||||
|
||||
// Half-block glyphs for wavescope
|
||||
const AG_HB_NONE = 0x20 // ' '
|
||||
const AG_HB_TOP = 0xDF // '▀'
|
||||
const AG_HB_BOT = 0xDC // '▄'
|
||||
const AG_HB_BOTH = 0xDB // '█'
|
||||
|
||||
// Density stairs for visualiser + stereo bar
|
||||
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
|
||||
|
||||
@@ -596,17 +590,358 @@ function ag_analyseHaar() {
|
||||
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: each column shows the range [min, max] of its slice
|
||||
// of the snapshot using half-block characters for 6 vertical sub-positions.
|
||||
// Mid-signal only — for stereo information you read the bottom bar.
|
||||
// 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 samplesPerCol = N / AG_LANE_W
|
||||
// 6 sub-positions: 0..5 from top to bottom.
|
||||
for (let c = 0; c < AG_LANE_W; c++) {
|
||||
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
|
||||
@@ -615,26 +950,27 @@ function ag_drawWavescope() {
|
||||
if (v < mn) mn = v
|
||||
if (v > mx) mx = v
|
||||
}
|
||||
// Map [-1, 1] → [0, 5] (top..bottom). +1 → 0, -1 → 5.
|
||||
let yMax = ((1 - mx) * 0.5 * 6) | 0
|
||||
let yMin = ((1 - mn) * 0.5 * 6) | 0
|
||||
if (yMax < 0) yMax = 0; if (yMax > 5) yMax = 5
|
||||
if (yMin < 0) yMin = 0; if (yMin > 5) yMin = 5
|
||||
// yMax is the top of the bar (smaller y = higher up), yMin is bottom.
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const subTop = row * 2
|
||||
const subBot = row * 2 + 1
|
||||
const hitTop = (yMax <= subTop) && (yMin >= subTop)
|
||||
const hitBot = (yMax <= subBot) && (yMin >= subBot)
|
||||
let g = AG_HB_NONE
|
||||
if (hitTop && hitBot) g = AG_HB_BOTH
|
||||
else if (hitTop) g = AG_HB_TOP
|
||||
else if (hitBot) g = AG_HB_BOT
|
||||
const idx = row * AG_LANE_W + c
|
||||
if (ag_waveGlyph[idx] === g) continue
|
||||
ag_waveGlyph[idx] = g
|
||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_WAVE_TOP + row, AG_COL_INSIDE_L + c, g)
|
||||
// [-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,11 +83,13 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
pos = 8
|
||||
|
||||
// -- 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 numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
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
|
||||
|
||||
if (songIndex < 0 || songIndex >= numSongs) {
|
||||
@@ -155,6 +157,50 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||
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()
|
||||
sys.free(filePtr)
|
||||
|
||||
@@ -217,6 +217,14 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
||||
// action string to close the dialog,
|
||||
// or null to stay open.
|
||||
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
||||
// scrollbarChars: number[6]?, -- glyph codes for the scrollbar:
|
||||
// [troughTopEmpty, troughMidEmpty,
|
||||
// troughBotEmpty, troughTopFilled,
|
||||
// troughMidFilled, troughBotFilled].
|
||||
// Default [0xBA,0xBA,0xBA,0xDB,0xDB,0xDB]
|
||||
// (CP437-safe). Callers with a custom
|
||||
// charset (e.g. taut) pass their own.
|
||||
// drawWell: bool?, -- draw the list background
|
||||
// bg: number?, -- list background colour (default 242).
|
||||
// },
|
||||
//
|
||||
@@ -287,6 +295,8 @@ function showDialog(opts) {
|
||||
: 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
|
||||
@@ -294,17 +304,22 @@ function showDialog(opts) {
|
||||
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 : 243
|
||||
const listBg = (c.listBg != null) ? c.listBg : (drawWell) ? 243 : bg
|
||||
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
||||
|
||||
// List state
|
||||
const list = opts.list || null
|
||||
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
|
||||
@@ -474,17 +489,21 @@ function showDialog(opts) {
|
||||
const sbar = listScrollbarNeeded()
|
||||
|
||||
// Top border (drawField style)
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow, lbCol)
|
||||
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
|
||||
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++) {
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow + 1 + r, lbCol)
|
||||
print('\u00AB')
|
||||
con.move(lbRow + 1 + r, lbCol + lw - 1)
|
||||
print('\u00AA')
|
||||
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)
|
||||
@@ -525,16 +544,20 @@ function showDialog(opts) {
|
||||
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
||||
const maxScroll = Math.max(1, listItems.length - listHeight)
|
||||
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
||||
let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB
|
||||
con.addch(r === indPos ? (trough + 3) : trough)
|
||||
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
|
||||
// filled (thumb) variant over the empty (trough) one.
|
||||
const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1
|
||||
con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg])
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow + 1 + listHeight, lbCol)
|
||||
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
|
||||
con.color_pair(fg, bg)
|
||||
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) {
|
||||
|
||||
561
assets/disk0/tvdos/sbin/vtmgr.js
Normal file
561
assets/disk0/tvdos/sbin/vtmgr.js
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
|
||||
234
it2taud.py
234
it2taud.py
@@ -57,6 +57,7 @@ from taud_common import (
|
||||
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
|
||||
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
|
||||
build_project_data, detect_subsongs,
|
||||
IXMP_PAN_NO_OVERRIDE,
|
||||
)
|
||||
|
||||
|
||||
@@ -435,7 +436,10 @@ class ITInstrument:
|
||||
'vol_env_loop', 'pan_env_loop', 'pf_env_loop',
|
||||
'vol_env_sus', 'pan_env_sus', 'pf_env_sus',
|
||||
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
|
||||
'dct', 'dca')
|
||||
'dct', 'dca', 'keyboard')
|
||||
# keyboard: list[int], 120 entries — keyboard[it_note] = sample_1based (0 = none).
|
||||
# Carried verbatim from the IT file so the Ixmp emitter can build patches that
|
||||
# cover non-canonical-sample note ranges. terranmon.txt "Ixmp" + Schism iti.c:80.
|
||||
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
|
||||
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
|
||||
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
|
||||
@@ -478,6 +482,7 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
|
||||
kb_note = data[ptr + 0x44 + n*2]
|
||||
kb_smp = data[ptr + 0x44 + n*2 + 1]
|
||||
keyboard.append(kb_smp) # 0 = no sample
|
||||
inst.keyboard = keyboard
|
||||
|
||||
# Pick C-5 (note 60) sample; fall back to most-frequent non-zero
|
||||
c5_smp = keyboard[60] if 60 < len(keyboard) else 0
|
||||
@@ -1119,6 +1124,133 @@ def _remap_bc_effects(chunks: list, chunk_map: list,
|
||||
f"subsong boundary; clamped to cue {default_target}")
|
||||
|
||||
|
||||
# ── Ixmp patch builder (multi-sample IT instruments) ─────────────────────────
|
||||
|
||||
def _it_note_to_taud(note: int, clamp_low: bool = False, clamp_high: bool = False) -> int:
|
||||
"""IT note (0..119, C-5 = 60) → Taud 4096-TET noteVal anchored at TAUD_C4.
|
||||
`clamp_low`/`clamp_high` expand the bottom/top of the keyboard to cover the
|
||||
full Taud playable range, so patches at the keyboard's edges don't leave
|
||||
notes outside the trigger rectangle unmatched."""
|
||||
if clamp_low: return 0x0000
|
||||
if clamp_high: return 0xFFFF
|
||||
val = round(TAUD_C4 + (note - 60) * 4096 / 12)
|
||||
return max(0x0020, min(0xFFFF, val))
|
||||
|
||||
|
||||
def _build_it_ixmp_patches(inst, samples, extras_offsets) -> list:
|
||||
"""For one IT instrument, return a list of Ixmp patch dicts covering every
|
||||
keyboard cell that maps to a NON-canonical sample. The canonical sample is
|
||||
served by the base instrument record so no patch is emitted for it (the
|
||||
engine falls through to the base inst when no patch matches).
|
||||
|
||||
Note ranges are contiguous runs of keyboard cells that point at the same
|
||||
sample. Per the Ixmp spec each (pitch_start..pitch_end, volume_start..end)
|
||||
rectangle MUST NOT overlap any other patch on the same instrument; this is
|
||||
guaranteed here because the keyboard mapping itself is a partition."""
|
||||
canonical = inst.canonical_sample
|
||||
kbd = getattr(inst, 'keyboard', None)
|
||||
if not kbd:
|
||||
return []
|
||||
# Distinct non-canonical samples referenced.
|
||||
distinct = []
|
||||
seen = set()
|
||||
for kb_smp in kbd:
|
||||
if kb_smp == 0 or kb_smp == canonical:
|
||||
continue
|
||||
if kb_smp not in seen and 1 <= kb_smp <= len(samples) and samples[kb_smp - 1] is not None:
|
||||
seen.add(kb_smp); distinct.append(kb_smp)
|
||||
if not distinct:
|
||||
return []
|
||||
|
||||
patches = []
|
||||
for smp_1based in distinct:
|
||||
si = smp_1based - 1
|
||||
s = samples[si]
|
||||
if not s.sample_data:
|
||||
continue
|
||||
sample_ptr = extras_offsets.get(('it_smp', si))
|
||||
if sample_ptr is None:
|
||||
continue # not in the pool — bin overflow or corrupt source
|
||||
|
||||
# Per-sample loop / sustain encoding (mirrors build_sample_inst_bin_it).
|
||||
if s.flags & IT_SMP_SUS_LOOP:
|
||||
ls = min(s.sus_beg, 65535); le = min(s.sus_end, 65535)
|
||||
sustain_bit = 0x4
|
||||
pingpong = bool(s.flags & IT_SMP_PINGPONG_SUS)
|
||||
has_loop = True
|
||||
elif s.has_loop:
|
||||
ls = min(s.loop_beg, 65535); le = min(s.loop_end, 65535)
|
||||
sustain_bit = 0x0
|
||||
pingpong = bool(s.flags & IT_SMP_PINGPONG)
|
||||
has_loop = True
|
||||
else:
|
||||
ls = 0; le = 0
|
||||
sustain_bit = 0x0
|
||||
pingpong = False
|
||||
has_loop = False
|
||||
loop_mode = (2 if (has_loop and pingpong) else (1 if has_loop else 0)) | sustain_bit
|
||||
|
||||
# Per-sample default volume / pan / auto-vibrato — mirrors the
|
||||
# use_instruments inst-record path so behaviour is identical when the
|
||||
# patch sample matches what the base instrument would have stored.
|
||||
smp_vol = min(getattr(s, 'vol', 64), 64)
|
||||
dnv = min(255, round(smp_vol * 255 / 64))
|
||||
smp_dfp = getattr(s, 'dfp', 0)
|
||||
default_pan = (min(255, max(0, round((smp_dfp & 0x7F) * 255 / 64)))
|
||||
if (smp_dfp & 0x80) else IXMP_PAN_NO_OVERRIDE)
|
||||
vib_speed_taud = min(255, round(getattr(s, 'av_speed', 0) * 255 / 64))
|
||||
vib_depth_taud = min(255, round(getattr(s, 'av_depth', 0) * 255 / 64))
|
||||
vib_rate_taud = getattr(s, 'av_sweep', 0) & 0xFF
|
||||
vib_wave_taud = getattr(s, 'av_wave', 0) & 0x07
|
||||
|
||||
# Find contiguous IT-note ranges where the keyboard points at this sample.
|
||||
run_start = None
|
||||
for n in range(120):
|
||||
if kbd[n] == smp_1based:
|
||||
if run_start is None:
|
||||
run_start = n
|
||||
else:
|
||||
if run_start is not None:
|
||||
_emit_patch(patches, run_start, n - 1, sample_ptr, s,
|
||||
ls, le, loop_mode, default_pan, dnv,
|
||||
vib_speed_taud, vib_depth_taud, vib_rate_taud, vib_wave_taud)
|
||||
run_start = None
|
||||
if run_start is not None:
|
||||
_emit_patch(patches, run_start, 119, sample_ptr, s,
|
||||
ls, le, loop_mode, default_pan, dnv,
|
||||
vib_speed_taud, vib_depth_taud, vib_rate_taud, vib_wave_taud)
|
||||
return patches
|
||||
|
||||
|
||||
def _emit_patch(patches, it_lo, it_hi, sample_ptr, s,
|
||||
ls, le, loop_mode, default_pan, dnv,
|
||||
vib_speed, vib_depth, vib_rate, vib_wave):
|
||||
"""Append one patch dict covering IT-note range [it_lo, it_hi] inclusive."""
|
||||
taud_lo = _it_note_to_taud(it_lo, clamp_low=(it_lo == 0))
|
||||
taud_hi = _it_note_to_taud(it_hi, clamp_high=(it_hi == 119))
|
||||
patches.append({
|
||||
'pitch_start': taud_lo,
|
||||
'pitch_end': taud_hi,
|
||||
'volume_start': 0,
|
||||
'volume_end': 63,
|
||||
'sample_ptr': sample_ptr,
|
||||
'sample_length': min(s.length, 65535),
|
||||
'play_start': 0,
|
||||
'loop_start': ls,
|
||||
'loop_end': le,
|
||||
'sampling_rate': min(getattr(s, 'c5_speed', 8363), 65535),
|
||||
'sample_detune': 0,
|
||||
'loop_mode': loop_mode,
|
||||
'default_pan': default_pan,
|
||||
'default_note_volume': dnv,
|
||||
'vibrato_speed': vib_speed,
|
||||
'vibrato_sweep': 0, # IT-side; FT2 sweep stays 0
|
||||
'vibrato_depth': vib_depth,
|
||||
'vibrato_rate': vib_rate,
|
||||
'vibrato_waveform': vib_wave,
|
||||
})
|
||||
|
||||
|
||||
# ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
|
||||
|
||||
def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
@@ -1190,13 +1322,29 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
offsets = {}
|
||||
pos = 0
|
||||
# IT use_instruments mode points many Taud instrument slots at the same
|
||||
# underlying sample object (e.g. seven "ChipBass.*" instruments all play
|
||||
# "ChipBass.looped"). Write each distinct sample's PCM into the pool once and
|
||||
# let every referencing slot share the offset, rather than emitting one
|
||||
# identical copy per slot. `pool_order` records the distinct samples in
|
||||
# ascending-offset order — the order taut.js's sample viewer expects SNam to
|
||||
# follow (it dedupes instrument records by (ptr,len), sorts by ptr, and
|
||||
# matches SNam[i+1] positionally — see taut.js buildSampleIndex).
|
||||
written = {} # id(sample) -> pool offset already written
|
||||
pool_order = [] # distinct sample objects, in pool (ascending-offset) order
|
||||
for idx, s in pcm_list:
|
||||
shared = written.get(id(s))
|
||||
if shared is not None:
|
||||
offsets[idx] = shared
|
||||
continue
|
||||
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
|
||||
if n <= 0:
|
||||
vprint(f" warning: sample bin full, dropping '{s.name}'")
|
||||
offsets[idx] = 0; s.length = 0; continue
|
||||
sample_bin[pos:pos+n] = s.sample_data[:n]
|
||||
offsets[idx] = pos
|
||||
written[id(s)] = pos
|
||||
pool_order.append(s)
|
||||
if n < len(s.sample_data):
|
||||
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}")
|
||||
s.length = n
|
||||
@@ -1384,7 +1532,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
|
||||
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio, pool_order
|
||||
|
||||
|
||||
# ── Pattern builder ───────────────────────────────────────────────────────────
|
||||
@@ -1805,6 +1953,10 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
# Pattern cells carry IT instrument numbers; for use_instruments mode, those
|
||||
# are instrument indices; we remap to samples below.
|
||||
# Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order.
|
||||
# Map IT sample (0-based) → IXMP patch dict template used when building the
|
||||
# per-instrument patch list. Populated by the use_instruments branch below.
|
||||
it_sample_patch_meta = {}
|
||||
|
||||
if h.use_instruments:
|
||||
# Build a proxy sample list where Taud inst slot = IT inst index,
|
||||
# resolved to the canonical sample. Slot 0 unused.
|
||||
@@ -1899,16 +2051,60 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
'dct': inst.dct,
|
||||
'dca': inst.dca,
|
||||
}
|
||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
||||
# ── Ixmp: pool keyboard-referenced extra samples beyond slot 255 ───────
|
||||
# IT instruments can map different IT notes to different samples via the
|
||||
# keyboard table (IMPI+0x44). The canonical sample is already in the proxy
|
||||
# at the instrument's Taud slot; extras (any other sample referenced in
|
||||
# the keyboard) get appended past index 256 so build_sample_inst_bin_it
|
||||
# pools them (its inst-record loop skips i >= 256 — see the same file).
|
||||
# We then look up their bin offsets via the returned offsets dict and
|
||||
# emit one Ixmp patch per (sample, contiguous-note-range) pair.
|
||||
extras_keys = [] # ordered list of ('it_smp', si) — index into the proxy is 256 + position
|
||||
for ii, inst in enumerate(instruments):
|
||||
if inst is None: continue
|
||||
canonical = inst.canonical_sample
|
||||
kbd = getattr(inst, 'keyboard', None) or []
|
||||
for kb_smp in kbd:
|
||||
if kb_smp == 0 or kb_smp == canonical:
|
||||
continue
|
||||
si = kb_smp - 1
|
||||
if 0 <= si < len(samples) and samples[si] is not None and samples[si].sample_data:
|
||||
key = ('it_smp', si)
|
||||
if key not in extras_keys:
|
||||
extras_keys.append(key)
|
||||
extras_base = len(proxy)
|
||||
for key in extras_keys:
|
||||
proxy.append(samples[key[1]])
|
||||
|
||||
sampleinst_raw, bin_offsets, sample_ratio, pool_order = build_sample_inst_bin_it(proxy, instr_data_by_slot)
|
||||
# Map ('it_smp', si) → sample-bin offset.
|
||||
extras_offsets = {key: bin_offsets.get(extras_base + j, 0)
|
||||
for j, key in enumerate(extras_keys)}
|
||||
# Also include each canonical sample at its taud-slot offset so the patch
|
||||
# builder can reuse them when an instrument's keyboard cell references the
|
||||
# canonical sample at a non-canonical note range.
|
||||
for ii, inst in enumerate(instruments):
|
||||
if inst is None: continue
|
||||
taud_slot = ii + 1
|
||||
if taud_slot >= 256: continue
|
||||
canon = inst.canonical_sample
|
||||
if canon == 0: continue
|
||||
si = canon - 1
|
||||
if 0 <= si < len(samples) and samples[si] is not None and ('it_smp', si) not in extras_offsets:
|
||||
# Look up the pool offset for the canonical via the proxy slot.
|
||||
if taud_slot in bin_offsets:
|
||||
extras_offsets[('it_smp', si)] = bin_offsets[taud_slot]
|
||||
else:
|
||||
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused)
|
||||
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused).
|
||||
# No instruments in the file → no multi-sample mapping → no Ixmp patches.
|
||||
proxy = [None] + list(samples)
|
||||
inst_vols = {
|
||||
i+1: min(s.vol, 0x3F)
|
||||
for i, s in enumerate(samples)
|
||||
if s is not None
|
||||
}
|
||||
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy)
|
||||
sampleinst_raw, bin_offsets, sample_ratio, pool_order = build_sample_inst_bin_it(proxy)
|
||||
extras_offsets = {}
|
||||
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
@@ -1961,12 +2157,36 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
|
||||
if with_project_data:
|
||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
smp_names = [''] + [(s.name if s is not None else '')
|
||||
for s in samples[:255]]
|
||||
# SNam mirrors the deduplicated sample pool: one entry per distinct
|
||||
# sample, in pool order, named after the sample itself. taut.js dedupes
|
||||
# instrument records by (ptr,len), sorts ascending by ptr, and matches
|
||||
# SNam[i+1] positionally to that list, so this ordering labels every
|
||||
# sample correctly and a shared sample (e.g. "ChipBass.looped") appears
|
||||
# exactly once instead of once per referencing instrument slot.
|
||||
smp_names = [''] + [(getattr(s, 'name', '') or '')
|
||||
for s in pool_order[:255]]
|
||||
|
||||
# Ixmp patches — only the use_instruments branch maps IT notes to multiple
|
||||
# samples; the sample-mode branch has nothing to emit because there's no
|
||||
# keyboard table on a raw IT sample.
|
||||
ixmp_patches = {}
|
||||
if h.use_instruments and extras_offsets:
|
||||
for ii, inst in enumerate(instruments):
|
||||
if inst is None: continue
|
||||
taud_slot = ii + 1
|
||||
if taud_slot >= 256: continue
|
||||
patches = _build_it_ixmp_patches(inst, samples, extras_offsets)
|
||||
if patches:
|
||||
ixmp_patches[taud_slot] = patches
|
||||
if ixmp_patches:
|
||||
vprint(f" ixmp: {sum(len(p) for p in ixmp_patches.values())} "
|
||||
f"patches across {len(ixmp_patches)} instruments")
|
||||
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=inst_names,
|
||||
sample_names=smp_names,
|
||||
ixmp_patches=ixmp_patches or None,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
|
||||
22
s3m2taud.py
22
s3m2taud.py
@@ -138,7 +138,11 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
|
||||
continue
|
||||
inst = S3MInstrument()
|
||||
inst.itype = data[ptr]
|
||||
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
|
||||
# 12-byte DOS filename field; null-terminated with possible trailing
|
||||
# garbage after the terminator (ST3 doesn't zero the tail). Truncate at
|
||||
# the first null. This field carries the per-sample short name (e.g.
|
||||
# 'HIT1') as distinct from the 28-byte title at 0x30.
|
||||
inst.filename = data[ptr+1:ptr+13].split(b'\x00', 1)[0].decode('latin-1', errors='replace')
|
||||
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
|
||||
memseg_hi = data[ptr + 0x0D]
|
||||
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
|
||||
@@ -939,17 +943,21 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# ── Project Data (optional) ──────────────────────────────────────────────
|
||||
# S3M instruments and samples share the same slot space, so the names go
|
||||
# into both INam and SNam (1-based; slot 0 empty).
|
||||
# S3M instruments and samples share the same slot space, but carry two
|
||||
# distinct name fields: the 28-byte title (inst.name → INam) and the
|
||||
# 12-byte DOS filename (inst.filename → SNam). e.g. WHEN.s3m instrument #1
|
||||
# is titled "(c) Purple Motion / 1994" with sample name 'HIT1'.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
sample_names = [''] + [(inst.filename if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=names,
|
||||
sample_names=names,
|
||||
instrument_names=inst_names,
|
||||
sample_names=sample_names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
|
||||
@@ -543,13 +543,93 @@ def _name_table_blob(names) -> bytes:
|
||||
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
|
||||
|
||||
|
||||
# ── Ixmp encoder (terranmon.txt §Project Data → Ixmp) ───────────────────────
|
||||
|
||||
# Per-patch byte layout. Field offsets must match AudioJSR223Delegate.uploadInstrumentPatches
|
||||
# (Kotlin parser) and terranmon.txt "Ixmp. Instrument extra samples".
|
||||
IXMP_PATCH_SIZE = 31
|
||||
IXMP_PAN_NO_OVERRIDE = 0xFF
|
||||
IXMP_DNV_NO_OVERRIDE = 0
|
||||
IXMP_VIBWAVE_NO_OVERRIDE = 0xFF
|
||||
|
||||
|
||||
def encode_ixmp_patch(p: dict) -> bytes:
|
||||
"""Encode a single patch dict into 31 bytes.
|
||||
|
||||
Expected keys (numeric values; defaults are applied for missing optional fields):
|
||||
pitch_start, pitch_end : Taud 4096-TET noteVal (Uint16)
|
||||
volume_start, volume_end : 0..63 (Uint8)
|
||||
sample_ptr : Uint32 (sample bin offset)
|
||||
sample_length : Uint16
|
||||
play_start, loop_start, loop_end : Uint16
|
||||
sampling_rate : Uint16 (same encoding as base inst byte 6-7)
|
||||
sample_detune : Int16, signed 4096-TET (default 0)
|
||||
loop_mode : Uint8 (default 0)
|
||||
default_pan : Uint8, 0xFF = no override (default 0xFF)
|
||||
default_note_volume : Uint8 IT-scaled (0 = no override, default 0)
|
||||
vibrato_speed/sweep/depth/rate: Uint8 (default 0)
|
||||
vibrato_waveform : Uint8 (0..7 or 0xFF for no override, default 0xFF)
|
||||
"""
|
||||
pitch_start = max(0, min(0xFFFF, int(p['pitch_start'])))
|
||||
pitch_end = max(0, min(0xFFFF, int(p['pitch_end'])))
|
||||
vol_start = max(0, min(63, int(p.get('volume_start', 0))))
|
||||
vol_end = max(0, min(63, int(p.get('volume_end', 63))))
|
||||
sample_ptr = int(p['sample_ptr']) & 0xFFFFFFFF
|
||||
sample_len = max(0, min(0xFFFF, int(p['sample_length'])))
|
||||
play_start = max(0, min(0xFFFF, int(p.get('play_start', 0))))
|
||||
loop_start = max(0, min(0xFFFF, int(p.get('loop_start', 0))))
|
||||
loop_end = max(0, min(0xFFFF, int(p.get('loop_end', 0))))
|
||||
rate = max(0, min(0xFFFF, int(p.get('sampling_rate', 0))))
|
||||
detune = max(-0x8000, min(0x7FFF, int(p.get('sample_detune', 0))))
|
||||
return struct.pack(
|
||||
'<BHHBBIHHHHHhBBBBBBBB',
|
||||
1, # patch version
|
||||
pitch_start, pitch_end,
|
||||
vol_start, vol_end,
|
||||
sample_ptr,
|
||||
sample_len,
|
||||
play_start, loop_start, loop_end,
|
||||
rate,
|
||||
detune,
|
||||
int(p.get('loop_mode', 0)) & 0x07,
|
||||
int(p.get('default_pan', IXMP_PAN_NO_OVERRIDE)) & 0xFF,
|
||||
int(p.get('default_note_volume', IXMP_DNV_NO_OVERRIDE)) & 0xFF,
|
||||
int(p.get('vibrato_speed', 0)) & 0xFF,
|
||||
int(p.get('vibrato_sweep', 0)) & 0xFF,
|
||||
int(p.get('vibrato_depth', 0)) & 0xFF,
|
||||
int(p.get('vibrato_rate', 0)) & 0xFF,
|
||||
int(p.get('vibrato_waveform', IXMP_VIBWAVE_NO_OVERRIDE)) & 0xFF,
|
||||
)
|
||||
|
||||
|
||||
def encode_ixmp_payload(patches_by_inst: dict) -> bytes:
|
||||
"""Encode a dict {instrument_id: [patch_dict, ...]} as one Ixmp section payload
|
||||
(the body that follows the FourCC + length header). Instruments are written in
|
||||
ascending id order. Overlapping pitch+volume rectangles within one instrument
|
||||
are INVALID per spec and the caller is responsible for keeping them disjoint."""
|
||||
if not patches_by_inst:
|
||||
return b''
|
||||
out = bytearray()
|
||||
for inst_id in sorted(patches_by_inst):
|
||||
patches = patches_by_inst[inst_id]
|
||||
if not patches:
|
||||
continue
|
||||
out.append(int(inst_id) & 0xFF)
|
||||
cnt = len(patches)
|
||||
out += bytes([cnt & 0xFF, (cnt >> 8) & 0xFF, (cnt >> 16) & 0xFF]) # Uint24 LE
|
||||
for patch in patches:
|
||||
out += encode_ixmp_patch(patch)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_project_data(*, project_name: str = '',
|
||||
author: str = '',
|
||||
copyright_str: str = '',
|
||||
sample_names=None,
|
||||
instrument_names=None,
|
||||
pattern_names=None,
|
||||
song_metadata=None) -> bytes:
|
||||
song_metadata=None,
|
||||
ixmp_patches=None) -> bytes:
|
||||
"""Build the optional PROJECT DATA section payload.
|
||||
|
||||
Returns the full block (8-byte magic + 8 reserved bytes + concatenated
|
||||
@@ -604,6 +684,9 @@ def build_project_data(*, project_name: str = '',
|
||||
smet += payload
|
||||
add(b'sMet', bytes(smet))
|
||||
|
||||
if ixmp_patches:
|
||||
add(b'Ixmp', encode_ixmp_payload(ixmp_patches))
|
||||
|
||||
if not sections:
|
||||
return b''
|
||||
|
||||
|
||||
@@ -2409,8 +2409,10 @@ TODO:
|
||||
[x] expose song table on UI (test with `insaniq2.taud`)
|
||||
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
|
||||
[ ] establish hooks for the interrupts
|
||||
[ ] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
||||
[x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
||||
follow the ImpulseTracker design first, then improve from there
|
||||
[?] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
|
||||
[ ] Needs .it and .xm test file to complete it2taud and xm2taud
|
||||
|
||||
TODO - list of demo songs that MUST ship with Microtone:
|
||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||
@@ -2469,7 +2471,7 @@ Audio Adapter MMIO
|
||||
Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
|
||||
Write 1 to decode the frame as MP2
|
||||
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR — except for the flag 0x11, in which the hardware must initialise then immediately start decoding.
|
||||
|
||||
41 RO: MP2 Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
@@ -2619,6 +2621,17 @@ This is a file format for Taud tracker data. Taud can be extended with Microtone
|
||||
|
||||
Endianness: Little
|
||||
|
||||
# Conformance language
|
||||
(RFC 2119+8174)
|
||||
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
|
||||
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
|
||||
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
|
||||
(IMPLEMENTATION DETAILS)
|
||||
- **INVALID.** Blame the encoder; decoder MUST stop decoding with appropriate errors.
|
||||
- **UNDEFINED BEHAVIOUR.** Encoder MAY encode it; decoder MAY do whatever it wants to, including spawning a daemon out of your nose.
|
||||
- **IGNORED.** Encoder MAY encode it; decoder MUST skip past it.
|
||||
- **RESERVED.** Encoder MUST NOT encode it. Decoder MUST skip past it.
|
||||
|
||||
# File Structure
|
||||
\x1F T S V M a u d
|
||||
[HEADER]
|
||||
@@ -2656,7 +2669,7 @@ Endianness: Little
|
||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||
0b 000 rrr ff
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: RESERVED)
|
||||
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation)
|
||||
Uint8 Song global volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
@@ -2664,7 +2677,7 @@ Endianness: Little
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint32 Compressed size of PATTERN BIN for this song
|
||||
Uint32 Compressed size of CUE SHEET for this song
|
||||
Byte[6] Reserved
|
||||
Byte[6] RESERVED
|
||||
|
||||
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
||||
|
||||
@@ -2686,7 +2699,7 @@ Endianness: Little
|
||||
Project Data is just a concatenation of blocks identified by their FourCC.
|
||||
|
||||
Byte[8] Magic (\x1E T a u d P r J)
|
||||
Byte[8] Reserved
|
||||
Byte[8] RESERVED
|
||||
* Repetition of
|
||||
Byte[4] Title of the section (fourcc)
|
||||
Uint32 Section length
|
||||
@@ -2737,11 +2750,11 @@ prefixes:
|
||||
* Repetition of:
|
||||
Uint8 Notation index (starting from zero) used by songs
|
||||
Uint32 Size of this notation following this field
|
||||
Uint16 Reserved for flags
|
||||
Uint16 RESERVED for flags
|
||||
Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0.
|
||||
Uint16 Reserved for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
|
||||
Uint16 RESERVED for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
|
||||
Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11
|
||||
Byte[8] Reserved
|
||||
Byte[8] RESERVED
|
||||
Byte[*] Name, null terminated. Encoding: UTF-8
|
||||
Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset
|
||||
Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right
|
||||
@@ -2762,6 +2775,45 @@ prefixes:
|
||||
Uint8 Version (Ascii 'a')
|
||||
Bytes Notation definitions (see above)
|
||||
|
||||
* Ixmp. Instrument extra samples
|
||||
* Repetition of:
|
||||
Uint8 Instrument ID
|
||||
Uint24 Count of patches
|
||||
** Repetition of:
|
||||
Uint8 Patch definition version (always 1)
|
||||
Uint16 Pitch start ; Taud 4096-TET noteVal (same scale as pattern-cell note)
|
||||
Uint16 Pitch end (inclusive)
|
||||
Uint8 Volume start ; 0..63
|
||||
Uint8 Volume end (inclusive) ; 0..63
|
||||
- Above four parameters define a rectangle over the Pitch-Volume space. See Notes 4 and 5
|
||||
Uint32 Sample pointer
|
||||
Uint16 Sample length
|
||||
Uint16 Play Start (usually 0 but not always)
|
||||
Uint16 Loop Start (can be smaller than Play Start)
|
||||
Uint16 Loop End
|
||||
Uint16 samplingRate ; per-sample C-5 speed; same encoding as base instrument byte 6-7
|
||||
Int16 sampleDetune ; per-sample fine detune in signed 4096-TET units (XM finetune; IT samples leave 0)
|
||||
Uint8 loopMode ; same encoding as base instrument byte 14 (bits 0-1 = mode, bit 2 = sustain loop)
|
||||
Uint8 defaultPan ; per-sample default pan (0..255; 0x80 = centre); 0xFF = "no override"
|
||||
Uint8 defaultNoteVolume ; per-sample default note volume (0..255 scaled from IT 0..64); 0 = "no override"
|
||||
Uint8 vibratoSpeed ; per-sample auto-vibrato (mirrors base inst byte 175)
|
||||
Uint8 vibratoSweep ; per-sample auto-vibrato (mirrors base inst byte 176)
|
||||
Uint8 vibratoDepth ; per-sample auto-vibrato (mirrors base inst byte 187)
|
||||
Uint8 vibratoRate ; per-sample auto-vibrato (mirrors base inst byte 188)
|
||||
Uint8 vibratoWaveform ; bits 0-2 only (mirrors instrumentFlag bits 2-4); 0xFF = "no override"
|
||||
|
||||
Notes:
|
||||
0. this extension is made to support IT/XM instrument spec as well as partial compatibility to SF2 (Soundfont format two)
|
||||
1. Envelopes (vol/pan/pf), fadeout, NNA / DCT / DCA, pitch-pan, filter, IGV and any other "instrument-scope" parameters all follow the base instrument definition. Only sample-scope parameters (the patch fields listed above) override.
|
||||
2. overlapping regions are considered INVALID
|
||||
3. multiple Ixmp blocks pointing the same instrument are considered INVALID
|
||||
4. IT and XM does not define volumes. Keep the Volume rectangle at 0..63 — the engine clamps to that range when matching.
|
||||
5. SF2 does define volumes (because MIDI). Convert it using `round(velocity * (63/127))`
|
||||
On import, `initialAttenuation`, filters and ADSR shall be ignored
|
||||
6. Patch selection at trigger time walks the patch list in order; the first patch whose rectangle contains the trigger's (noteVal, rowVolume) wins. When no patch matches, the base instrument's sample fields are used unchanged.
|
||||
7. Sentinel values listed above ("no override") let a patch defer to the base instrument for a given field — used by converters that don't carry per-sample data for one of the dimensions (e.g. SF2 ignoring per-sample pan).
|
||||
8. Total per-patch payload is 31 bytes.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
**S3M (ScreamTracker 3) to Taud conversion notes**
|
||||
|
||||
@@ -140,6 +140,47 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
fun getVoiceActive(playhead: Int, voice: Int): Boolean =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.active == true
|
||||
|
||||
/** Active-note counts per instrument id (index 0..255): how many notes are sounding *right
|
||||
* now* for each instrument, counting ~~BOTH~~ the live foreground voices ~~and the NNA background
|
||||
* ghosts in the mixer-private pool~~~. Lets visualisers colour by polyphony. The ghost pool is
|
||||
* mutated by the render thread, so it is read defensively by index and any transient
|
||||
* inconsistency is tolerated (a single best-effort frame). */
|
||||
fun getActiveNoteCounts(playhead: Int): IntArray {
|
||||
val counts = IntArray(256)
|
||||
val ts = getPlayhead(playhead)?.trackerState ?: return counts
|
||||
for (v in ts.voices) {
|
||||
if (v.active) counts[v.instrumentId and 0xFF]++
|
||||
}
|
||||
// disabling NNA for now
|
||||
/*try {
|
||||
val bg = ts.backgroundVoices
|
||||
for (i in 0 until bg.size) {
|
||||
val v = bg.getOrNull(i) ?: continue
|
||||
if (v.active) counts[v.instrumentId and 0xFF]++
|
||||
}
|
||||
} catch (_: Exception) { /* ghost pool mutated mid-read — counts are best-effort */ }
|
||||
*/
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
|
||||
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
|
||||
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
|
||||
fun getVoiceFunkSpeed(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.funkSpeed
|
||||
}
|
||||
|
||||
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
|
||||
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
|
||||
* empty array when the instrument has never been funk-repeated. The render thread mutates the
|
||||
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
|
||||
fun getInstrumentFunkMask(slot: Int): IntArray {
|
||||
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
|
||||
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
|
||||
}
|
||||
|
||||
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
|
||||
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
|
||||
* inactive voices. */
|
||||
@@ -156,6 +197,54 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
return v.instrumentId and 0xFF
|
||||
}
|
||||
|
||||
/** Current sample-frame playback position (fractional double) of the voice. Returns -1.0
|
||||
* when the voice is inactive so visualisers can distinguish "no cursor" from "cursor at 0". */
|
||||
fun getVoiceSamplePos(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1.0
|
||||
if (!v.active) return -1.0
|
||||
return v.samplePos
|
||||
}
|
||||
|
||||
/** Volume-envelope segment index — i.e. the node the voice is currently moving *away* from
|
||||
* (the next node it will hit is index + 1). Returns -1 when inactive. */
|
||||
fun getVoiceEnvVolIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envIndex
|
||||
}
|
||||
/** Seconds elapsed *into* the current volume-envelope segment (0 ≤ t < segment.offset). */
|
||||
fun getVoiceEnvVolTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envTimeSec
|
||||
}
|
||||
|
||||
/** Pan-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPanIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPanIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pan-envelope segment. */
|
||||
fun getVoiceEnvPanTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPanTimeSec
|
||||
}
|
||||
|
||||
/** Pitch/filter-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPitchIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPfIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pitch/filter-envelope segment. */
|
||||
fun getVoiceEnvPitchTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPfTimeSec
|
||||
}
|
||||
|
||||
/** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */
|
||||
fun setTrackerRow(playhead: Int, row: Int) {
|
||||
getPlayhead(playhead)?.trackerState?.let { ts ->
|
||||
@@ -176,6 +265,64 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload an Ixmp "extra samples" block for instrument [slot] (0-255). The payload is
|
||||
* a flat byte array of `count × 31` patch records — see terranmon.txt "Ixmp. Instrument
|
||||
* extra samples" for the on-wire field layout. Passing an empty array clears any
|
||||
* previously-installed patches on this instrument. */
|
||||
fun uploadInstrumentPatches(slot: Int, bytes: IntArray) {
|
||||
val inst = getFirstSnd()?.instruments?.get(slot and 0xFF) ?: return
|
||||
val recordSize = 31
|
||||
if (bytes.isEmpty() || bytes.size < recordSize) {
|
||||
inst.extraPatches = null
|
||||
return
|
||||
}
|
||||
val count = bytes.size / recordSize
|
||||
if (count == 0) { inst.extraPatches = null; return }
|
||||
fun u8 (o: Int) = bytes[o] and 0xFF
|
||||
fun u16(o: Int) = (bytes[o] and 0xFF) or ((bytes[o + 1] and 0xFF) shl 8)
|
||||
fun s16(o: Int): Int { val v = u16(o); return if (v >= 0x8000) v - 0x10000 else v }
|
||||
fun u32(o: Int) = (bytes[o] and 0xFF) or
|
||||
((bytes[o + 1] and 0xFF) shl 8) or
|
||||
((bytes[o + 2] and 0xFF) shl 16) or
|
||||
((bytes[o + 3] and 0xFF) shl 24)
|
||||
val patches = Array(count) { i ->
|
||||
val o = i * recordSize
|
||||
// Patch version byte at offset 0 is parsed but only version 1 is recognised;
|
||||
// a future version bump would gate alternate field layouts here.
|
||||
AudioAdapter.TaudInstPatch(
|
||||
pitchStart = u16(o + 1),
|
||||
pitchEnd = u16(o + 3),
|
||||
volumeStart = u8 (o + 5),
|
||||
volumeEnd = u8 (o + 6),
|
||||
samplePtr = u32(o + 7),
|
||||
sampleLength = u16(o + 11),
|
||||
playStart = u16(o + 13),
|
||||
loopStart = u16(o + 15),
|
||||
loopEnd = u16(o + 17),
|
||||
samplingRate = u16(o + 19),
|
||||
sampleDetune = s16(o + 21),
|
||||
loopMode = u8 (o + 23),
|
||||
defaultPan = u8 (o + 24),
|
||||
defaultNoteVolume = u8 (o + 25),
|
||||
vibratoSpeed = u8 (o + 26),
|
||||
vibratoSweep = u8 (o + 27),
|
||||
vibratoDepth = u8 (o + 28),
|
||||
vibratoRate = u8 (o + 29),
|
||||
vibratoWaveform = u8 (o + 30)
|
||||
)
|
||||
}
|
||||
inst.extraPatches = patches
|
||||
}
|
||||
|
||||
/** Number of Ixmp patches currently installed on instrument [slot], or 0 if none. */
|
||||
fun getInstrumentPatchCount(slot: Int): Int =
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches?.size ?: 0
|
||||
|
||||
/** Clear any Ixmp patches previously uploaded to instrument [slot]. */
|
||||
fun clearInstrumentPatches(slot: Int) {
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches = null
|
||||
}
|
||||
|
||||
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
|
||||
fun uploadPattern(slot: Int, bytes: IntArray) {
|
||||
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
|
||||
@@ -226,6 +373,13 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
getPlayhead(playhead)?.resetParams()
|
||||
}
|
||||
|
||||
/** Clear funk-repeat (S$Fx) state (per-voice run-state + per-instrument loop-inversion masks)
|
||||
* without disturbing tempo / volume / position. Call on a fresh play-from-start so stale funk
|
||||
* state from a prior playback doesn't bleed into the replay. */
|
||||
fun resetFunkState(playhead: Int) {
|
||||
getPlayhead(playhead)?.resetFunkState()
|
||||
}
|
||||
|
||||
fun purgeQueue(playhead: Int) {
|
||||
getPlayhead(playhead)?.purgeQueue()
|
||||
}
|
||||
|
||||
@@ -1357,9 +1357,55 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
const val OP_Z = 0x23
|
||||
}
|
||||
|
||||
private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double =
|
||||
inst.samplingRate.toDouble() / SAMPLING_RATE *
|
||||
2.0.pow((noteVal - MIDDLE_C + inst.sampleDetuneSigned) / 4096.0)
|
||||
// Active-sample-aware playback rate. Reads from the Voice's snapshotted sample
|
||||
// view (set by [applyActiveSample]) so Ixmp-overlaid instruments use the patch's
|
||||
// samplingRate / detune, not the base inst's.
|
||||
private fun computePlaybackRate(voice: Voice, noteVal: Int): Double =
|
||||
voice.activeSamplingRate.toDouble() / SAMPLING_RATE *
|
||||
2.0.pow((noteVal - MIDDLE_C + voice.activeSampleDetune) / 4096.0)
|
||||
|
||||
/**
|
||||
* Snapshot the sample-scope state for [voice] from either the base instrument
|
||||
* or a resolved Ixmp patch. Called by every fresh trigger; the per-tick read
|
||||
* sites then go through voice.active* instead of inst.* so multi-sample
|
||||
* (IT/XM keyboard table) instruments select the right sample per note.
|
||||
*
|
||||
* Sentinels on the patch: defaultPan == 0xFF, defaultNoteVolume == 0,
|
||||
* vibratoWaveform == 0xFF all defer to the base instrument. Other fields
|
||||
* are always carried by the patch (converter responsibility).
|
||||
*/
|
||||
private fun applyActiveSample(voice: Voice, inst: TaudInst, patch: TaudInstPatch?) {
|
||||
if (patch == null) {
|
||||
voice.activeSamplePtr = inst.samplePtr
|
||||
voice.activeSampleLength = inst.sampleLength
|
||||
voice.activeSamplePlayStart = inst.samplePlayStart
|
||||
voice.activeSampleLoopStart = inst.sampleLoopStart
|
||||
voice.activeSampleLoopEnd = inst.sampleLoopEnd
|
||||
voice.activeSamplingRate = inst.samplingRate
|
||||
voice.activeSampleDetune = inst.sampleDetuneSigned
|
||||
voice.activeLoopMode = inst.loopMode
|
||||
voice.activeVibratoSpeed = inst.vibratoSpeed
|
||||
voice.activeVibratoSweep = inst.vibratoSweep
|
||||
voice.activeVibratoDepth = inst.vibratoDepth
|
||||
voice.activeVibratoRate = inst.vibratoRate
|
||||
voice.activeVibratoWaveform = inst.vibratoWaveform
|
||||
} else {
|
||||
voice.activeSamplePtr = patch.samplePtr
|
||||
voice.activeSampleLength = patch.sampleLength
|
||||
voice.activeSamplePlayStart = patch.playStart
|
||||
voice.activeSampleLoopStart = patch.loopStart
|
||||
voice.activeSampleLoopEnd = patch.loopEnd
|
||||
voice.activeSamplingRate = patch.samplingRate
|
||||
voice.activeSampleDetune = patch.sampleDetune
|
||||
voice.activeLoopMode = patch.loopMode
|
||||
voice.activeVibratoSpeed = patch.vibratoSpeed
|
||||
voice.activeVibratoSweep = patch.vibratoSweep
|
||||
voice.activeVibratoDepth = patch.vibratoDepth
|
||||
voice.activeVibratoRate = patch.vibratoRate
|
||||
voice.activeVibratoWaveform =
|
||||
if (patch.vibratoWaveform == 0xFF) inst.vibratoWaveform else patch.vibratoWaveform
|
||||
}
|
||||
}
|
||||
|
||||
// Convert a 4096-TET noteVal to its Amiga-period equivalent (Double, no rounding).
|
||||
private fun noteValToAmigaPeriod(noteVal: Int): Double =
|
||||
@@ -1754,16 +1800,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
* 0 means full depth immediately).
|
||||
*/
|
||||
private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int {
|
||||
// Depth from byte 187 (full 0..255). Speed from byte 175 (FT2 0..255 scale).
|
||||
val depth0 = inst.vibratoDepth
|
||||
if (depth0 == 0 || inst.vibratoSpeed == 0) return 0
|
||||
// Reads come from the voice's active-sample snapshot (patch-aware) so multi-sample
|
||||
// IT/XM instruments use the per-sample auto-vibrato that the trigger resolved to.
|
||||
// [inst] is retained in the signature for callsite continuity but only the voice's
|
||||
// active fields are consulted here.
|
||||
val depth0 = voice.activeVibratoDepth
|
||||
if (depth0 == 0 || voice.activeVibratoSpeed == 0) return 0
|
||||
|
||||
// Two ramp-in semantics:
|
||||
// FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep.
|
||||
// IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick,
|
||||
// capped at depth0 * 256, then divided by 256.
|
||||
val ftSweep = inst.vibratoSweep
|
||||
val itRate = inst.vibratoRate
|
||||
val ftSweep = voice.activeVibratoSweep
|
||||
val itRate = voice.activeVibratoRate
|
||||
val t = voice.autoVibTicksSinceTrigger
|
||||
val rampDepth = when {
|
||||
ftSweep != 0 -> ((depth0 * t / ftSweep).coerceAtMost(depth0))
|
||||
@@ -1772,17 +1821,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
voice.autoVibTicksSinceTrigger++
|
||||
|
||||
// Vibrato waveform selector lives in instrumentFlag bits 2-4.
|
||||
// Vibrato waveform selector lives in instrumentFlag bits 2-4 (snapshotted onto voice).
|
||||
// 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only).
|
||||
// lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down.
|
||||
val wave = inst.vibratoWaveform
|
||||
val wave = voice.activeVibratoWaveform
|
||||
val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1)
|
||||
else lfoSample(voice.autoVibPhase, wave and 3)
|
||||
// 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the
|
||||
// shift compensates so depth ≈255 yields a similar musical excursion
|
||||
// (~±9 cents) to the old depth ≈15.
|
||||
val pitchDelta = (rawSample * rampDepth) shr 10
|
||||
voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF
|
||||
voice.autoVibPhase = (voice.autoVibPhase + voice.activeVibratoSpeed * 2) and 0xFF
|
||||
return pitchDelta
|
||||
}
|
||||
|
||||
@@ -1790,10 +1839,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
* Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's
|
||||
* funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the
|
||||
* caller is responsible for wrapping into a loop region first if loop semantics apply.
|
||||
*
|
||||
* Sample-geometry reads come from the voice's active-sample snapshot so Ixmp-patched
|
||||
* voices read the right bytes. The funk-mask continues to live on the base instrument
|
||||
* (PT2 effect; doesn't combine with multi-sample IT/XM in practice).
|
||||
*/
|
||||
private fun readSamplePoint(inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double {
|
||||
private fun readSamplePoint(voice: Voice, inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double {
|
||||
val i = idx.coerceIn(0, sampleLen - 1)
|
||||
var b = sampleBin[(inst.samplePtr + i).coerceAtMost(binMax).toLong()].toUint()
|
||||
var b = sampleBin[(voice.activeSamplePtr + i).coerceAtMost(binMax).toLong()].toUint()
|
||||
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
|
||||
val ls = inst.sampleLoopStart
|
||||
if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF
|
||||
@@ -1804,9 +1857,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double {
|
||||
if (inst.index == 0) return 0.0
|
||||
|
||||
val sampleLen = inst.sampleLength.coerceAtLeast(1)
|
||||
val loopStart = inst.sampleLoopStart.toDouble()
|
||||
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||
val sampleLen = voice.activeSampleLength.coerceAtLeast(1)
|
||||
val loopStart = voice.activeSampleLoopStart.toDouble()
|
||||
val loopEnd = voice.activeSampleLoopEnd.toDouble().coerceAtLeast(1.0)
|
||||
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
|
||||
|
||||
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
|
||||
@@ -1826,7 +1879,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac.
|
||||
for (j in -SINC_WIDTH .. SINC_WIDTH) {
|
||||
val coeff = sincTap(frac, j)
|
||||
if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff
|
||||
if (coeff != 0.0) acc += readSamplePoint(voice, inst, i0 + j, sampleLen, binMax) * coeff
|
||||
}
|
||||
acc
|
||||
}
|
||||
@@ -1837,10 +1890,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// formula in integer arithmetic, then map (out >> 1) back to [-1, 1].
|
||||
// The (out & 0xffff) → int16 cast after the third tap reproduces the
|
||||
// SNES hardware mid-sum overflow (the famous gauss "chirp").
|
||||
val oldest = (readSamplePoint(inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt()
|
||||
val olders = (readSamplePoint(inst, i0, sampleLen, binMax) * 32767.0).toInt()
|
||||
val olds = (readSamplePoint(inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt()
|
||||
val news = (readSamplePoint(inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt()
|
||||
val oldest = (readSamplePoint(voice, inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt()
|
||||
val olders = (readSamplePoint(voice, inst, i0, sampleLen, binMax) * 32767.0).toInt()
|
||||
val olds = (readSamplePoint(voice, inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt()
|
||||
val news = (readSamplePoint(voice, inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt()
|
||||
val offset = (frac * 256.0).toInt().coerceIn(0, 255)
|
||||
var out = (SNES_GAUSS[0xff - offset] * oldest) shr 10
|
||||
out += (SNES_GAUSS[0x1ff - offset] * olders) shr 10
|
||||
@@ -1868,7 +1921,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// a mid-rail seed), reproducing DMC's coarse quantisation. Per-voice
|
||||
// counter persists across samples and is reseeded to mid-rail on note
|
||||
// trigger (see triggerNote).
|
||||
val target = readSamplePoint(inst, i0, sampleLen, binMax)
|
||||
val target = readSamplePoint(voice, inst, i0, sampleLen, binMax)
|
||||
val targetLevel = ((target + 1.0) * 63.5).toInt().coerceIn(0, 127)
|
||||
when {
|
||||
targetLevel > voice.nesDpcmCounter && voice.nesDpcmCounter <= 125 ->
|
||||
@@ -1881,8 +1934,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
INTERP_NONE, INTERP_A500, INTERP_A1200 ->
|
||||
// Paula-style ZOH — emit the integer-indexed sample byte without
|
||||
// sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs.
|
||||
readSamplePoint(inst, i0, sampleLen, binMax)
|
||||
else -> readSamplePoint(inst, i0, sampleLen, binMax)
|
||||
readSamplePoint(voice, inst, i0, sampleLen, binMax)
|
||||
else -> readSamplePoint(voice, inst, i0, sampleLen, binMax)
|
||||
}
|
||||
|
||||
// While ramping out at sample end, hold position so the mixer keeps emitting the
|
||||
@@ -1895,7 +1948,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// When the sustain bit is set, key-off escapes the loop: the sample plays past
|
||||
// loopEnd until it ends naturally (loopMode 0 semantics).
|
||||
val effectiveLoopMode =
|
||||
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3)
|
||||
if (voice.activeSampleLoopSustain && voice.keyOff) 0 else (voice.activeLoopMode and 3)
|
||||
when (effectiveLoopMode) {
|
||||
0 -> if (voice.samplePos >= sampleLen) {
|
||||
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
|
||||
@@ -1977,16 +2030,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
* unconditionally on inst-column rows, regardless of porta). Sets
|
||||
* noteVolume only — channelVolume (IT chan->global_volume) survives.
|
||||
*/
|
||||
private fun rowVolumeFromDefault(inst: TaudInst): Int {
|
||||
val dnv = inst.defaultNoteVolume
|
||||
private fun rowVolumeFromDefault(inst: TaudInst, patch: TaudInstPatch? = null): Int {
|
||||
// Patch overrides the base inst's DNV unless the sentinel (0 = no override).
|
||||
val dnv = patch?.defaultNoteVolume?.takeIf { it != 0 } ?: inst.defaultNoteVolume
|
||||
return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255
|
||||
}
|
||||
|
||||
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
|
||||
if (instId != 0) voice.instrumentId = instId
|
||||
val inst = instruments[voice.instrumentId]
|
||||
// Resolve the Ixmp patch (if any) for this trigger. Volume axis uses the
|
||||
// pre-patch seed so the rectangle test is well-defined; the patch's own
|
||||
// DNV is then layered onto the final voice.noteVolume below.
|
||||
val seedVolForLookup = when {
|
||||
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||
instId != 0 -> rowVolumeFromDefault(inst, null)
|
||||
else -> voice.noteVolume.coerceIn(0, 0x3F)
|
||||
}
|
||||
val patch = inst.resolvePatch(noteVal, seedVolForLookup)
|
||||
applyActiveSample(voice, inst, patch)
|
||||
voice.tonePortaTarget = -1 // fresh note trigger cancels any running porta
|
||||
voice.samplePos = inst.samplePlayStart.toDouble()
|
||||
voice.samplePos = voice.activeSamplePlayStart.toDouble()
|
||||
voice.forward = true
|
||||
voice.active = true
|
||||
voice.keyOff = false
|
||||
@@ -2042,8 +2106,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (instId != 0) {
|
||||
// Default pan: applied unless the pattern row has already overridden channelPan.
|
||||
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
|
||||
// An Ixmp patch's defaultPan (when non-sentinel, i.e. != 0xFF) takes precedence over
|
||||
// the base instrument's defaultPan.
|
||||
if ((inst.panEnvLoop ushr 7) and 1 != 0) {
|
||||
voice.channelPan = inst.defaultPan
|
||||
val patchPan = patch?.defaultPan?.takeIf { it != 0xFF }
|
||||
voice.channelPan = patchPan ?: inst.defaultPan
|
||||
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
|
||||
}
|
||||
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
|
||||
@@ -2066,7 +2133,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.basePitch = noteVal
|
||||
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
|
||||
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
|
||||
voice.playbackRate = computePlaybackRate(inst, noteVal)
|
||||
voice.playbackRate = computePlaybackRate(voice, noteVal)
|
||||
// Fresh trigger seeds noteVolume from the per-instrument "default note volume"
|
||||
// (byte 196) when the row carried an instrument byte but no explicit V column —
|
||||
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
|
||||
@@ -2078,9 +2145,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// chan->global_volume across sample changes, so M / N writes persist.
|
||||
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
|
||||
// mixer applies independently of this seed.
|
||||
// When an Ixmp patch overrides DNV (non-sentinel), the patch wins via rowVolumeFromDefault.
|
||||
voice.noteVolume = when {
|
||||
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
|
||||
instId != 0 -> rowVolumeFromDefault(inst)
|
||||
instId != 0 -> rowVolumeFromDefault(inst, patch)
|
||||
else -> voice.noteVolume
|
||||
}
|
||||
voice.rowVolume = voice.noteVolume
|
||||
@@ -2126,14 +2194,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
private fun applyDuplicateCheck(ts: TrackerState, channel: Int, newInstId: Int, newNote: Int) {
|
||||
if (newInstId == 0) return
|
||||
val newInst = instruments[newInstId]
|
||||
// For DCT=2 (sample match) we compare canonical sample identity. With Ixmp, the
|
||||
// new note's effective sample is the patch's (or the base inst's if no patch).
|
||||
// Volume axis defaults to full (0x3F) at this resolution point — the actual
|
||||
// trigger volume isn't known yet and the IT DCT model is volume-agnostic anyway.
|
||||
val newPatch = newInst.resolvePatch(newNote, 0x3F)
|
||||
val newSmpPtr = newPatch?.samplePtr ?: newInst.samplePtr
|
||||
val newSmpLen = newPatch?.sampleLength ?: newInst.sampleLength
|
||||
|
||||
fun isDuplicate(v: Voice): Boolean {
|
||||
val existInst = instruments[v.instrumentId]
|
||||
return when (existInst.duplicateCheckType) {
|
||||
1 -> v.noteVal == newNote && v.instrumentId == newInstId
|
||||
2 -> v.instrumentId == newInstId &&
|
||||
existInst.samplePtr == newInst.samplePtr &&
|
||||
existInst.sampleLength == newInst.sampleLength
|
||||
v.activeSamplePtr == newSmpPtr &&
|
||||
v.activeSampleLength == newSmpLen
|
||||
3 -> v.instrumentId == newInstId
|
||||
else -> false
|
||||
}
|
||||
@@ -2254,6 +2329,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
v.bitcrusherHeld = src.bitcrusherHeld
|
||||
v.overdriveAmp = src.overdriveAmp
|
||||
v.sourceChannel = channel
|
||||
// Active-sample snapshot must follow the foreground voice so the ghost's per-tick
|
||||
// playback (samplingRate, loop bounds, auto-vibrato) keeps using the patch the
|
||||
// foreground had bound — not the base instrument it would otherwise re-derive.
|
||||
v.activeSamplePtr = src.activeSamplePtr
|
||||
v.activeSampleLength = src.activeSampleLength
|
||||
v.activeSamplePlayStart = src.activeSamplePlayStart
|
||||
v.activeSampleLoopStart = src.activeSampleLoopStart
|
||||
v.activeSampleLoopEnd = src.activeSampleLoopEnd
|
||||
v.activeSamplingRate = src.activeSamplingRate
|
||||
v.activeSampleDetune = src.activeSampleDetune
|
||||
v.activeLoopMode = src.activeLoopMode
|
||||
v.activeVibratoSpeed = src.activeVibratoSpeed
|
||||
v.activeVibratoSweep = src.activeVibratoSweep
|
||||
v.activeVibratoDepth = src.activeVibratoDepth
|
||||
v.activeVibratoRate = src.activeVibratoRate
|
||||
v.activeVibratoWaveform = src.activeVibratoWaveform
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -2433,7 +2524,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
0x0000 -> {
|
||||
if (row.instrment != 0) {
|
||||
voice.instrumentId = row.instrment
|
||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
||||
// Re-resolve the patch on the new instrument against the voice's
|
||||
// current note so multi-sample IT/XM instruments pick up the right
|
||||
// sample (and per-patch DNV) even on a continue row. samplePos is
|
||||
// preserved — Schism csf_instrument_change reloads sample geometry
|
||||
// but does not retrigger.
|
||||
val newInst = instruments[voice.instrumentId]
|
||||
val newPatch = newInst.resolvePatch(voice.noteVal, voice.noteVolume)
|
||||
applyActiveSample(voice, newInst, newPatch)
|
||||
val seedVol = rowVolumeFromDefault(newInst, newPatch)
|
||||
voice.noteVolume = seedVol
|
||||
voice.rowVolume = seedVol
|
||||
voice.keyOff = false
|
||||
@@ -2470,7 +2569,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// and the bump persisted through the following vibrato rows).
|
||||
if (row.instrment != 0) {
|
||||
voice.instrumentId = row.instrment
|
||||
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId])
|
||||
// Porta + inst-byte: re-resolve the patch on the new instrument
|
||||
// against the voice's current note (Schism evaluates the keyboard
|
||||
// table at csf_instrument_change time; the porta target row.note
|
||||
// is only the slide destination, not the sample selector).
|
||||
val newInst = instruments[voice.instrumentId]
|
||||
val newPatch = newInst.resolvePatch(voice.noteVal, voice.noteVolume)
|
||||
applyActiveSample(voice, newInst, newPatch)
|
||||
val seedVol = rowVolumeFromDefault(newInst, newPatch)
|
||||
voice.noteVolume = seedVol
|
||||
voice.rowVolume = seedVol
|
||||
voice.keyOff = false
|
||||
@@ -2609,7 +2715,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0 // reseed on next per-tick slide
|
||||
voice.linearFreq = -1.0
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
|
||||
} else {
|
||||
voice.slideMode = 1; voice.slideArg = -arg
|
||||
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
|
||||
@@ -2628,7 +2734,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0
|
||||
voice.linearFreq = -1.0
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
|
||||
} else {
|
||||
voice.slideMode = 2; voice.slideArg = arg
|
||||
voice.amigaPeriod = -1.0
|
||||
@@ -2741,12 +2847,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
}
|
||||
EffectOp.OP_O -> {
|
||||
// Sample-offset O: clamps into the active sample's loop region when an O$xx
|
||||
// value lands past loopEnd. Reads from the patch-aware active-sample view.
|
||||
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
|
||||
val inst = instruments[voice.instrumentId]
|
||||
var off = arg
|
||||
if ((inst.loopMode and 3) != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) {
|
||||
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1)
|
||||
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen)
|
||||
if ((voice.activeLoopMode and 3) != 0 &&
|
||||
voice.activeSampleLoopEnd > voice.activeSampleLoopStart &&
|
||||
off > voice.activeSampleLoopEnd) {
|
||||
val loopLen = (voice.activeSampleLoopEnd - voice.activeSampleLoopStart).coerceAtLeast(1)
|
||||
off = voice.activeSampleLoopStart + ((off - voice.activeSampleLoopStart) % loopLen)
|
||||
}
|
||||
voice.samplePos = off.toDouble()
|
||||
}
|
||||
@@ -2837,7 +2946,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
voice.basePitch = voice.noteVal
|
||||
voice.amigaPeriod = -1.0
|
||||
voice.linearFreq = -1.0
|
||||
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal)
|
||||
voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
|
||||
}
|
||||
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
|
||||
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
|
||||
@@ -2905,7 +3014,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
for (vi in 0 until ts.voices.size) {
|
||||
val voice = ts.voices[vi]
|
||||
if (!voice.active && voice.noteDelayTick < 0) continue
|
||||
val inst = instruments[voice.instrumentId]
|
||||
var inst = instruments[voice.instrumentId]
|
||||
|
||||
// Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume
|
||||
// alone — IT's note cut stops the sample, it doesn't reset chan->global_volume.
|
||||
@@ -2921,6 +3030,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
maybeSpawnBackgroundForNNA(ts, voice, vi)
|
||||
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
|
||||
voice.noteDelayTick = -1
|
||||
// triggerNote may have swapped in a new instrument; re-bind so the rest of this
|
||||
// tick's per-voice work (playbackRate at L3090, envelope/fadeout/auto-vibrato)
|
||||
// uses the instrument that just fired, not the one the voice held on entry. On a
|
||||
// never-triggered voice the stale binding is instruments[0] (samplingRate 0),
|
||||
// which would zero playbackRate and freeze the sample — the "first note on a
|
||||
// fresh channel via S$Dx is silent" bug.
|
||||
inst = instruments[voice.instrumentId]
|
||||
}
|
||||
|
||||
if (!voice.active) {
|
||||
@@ -3059,7 +3175,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
if (voice.retrigCounter >= voice.retrigInterval) {
|
||||
voice.retrigCounter = 0
|
||||
val retrigInst = instruments[voice.instrumentId]
|
||||
voice.samplePos = retrigInst.samplePlayStart.toDouble()
|
||||
// Use the voice's active sample's playStart (patch-aware) — without this
|
||||
// a Q retrigger on a multi-sample instrument would jump to the base sample
|
||||
// even though the voice is bound to a patch.
|
||||
voice.samplePos = voice.activeSamplePlayStart.toDouble()
|
||||
voice.keyOff = false
|
||||
voice.envIndex = 0; voice.envTimeSec = 0.0
|
||||
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
|
||||
@@ -3087,7 +3206,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
else 0
|
||||
|
||||
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
|
||||
voice.playbackRate = computePlaybackRate(inst, finalPitch)
|
||||
voice.playbackRate = computePlaybackRate(voice, finalPitch)
|
||||
|
||||
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
|
||||
// Schism filters.c:80-86 computes `cutoff_used = chan->cutoff * (flt_modifier+256)/256`
|
||||
@@ -3191,7 +3310,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
|
||||
else 0
|
||||
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
|
||||
bg.playbackRate = computePlaybackRate(inst, finalPitch)
|
||||
bg.playbackRate = computePlaybackRate(bg, finalPitch)
|
||||
// Filter-mode pf envelope: same scaling rule as foreground.
|
||||
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
|
||||
val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254
|
||||
@@ -3683,6 +3802,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
var autoVibPhase = 0 // 8-bit phase counter
|
||||
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
|
||||
|
||||
// Active-sample view — snapshot of either the base instrument's sample-scope
|
||||
// fields or, when an Ixmp patch covers (noteVal, rowVolume) at trigger time,
|
||||
// the matching TaudInstPatch overlay. Per-tick and per-row code reads from
|
||||
// these instead of `inst.*` so multi-sample (IT keyboard table) instruments
|
||||
// play the correct sample for the triggered note. Snapshotted by triggerNote
|
||||
// and the equivalent paths (Q retrigger, NNA ghosting).
|
||||
var activeSamplePtr = 0
|
||||
var activeSampleLength = 0
|
||||
var activeSamplePlayStart = 0
|
||||
var activeSampleLoopStart = 0
|
||||
var activeSampleLoopEnd = 0
|
||||
var activeSamplingRate = 0
|
||||
var activeSampleDetune = 0 // signed 4096-TET
|
||||
var activeLoopMode = 0 // bits 0-1 = direction, bit 2 = sustain (matches inst byte 14)
|
||||
var activeVibratoSpeed = 0
|
||||
var activeVibratoSweep = 0
|
||||
var activeVibratoDepth = 0
|
||||
var activeVibratoRate = 0
|
||||
var activeVibratoWaveform = 0 // bits 0-2 only
|
||||
val activeSampleLoopSustain: Boolean get() = (activeLoopMode and 0x04) != 0
|
||||
|
||||
// NES 2A03 DMC counter for INTERP_NES_DPCM (interpolation mode 5).
|
||||
// 7-bit unsigned (0..127), slews ±2 per output sample as the sigma-delta
|
||||
// bitstream is generated on the fly. Seeded to mid-rail (63) on every
|
||||
@@ -4094,6 +4234,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear funk-repeat (S$Fx) state only — per-voice run-state plus the per-instrument
|
||||
* loop-inversion masks — without touching tempo / volume / position. taut calls this on
|
||||
* every fresh play-from-start so accumulated inversions and a stale funkSpeed don't bleed
|
||||
* from a prior session into the replay; full resetParams would also clobber bpm / tickRate /
|
||||
* volume, which a replay must preserve. Masks still persist across a natural song loop. */
|
||||
fun resetFunkState() {
|
||||
trackerState?.voices?.forEach {
|
||||
it.funkSpeed = 0
|
||||
it.funkAccumulator = 0
|
||||
it.funkWritePos = 0
|
||||
}
|
||||
parent.instruments.forEach { it.funkMask = null }
|
||||
}
|
||||
|
||||
fun purgeQueue() {
|
||||
pcmQueue.clear()
|
||||
if (isPcmMode) {
|
||||
@@ -4152,6 +4306,41 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
}
|
||||
|
||||
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
|
||||
|
||||
/**
|
||||
* One Ixmp "extra sample" patch — overlays sample-scope state on a base instrument
|
||||
* for a (noteVal, rowVolume) rectangle. See terranmon.txt "Ixmp. Instrument extra
|
||||
* samples" for the on-wire layout. Envelopes, fadeout, NNA / DCT / DCA, filter,
|
||||
* pitch-pan, IGV and other instrument-scope fields stay on the base TaudInst —
|
||||
* only the fields below override.
|
||||
*
|
||||
* Sentinels: defaultPan == 0xFF, defaultNoteVolume == 0, vibratoWaveform == 0xFF
|
||||
* all mean "inherit the base instrument's value". samplingRate == 0 would silence
|
||||
* the patch (same semantics as base inst), so converters must always supply it.
|
||||
*/
|
||||
data class TaudInstPatch(
|
||||
val pitchStart: Int,
|
||||
val pitchEnd: Int,
|
||||
val volumeStart: Int,
|
||||
val volumeEnd: Int,
|
||||
val samplePtr: Int,
|
||||
val sampleLength: Int,
|
||||
val playStart: Int,
|
||||
val loopStart: Int,
|
||||
val loopEnd: Int,
|
||||
val samplingRate: Int,
|
||||
val sampleDetune: Int, // signed 4096-TET
|
||||
val loopMode: Int, // matches base inst byte 14 (bits 0-1 = mode, bit 2 = sustain)
|
||||
val defaultPan: Int, // 0..255; 0xFF = no override
|
||||
val defaultNoteVolume: Int, // 0..255 IT-scaled; 0 = no override
|
||||
val vibratoSpeed: Int,
|
||||
val vibratoSweep: Int,
|
||||
val vibratoDepth: Int,
|
||||
val vibratoRate: Int,
|
||||
val vibratoWaveform: Int // 0..7; 0xFF = no override
|
||||
) {
|
||||
val sampleLoopSustain: Boolean get() = (loopMode and 0x04) != 0
|
||||
}
|
||||
/**
|
||||
* 256-byte instrument record (terranmon.txt:2001+).
|
||||
*
|
||||
@@ -4276,12 +4465,31 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
|
||||
// Byte 196 is the new "default note volume" field — see triggerNote.
|
||||
private val reserved = ByteArray(59)
|
||||
|
||||
// Optional Ixmp "extra sample" patches — non-null when an Ixmp block was uploaded
|
||||
// for this instrument. Patches are scanned in order at trigger time; first hit on
|
||||
// (noteVal, rowVolume) wins (overlapping rectangles are INVALID per spec).
|
||||
var extraPatches: Array<TaudInstPatch>? = null
|
||||
|
||||
/** Walk [extraPatches] and return the first patch whose pitch+volume rectangle
|
||||
* contains the given trigger. Returns null when no patches are bound or none match. */
|
||||
fun resolvePatch(noteVal: Int, rowVolume: Int): TaudInstPatch? {
|
||||
val patches = extraPatches ?: return null
|
||||
for (p in patches) {
|
||||
if (noteVal in p.pitchStart..p.pitchEnd &&
|
||||
rowVolume in p.volumeStart..p.volumeEnd) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
|
||||
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
|
||||
// Mask is sized for the loop length at allocation time; if the loop bounds change
|
||||
// (e.g. a new song reuses this instrument slot with different sample data) the old
|
||||
// mask is stale and must be discarded — otherwise indexing past its end crashes the
|
||||
// render thread with ArrayIndexOutOfBoundsException.
|
||||
// Note: with Ixmp patches active the mask still indexes the BASE instrument's loop
|
||||
// region, not the active patch's. Funk repeat (S$Fx) is a PT2 effect and doesn't
|
||||
// coexist with multi-sample IT/XM instruments in practice.
|
||||
var funkMask: ByteArray? = null
|
||||
fun toggleFunkBit(loopOffset: Int) {
|
||||
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)
|
||||
|
||||
@@ -16,6 +16,11 @@ Limits:
|
||||
- Multi-sample instruments use the sample selected by the *current
|
||||
note's* keymap entry; the converter materialises one Taud
|
||||
instrument slot per (XM instrument, sample-in-instrument) pair.
|
||||
(Note: it2taud uses the alternate Ixmp project-data extension
|
||||
instead — one Taud instrument per IT instrument, plus an Ixmp
|
||||
patch list for the keyboard mapping. XM could be retrofitted the
|
||||
same way to conserve Taud instrument slots; deferred until any
|
||||
real XM file actually hits the 255-slot cap.)
|
||||
|
||||
Pattern length policy:
|
||||
- XM patterns ≤ 64 rows → 1 Taud cue with the LEN ($02xx)
|
||||
|
||||
Reference in New Issue
Block a user