11 Commits

Author SHA1 Message Date
minjaesong
e27a01dca6 command.js: autocomplete by candidate window 2026-06-04 01:16:56 +09:00
minjaesong
35263eeaa4 command.js: commandrc and AUTOEXEC.BAT split 2026-06-03 23:15:34 +09:00
minjaesong
d223adda25 command.js: left/right cursoring 2026-06-03 22:50:54 +09:00
minjaesong
a9d095e3cb tvdos: concurrency and VT 2026-06-03 20:49:59 +09:00
minjaesong
dad345c027 taut: sample RAM numbers 2026-06-02 19:41:44 +09:00
minjaesong
2045da0286 playgui: better ASCII waveform 2026-06-02 15:21:05 +09:00
minjaesong
3362a6b732 taud Ixmp extension, doc cleanup 2026-05-31 14:50:11 +09:00
minjaesong
038db60b59 fix: taud note with SDx not firing due to unbound inst 2026-05-30 09:05:28 +09:00
minjaesong
1d3b5ce8aa taut: persistent funk visualisation 2026-05-30 01:33:28 +09:00
minjaesong
9e8af96c32 taut: sample dedup 2026-05-29 15:01:55 +09:00
minjaesong
43e5baadf4 taut: realtime waveform update for funk repeat simulation 2026-05-29 14:02:55 +09:00
20 changed files with 2560 additions and 298 deletions

117
CLAUDE.md
View File

@@ -165,6 +165,14 @@ Peripheral memories can be accessed using `vm.peek()` and `vm.poke()` functions,
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions. - The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
## Taud Tracker Engine
The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt`.
### Critical Implementation Notes
**Re-bind the local `inst` after any mid-tick `triggerNote`.** `applyTrackerTick` binds `var inst = instruments[voice.instrumentId]` once at the top of the per-voice loop. When the note-delay (`S$Dx`) deferred trigger fires mid-tick, `triggerNote` swaps the voice's `instrumentId` — but the rest of that tick (playback-rate recompute at the `computePlaybackRate(inst, finalPitch)` line, `advanceEnvelope`, `advancePfEnvelope`, `advanceAutoVibrato`, and the fadeout / filter-env reads of `inst.*`) keeps using the captured binding. The damage on a **never-triggered voice** (`instrumentId == 0` → stale `inst = instruments[0]`, whose `samplingRate == 0`) is that `playbackRate` is overwritten with `0.0`, freezing the sample at its start for the trigger tick — perceived as "the first delayed note on a fresh channel doesn't fire" (canonical: WHEN.taud cue 0 voice 13 pattern 0x0A row 16, inst `0x11` SD2 on a fresh play). On a warm voice the stale `inst` is a real instrument with non-zero rate, so the note sounds (at the wrong rate for one tick — a sub-perceptual glitch). Re-bind `inst = instruments[voice.instrumentId]` immediately after the note-delay fire block. Any future in-tick trigger paths (currently only S$Dx) must do the same.
## TVDOS ## TVDOS
### TVDOS Movie Formats ### TVDOS Movie Formats
@@ -428,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
- DC frequency underamplification (using 1.0 instead of 4.0/6.0) - DC frequency underamplification (using 1.0 instead of 4.0/6.0)
- Incorrect stereo imaging and extreme side channel distortion - Incorrect stereo imaging and extreme side channel distortion
- Severe frequency response errors that manifest as "clipping-like" distortion - Severe frequency response errors that manifest as "clipping-like" distortion
## Virtual Consoles (vtmgr)
Linux-style virtual consoles for TVDOS: up to 6 independent shell sessions,
switched with **Alt-1..Alt-6** or the **`chvt N`** builtin, **Alt-0** to exit.
Implemented entirely in JS — **no tsvm_core changes**.
### Architecture
- **Dispatcher**: `assets/disk0/tvdos/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.

View File

@@ -1,10 +1,22 @@
# Taud Tracker Effect Command Reference # Taud Tracker Effect Command Reference
Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine must implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers. Taud is a tracker-style music format derived from ScreamTracker 3's pattern command set, extended to 16-bit effect arguments and a 4096-tone equal-temperament pitch grid. This document defines every effect command a Taud engine **MUST** implement. Each command entry has three parts: a plain explanation for composers, compatibility notes for converting patterns from ScreamTracker 3 (ST3), ImpulseTracker (IT), FastTracker 2 (FT2) or ProTracker (PT), and implementation details for engine writers.
## Conformance language
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and **OPTIONAL** in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals and bold. Lowercase uses of these words carry their ordinary English meaning and impose no normative requirement.
In short:
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
The "Plain" paragraph of each effect description is non-normative tutorial text; the **Compatibility** and **Implementation** paragraphs carry the normative requirements, expressed through the keywords above.
--- ---
## 0. Tracker Terminologies ## 0. Tracker terminologies
This manual extensively uses "tracker lingo" that may not sound intuitive to the modern DAW users. This section covers some of the tracker lingo to get the concepts better understood for those who have never used trackers. This manual extensively uses "tracker lingo" that may not sound intuitive to the modern DAW users. This section covers some of the tracker lingo to get the concepts better understood for those who have never used trackers.
@@ -16,7 +28,7 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
* **Row.** One horizontal slot within a pattern, at most one note event per channel. A row's duration is `speed × tick_duration` — see Speed and Tempo below. * **Row.** One horizontal slot within a pattern, at most one note event per channel. A row's duration is `speed × tick_duration` — see Speed and Tempo below.
* **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one ticks for operation, and **they will not get applied when tick rate is set to 1.** * **Ticks.** A row spans several ticks dictated by a "tick rate". All note effects happen on those ticks while playing. Some effects (notably sliding effects, excluding fine slides) require more than one tick for operation, and **MUST NOT** be applied when the tick rate is set to 1.
* **Speed vs. Tempo.** Two independent timing knobs. **Speed** (effect A) is the number of ticks per row; **tempo** (effect T) sets the duration of one tick, conventionally expressed as BPM. To slow the song globally without changing how often per-tick effects update, lower the tempo. To give per-tick effects more iterations per row (denser vibrato, longer slides per row), raise the speed. The default is speed 6, tempo $64 → 125 BPM → 50 Hz tick rate → 120 ms per row. * **Speed vs. Tempo.** Two independent timing knobs. **Speed** (effect A) is the number of ticks per row; **tempo** (effect T) sets the duration of one tick, conventionally expressed as BPM. To slow the song globally without changing how often per-tick effects update, lower the tempo. To give per-tick effects more iterations per row (denser vibrato, longer slides per row), raise the speed. The default is speed 6, tempo $64 → 125 BPM → 50 Hz tick rate → 120 ms per row.
@@ -34,7 +46,7 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
* **Note off, note cut, note fade.** Three distinct ways a note ends. **Note cut** (`^^^` or S$Cx) silences instantly. **Note off** (`===` or an NNA = NoteOff) releases the sustain loop and lets the volume envelope's release segment play out, then fades. **Note fade** keeps the sustain loop running but begins the fadeout decay — for soft tail-offs that still sound sustained. * **Note off, note cut, note fade.** Three distinct ways a note ends. **Note cut** (`^^^` or S$Cx) silences instantly. **Note off** (`===` or an NNA = NoteOff) releases the sustain loop and lets the volume envelope's release segment play out, then fades. **Note fade** keeps the sustain loop running but begins the fadeout decay — for soft tail-offs that still sound sustained.
* **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background **ghost** voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips. * **NNA — New Note Action.** What happens to a still-playing note when a fresh note arrives on the same channel. Options are Cut (drop the old voice), Continue (let it ring through), Note Off (release it), or Note Fade (begin fadeout). The displaced voice becomes a background *ghost* voice — still audible but no longer addressable from the pattern. This is the tracker's substitute for polyphony across DAW MIDI clips.
* **Portamento.** Automatic pitch glide toward a target note (effect G). A row carrying both a note *and* a G does **not** re-trigger the sample; instead the note becomes the target and the already-sounding sample slides into it. Distinct from generic pitch slides (E/F), which move pitch by a fixed amount per tick with no target. * **Portamento.** Automatic pitch glide toward a target note (effect G). A row carrying both a note *and* a G does **not** re-trigger the sample; instead the note becomes the target and the already-sounding sample slides into it. Distinct from generic pitch slides (E/F), which move pitch by a fixed amount per tick with no target.
@@ -52,15 +64,15 @@ This manual extensively uses "tracker lingo" that may not sound intuitive to the
## 1. Sound device ## 1. Sound device
- **Bit depth:** 8-bit unsigned throughout, including the final mixdown. - **Bit depth:** 8-bit unsigned throughout, including the final mixdown. Conforming implementations **MUST** deliver 8-bit unsigned samples at the output stage.
- **Sample rate:** fixed at 32000 Hz. - **Sample rate:** fixed at 32000 Hz. Conforming implementations **MUST** produce output at exactly this rate; resampling to another playback rate is the responsibility of the host environment, not of the Taud engine.
- **Output channels:** strictly stereo; the mix bus always produces a two-channel frame even for mono-source samples. - **Output channels:** strictly stereo; the mix bus **MUST** always produce a two-channel frame, even for mono-source samples.
Internal accumulators may widen to 16 or 32 bits during mixing and effect computation, but stored samples and final output are 8-bit. Internal accumulators **MAY** widen to 16 or 32 bits during mixing and effect computation, but stored samples and final output **MUST** be 8-bit.
## 2. Pitch system — 4096-TET ## 2. Pitch system — 4096-TET
One octave spans **4096 pitch units** ($1000 exactly). A 12-TET semitone therefore equals **4096 ÷ 12 ≈ 341.333 units** (≈ $0155.55), which is not an integer; this irrationality is a deliberate consequence of choosing a microtonal native grid. Implementations store channel pitch as a signed integer in Taud units, and convert to playback rate using One octave spans **4096 pitch units** ($1000 exactly). A 12-TET semitone therefore equals **4096 ÷ 12 ≈ 341.333 units** (≈ $0155.55), which is not an integer; this irrationality is a deliberate consequence of choosing a microtonal native grid. Implementations **MUST** store channel pitch as a signed integer in Taud units, and **MUST** convert to playback rate using
``` ```
playback_rate = reference_rate × 2 ^ (pitch_units / 4096) playback_rate = reference_rate × 2 ^ (pitch_units / 4096)
@@ -83,13 +95,13 @@ Commonly used intervals in Taud units are listed below; all are rounded to the n
## 3. Volume system ## 3. Volume system
Per-note and per-channel volume runs from **$00 (silent) to $3F (full)**, a 6-bit range narrower than ST3's 0..$40. Global volume (effect V) runs 0..$FF; this wider range lets the mix bus scale the summed channel output without disturbing individual note volumes. The per-frame mix chain per channel is Per-note and per-channel volume runs from **$00 (silent) to $3F (full)**, a 6-bit range narrower than ST3's 0..$40. Global volume (effect V) runs 0..$FF; this wider range lets the mix bus scale the summed channel output without disturbing individual note volumes. Conforming engines **MUST** implement the per-frame mix chain per channel as
``` ```
mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift mix = sample × note_vol × channel_vol × global_vol >> normalisation_shift
``` ```
with saturation applied before the 8-bit stereo output. with saturation applied before the 8-bit stereo output. Internal accumulators **MAY** widen during this computation (see §1), but the saturating clip to the 8-bit range **MUST** be performed at the boundary.
`note_vol` and `channel_vol` are **two independent multiplicative axes** mirroring IT's `chan->volume` and `chan->global_volume`: `note_vol` and `channel_vol` are **two independent multiplicative axes** mirroring IT's `chan->volume` and `chan->global_volume`:
@@ -127,7 +139,7 @@ Most effects recall their last non-zero argument when re-issued with $0000. Unli
Every other memory-carrying effect (D, I, J, K, L, N, O, P, Q, and others) has a private slot. Every other memory-carrying effect (D, I, J, K, L, N, O, P, Q, and others) has a private slot.
**Effects without recall (literal zero).** A few effects do *not* recall on $0000 — the argument is taken at face value. **M** (set channel volume), **V** (set global volume), and the volume- / panning-column SET selectors all behave this way: writing `M $0000` or `V $0000` is a literal "set to silence", not a memory recall. Converters lifting from source trackers that *do* share memory (notably ST3, where the `$00` argument may cohabit with D/E/F/etc.'s shared slot) MUST eagerly resolve the recall to an explicit value before emitting, since the Taud engine takes M / V arguments verbatim. **Effects without recall (literal zero).** A few effects do *not* recall on $0000 — the argument **MUST** be taken at face value. **M** (set channel volume), **V** (set global volume), and the volume- / panning-column SET selectors all behave this way: writing `M $0000` or `V $0000` is a literal "set to silence", not a memory recall. Converters lifting from source trackers that *do* share memory (notably ST3, where the `$00` argument may cohabit with D/E/F/etc.'s shared slot) **MUST** eagerly resolve the recall to an explicit value before emitting, since the Taud engine takes M / V arguments verbatim.
## 7. Opcode and argument format ## 7. Opcode and argument format
@@ -143,7 +155,7 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
**Compatibility.** ST3 `Axx` maps one-to-one: Taud `A $xx00`. ST3 `A00` is a no-op; Taud `A $0000` is likewise ignored. ProTracker `Fxx` with `xx < $20` maps to Taud `A $xx00`; `Fxx` with `xx ≥ $20` maps to T instead (see T). **Compatibility.** ST3 `Axx` maps one-to-one: Taud `A $xx00`. ST3 `A00` is a no-op; Taud `A $0000` is likewise ignored. ProTracker `Fxx` with `xx < $20` maps to Taud `A $xx00`; `Fxx` with `xx ≥ $20` maps to T instead (see T).
**Implementation.** If the high byte is non-zero, write it to `ticks_per_row`; the low byte is reserved and must be zero. The change takes effect from the row on which the A command appears. There is no memory for A. **Implementation.** If the high byte is non-zero, the engine **MUST** write it to `ticks_per_row`; the low byte is reserved and **MUST** be zero. The change takes effect from the row on which the A command appears. There is no memory for A.
--- ---
@@ -151,11 +163,11 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
**Plain.** Finishes the current row, then continues playback at row 0 of the pattern at cue position $xxyy. Use this to create song-level jumps, loops, or branching structures. **Plain.** Finishes the current row, then continues playback at row 0 of the pattern at cue position $xxyy. Use this to create song-level jumps, loops, or branching structures.
**Compatibility.** ST3 `Bxx` jumps to an 8-bit cue and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs may have up to $10000 cue entries. **Compatibility.** ST3 `Bxx` jumps to an 8-bit cue and maps to Taud `B $00xx`. The extended 16-bit range means Taud songs **MAY** have up to $10000 cue entries.
**Implementation.** On the last tick of the current row, set the next cue index to the argument and the next row to 0. If the argument exceeds the song length, wrap to the song's defined restart position (cue $0000 by default). Jumps are detected by a visited `(cue, row)` set so that pathological loops do not prevent song-length computation, though they do not interrupt actual playback. There is no memory for B. **Implementation.** On the last tick of the current row, the engine **MUST** set the next cue index to the argument and the next row to 0. If the argument exceeds the song length, the engine **MUST** wrap to the song's defined restart position (cue $0000 by default). Jumps **SHOULD** be detected by a visited `(cue, row)` set so that pathological loops do not prevent song-length computation, though they **MUST NOT** interrupt actual playback. There is no memory for B.
**Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both fire: B chooses the cue, C chooses the row within that cue. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column takes precedence. **Simultaneous B and C on the same row.** If a B command appears in the same row as a C command (on any channel), both **MUST** fire: B chooses the cue, C chooses the row within that cue. If the two commands appear on different channels, channel priority is **ascending channel index** — the lowest-numbered channel carrying either effect wins its parameter. If both appear on the same channel row (only possible if one is a volume-column equivalent), the effect column **MUST** take precedence.
--- ---
@@ -163,9 +175,9 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
**Plain.** Finishes the current row, then skips ahead to row $xxyy of the **next** pattern in the cue sequence. **Plain.** Finishes the current row, then skips ahead to row $xxyy of the **next** pattern in the cue sequence.
**Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes should clamp to row 0 on import. When exporting back to ST3, encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63. **Compatibility.** ST3 stores `Cxx` as **BCD** (so on-disk `$10` means decimal row 10); Taud stores the argument as plain binary. When converting from ST3, converters **MUST** decode with `row = (byte >> 4) × 10 + (byte & $0F)`. Valid ST3 source bytes are those representing decimal 0..63; out-of-range BCD bytes **SHOULD** clamp to row 0 on import. When exporting back to ST3, converters **MUST** encode with `byte = ((row / 10) << 4) | (row % 10)`, clamped at row 63.
**Implementation.** On the last tick of the current row, advance the cue index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, start the destination pattern at row 0. There is no memory for C. **Implementation.** On the last tick of the current row, the engine **MUST** advance the cue index by 1 (or honour a co-occurring B), then set the next row to the argument. If the argument exceeds the destination pattern's row count, the engine **MUST** start the destination pattern at row 0. There is no memory for C.
--- ---
@@ -237,9 +249,9 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
- **MONOTONE source** (Taud `ff = 2`): - **MONOTONE source** (Taud `ff = 2`):
- MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources. - MONOTONE `2xx` → Taud `E $00xx` **verbatim** (Hz/tick). The engine converts the stored pitch to frequency, subtracts the argument, and converts back. MONOTONE has no fine-slide form; converters never emit `E $Fxxx` for ff=2 sources.
The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter should have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters MUST set bits 0-1 (`ff`) of the song-table flags byte to match the units they emit, and MUST NOT mix scales within one Taud song. The mode flag therefore controls **two** decoder behaviours simultaneously: (a) which numeric scale the converter ought to have used when emitting coarse arguments, and (b) which arithmetic the engine performs on those arguments per tick. Converters **MUST** set bits 0-1 (`ff`) of the song-table flags byte to match the units they emit, and **MUST NOT** mix scales within one Taud song.
Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter must eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory. Because E and F share memory in Taud (narrower than ST3's broad shared memory), an ST3 song that used `E00` or `F00` to recall a D, G, or Q argument will break on import; the converter **MUST** eagerly resolve ST3 recalls into explicit Taud arguments rather than relying on memory.
**Implementation.** Per-tick processing: **Implementation.** Per-tick processing:
@@ -299,10 +311,10 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1): The unit of `$xxxx` depends on the song-table tone mode (effect `1`, bits 0-1):
- `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources should be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here. - `ff = 0` (linear) and `ff = 1` (Amiga): 4096-TET pitch units per tick. Amiga sources **SHOULD** be converted to linear units on G, since the original PT G slide already operated semi-linearly within a small range and the shared-memory pitfall of E/F does not apply here.
- `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630). - `ff = 2` (linear-frequency): Hz/tick. The engine walks the channel's *frequency* toward the target note's frequency by `±$xxxx` Hz each non-first tick. This is MONOTONE's `3xx` behaviour verbatim (MTSRC/MT_PLAY.PAS:620-630).
**Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). Amiga-mode G sources should be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F. **Compatibility.** ST3 `Gxx` uses an 8-bit value in period-table units; converters **MUST** convert to Taud using the same `round(× 64/3)` scale as E/F coarse (1/16 semitone per ST3 slide unit). Amiga-mode G sources **SHOULD** be treated as linear. MONOTONE `3xx` → Taud `G $00xx` verbatim under ff=2. G has its **own** memory slot in both ST3 and Taud, so conversion is straightforward and does not suffer the shared-memory problem of E/F.
**Implementation.** **Implementation.**
@@ -464,9 +476,9 @@ The `tick_within_row mod 3` counter resets every row start (so every row begins
## K $xy00 — Dual: vibrato continuation and volume slide $xy ## K $xy00 — Dual: vibrato continuation and volume slide $xy
**Plain.** Continues the previously started vibrato (H or U) without retriggering it, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available in this form. The K command is implemented sorely for tracker compatibility — new compositions should prefer an explicit `H $0000` (vibrato recall) plus a volume-column slide (`1.$xy` / `2.$xy`), which carries the same semantics with one less hidden dependency. **Plain.** Continues the previously started vibrato (H or U) without retriggering it, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available in this form. The K command is implemented solely for tracker compatibility — new compositions **SHOULD** prefer an explicit `H $0000` (vibrato recall) plus a volume-column slide (`1.$xy` / `2.$xy`), which carries the same semantics with one less hidden dependency.
**Compatibility.** ST3 / IT `Kxy` map directly to Taud `K $xy00`: the source's `xy` argument byte goes verbatim into the high byte of the Taud argument. ProTracker / FT2 / XM `6xy` map identically. Source-tracker memory cohorts that share K's argument with D (notably the ST3 single-slot shared memory and IT's D/K/L vol-slide cohort) MUST be resolved eagerly by the converter — emit explicit arguments rather than relying on cohort sharing, since Taud's K has its own private slot. **Compatibility.** ST3 / IT `Kxy` map directly to Taud `K $xy00`: the source's `xy` argument byte goes verbatim into the high byte of the Taud argument. ProTracker / FT2 / XM `6xy` map identically. Source-tracker memory cohorts that share K's argument with D (notably the ST3 single-slot shared memory and IT's D/K/L vol-slide cohort) **MUST** be resolved eagerly by the converter — converters **MUST** emit explicit arguments rather than relying on cohort sharing, since Taud's K has its own private slot.
**Implementation.** On row parse: **Implementation.** On row parse:
@@ -500,9 +512,9 @@ The slide writes the per-note axis (same as D); `channel_vol` is untouched. K ha
## L $xy00 — Dual: tone portamento continuation and volume slide $xy ## L $xy00 — Dual: tone portamento continuation and volume slide $xy
**Plain.** Continues the previously started tone portamento (G) without retriggering, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available here. Like K, L is implemented sorely for tracker compatibility — new compositions should prefer an explicit `G $0000` plus a volume-column slide. **Plain.** Continues the previously started tone portamento (G) without retriggering, while applying a volume slide of `$xy` per non-first tick. Fine volume slides are not available here. Like K, L is implemented solely for tracker compatibility — new compositions **SHOULD** prefer an explicit `G $0000` plus a volume-column slide.
**Compatibility.** ST3 / IT `Lxy` map directly to Taud `L $xy00`. ProTracker / FT2 / XM `5xy` map identically. As with K, source cohort recalls (ST3 shared memory; IT D/K/L vol-slide cohort) MUST be resolved eagerly by the converter; Taud's L has its own private slot. **Compatibility.** ST3 / IT `Lxy` map directly to Taud `L $xy00`. ProTracker / FT2 / XM `5xy` map identically. As with K, source cohort recalls (ST3 shared memory; IT D/K/L vol-slide cohort) **MUST** be resolved eagerly by the converter; Taud's L has its own private slot.
**Implementation.** Identical machinery to K with `G` swapped for the LFO update: **Implementation.** Identical machinery to K with `G` swapped for the LFO update:
@@ -535,7 +547,7 @@ The slide writes the per-note axis (same as D); `channel_vol` is untouched. L ha
**Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would. **Plain.** Sets the per-channel volume axis (`channel_vol`, see §3) to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. M is the analog of IT's `Mxx`, which writes `chan->global_volume` — it does **not** disturb the per-note volume (`note_vol`) set by the volume column or seeded from the instrument default. A vol-col SET of $02 on a note row followed by an `M $4000` on the next row therefore plays the channel at `2/63 × $3F/63 ≈ 3%` of full, *not* at full — exactly as IT would.
**Compatibility.** IT `Mxx` maps directly: the source byte is taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory**`M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) MUST be eagerly resolved by the converter before emit. **Compatibility.** IT `Mxx` maps directly: the source byte **MUST** be taken **verbatim** with a clamp to `$3F` (IT's $40 cap snaps down by one). ST3 has no native M; OpenMPT/Schism's S3M-with-IT-extensions does, and the same verbatim-with-clamp rule applies on import. M has **no memory**`M $0000` is a literal "set channel volume to silence", not a recall. Source-tracker shared-memory recalls (e.g., ST3's single-slot shared memory) **MUST** be eagerly resolved by the converter before emit.
**Implementation.** **Implementation.**
@@ -651,9 +663,9 @@ The volume modifier table, **computed with arithmetic (no LUT)**, is:
| 6 | vol × 2 / 3 | E | vol × 3 / 2 | | 6 | vol × 2 / 3 | E | vol × 3 / 2 |
| 7 | vol × 1 / 2 | F | vol × 2 | | 7 | vol × 1 / 2 | F | vol × 2 |
Multiplicative cases use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results clip to $00..$3F after. Multiplicative cases **MUST** use integer arithmetic: `vol × 2 / 3` is `(vol × 2) / 3` (truncated); `vol × 3 / 2` is `(vol × 3) / 2`; `vol × 1 / 2` is `vol >> 1`; `vol × 2` is `vol << 1`. All results **MUST** clip to $00..$3F after.
A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule. A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) **MUST NOT** be retriggered, matching ST3's `kST3RetrigAfterNoteCut` rule.
--- ---
@@ -661,7 +673,7 @@ A note previously silenced by a cut (`^^^` or `SCx` earlier in the row) is not r
**Plain.** Modulates volume with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $4x. **Plain.** Modulates volume with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $4x.
**Compatibility.** ST3 `Rxy` uses nibbles; convert by nibble-repeat. ST3's volume cap is $40; Taud's is $3F — very deep tremolo that would have briefly clipped at $40 in ST3 may clip slightly earlier in Taud. R has its own memory slot (not shared with H/U). **Compatibility.** ST3 `Rxy` uses nibbles; converters **MUST** convert by nibble-repeat. ST3's volume cap is $40; Taud's is $3F — very deep tremolo that would have briefly clipped at $40 in ST3 **MAY** clip slightly earlier in Taud. R has its own memory slot (not shared with H/U).
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range: **Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
@@ -691,7 +703,7 @@ Taud splits T by which byte carries the value:
**Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $19`: Taud byte $00 → 25 BPM, $64 → 125 BPM (default), $FF → 280 BPM. **Plain.** Sets the Taud tempo byte to `$xx`. The resulting BPM is `$xx + $19`: Taud byte $00 → 25 BPM, $64 → 125 BPM (default), $FF → 280 BPM.
**Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; convert with `taud_byte = xx $18`. Taud byte $07 corresponds to ST3's minimum BPM of 32; Taud bytes below $07 are inexpressible in ST3 and should round up to $07 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below. **Compatibility.** ST3 `Txx` (where `xx ∈ $20..$FF`) stores BPM directly; converters **MUST** convert with `taud_byte = xx $18`. Taud byte $07 corresponds to ST3's minimum BPM of 32; Taud bytes below $07 are inexpressible in ST3 and **SHOULD** round up to $07 (BPM 32) when exporting. OpenMPT's extended tempo slides (`T $0x` down, `T $1x` up) in S3M files map to Taud T $00xx — see below.
ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx $19)00`; `Fxx` with `xx < $20` maps to A (speed) instead. ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx $19)00`; `Fxx` with `xx < $20` maps to A (speed) instead.
@@ -701,7 +713,7 @@ ProTracker `Fxx` with `xx ≥ $20` maps to Taud `T $(xx $19)00`; `Fxx` with
**Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops. **Plain.** Adjusts the tempo continuously during the row. `$00_0y` (low nibble under a zero high nibble within the low byte) slides BPM down by `$y` per non-first tick; `$00_1y` slides up. Out-of-range encodings ($00_20 through $00_FF) are reserved and behave as no-ops.
**Compatibility.** ST3 itself has only the set form; the slide forms originate in the OpenMPT/Schism extension of S3M. On export to strict ST3, slide forms are unrepresentable and should be approximated as an equivalent set-tempo on a later row. **Compatibility.** ST3 itself has only the set form; the slide forms originate in the OpenMPT/Schism extension of S3M. On export to strict ST3, slide forms are unrepresentable and **SHOULD** be approximated as an equivalent set-tempo on a later row.
**Implementation.** **Implementation.**
@@ -731,9 +743,9 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
**Plain.** Sets the global mix bus volume (0..$FF). $00 is silence; $FF is full. The default is $80. **Plain.** Sets the global mix bus volume (0..$FF). $00 is silence; $FF is full. The default is $80.
**Compatibility.** ST3's global volume is 0..$40; convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40. IT's global volume is 0..$80; convert with `taud_v = it_v × 2`, clamped at $FF. On IT, the very first `V 00` command must be resolved as the song's initial global volume. **Compatibility.** ST3's global volume is 0..$40; converters **MUST** convert with `taud_v = st3_v × 4`, clamped at $FF. On export, `st3_v = taud_v >> 2`, clamped at $40. IT's global volume is 0..$80; converters **MUST** convert with `taud_v = it_v × 2`, clamped at $FF. On IT, the very first `V 00` command **MUST** be resolved as the song's initial global volume.
**Implementation.** Write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud follows suit, but new Taud compositions should avoid muting channels that carry global effects. **Implementation.** The engine **MUST** write the high byte to `global_volume` on the row the command appears. The low byte is reserved. ST3's `kST3NoMutedChannels` rule applies: V on a muted channel is ignored by ST3; for strict-compatible playback Taud **MUST** follow suit, but new Taud compositions **SHOULD NOT** mute channels that carry global effects.
--- ---
@@ -761,7 +773,7 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
**Plain.** Modulates panning with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $5x. **Plain.** Modulates panning with an LFO, symmetrically with H's pitch modulation. `$xx` is LFO speed, `$yy` depth; the waveform is selected by S $5x.
**Compatibility.** IT `Yxy` uses nibbles; convert by nibble-repeat. IT's panning cap is $40; Taud's is $3F — very deep vibrato that would have briefly clipped at $40 in IT may clip slightly earlier in Taud. Y has its own memory slot. **Compatibility.** IT `Yxy` uses nibbles; converters **MUST** convert by nibble-repeat. IT's panning cap is $40; Taud's is $3F — very deep vibrato that would have briefly clipped at $40 in IT **MAY** clip slightly earlier in Taud. Y has its own memory slot.
**Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range: **Implementation.** Identical machinery to H with a larger shift to fit the narrower volume range:
@@ -792,7 +804,7 @@ Boundary rules:
- The block stops at the end of the pattern: a ditto whose nominal span would overflow the pattern's row count clips silently at the final row. - The block stops at the end of the pattern: a ditto whose nominal span would overflow the pattern's row count clips silently at the final row.
- `$xx = $00`, `$yy = $00`, and any `$xx` greater than the row index on which the ditto sits are all treated as no-ops — there is nothing valid to copy from. - `$xx = $00`, `$yy = $00`, and any `$xx` greater than the row index on which the ditto sits are all treated as no-ops — there is nothing valid to copy from.
- A `7` cell appearing inside a source block is **not** recursively expanded: when that source row is pasted into a destination, its effect column is treated as empty. This keeps expansion single-pass and prevents unbounded nesting. - A `7` cell appearing inside a source block is **not** recursively expanded: when that source row is pasted into a destination, its effect column is treated as empty. This keeps expansion single-pass and prevents unbounded nesting.
- Flow-control effects (B, C, S$Bx, S$Ex) that fall inside a source block still fire when their copy lands on a destination row, since the engine sees them as ordinary effect cells after expansion. Composers and converters should avoid placing S$Bx loop bounds wholly inside a ditto'd range — the loop counter is per-voice and the same destination row would be revisited twice with the same state. - Flow-control effects (B, C, S$Bx, S$Ex) that fall inside a source block still fire when their copy lands on a destination row, since the engine sees them as ordinary effect cells after expansion. Composers and converters **SHOULD NOT** place S$Bx loop bounds wholly inside a ditto'd range — the loop counter is per-voice and the same destination row would be revisited twice with the same state.
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory. **Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory.
@@ -872,7 +884,7 @@ Effect dispatch sees the synthesised effect, never the literal `7` opcode of the
- `8 $0000` disables both stages and resets the shared clipping mode to clamp. - `8 $0000` disables both stages and resets the shared clipping mode to clamp.
- `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics. - `8 $x000` updates only the shared clipping mode and leaves the active depth/skip undisturbed — useful for switching between clamp/fold/wrap mid-pattern without retyping the whole argument. The same form on effect 9 has identical semantics.
**Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 must spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern. **Compatibility.** Unique to Taud — no ST3/IT/PT equivalent. The effect has no memory: every cell that names effect 8 **MUST** spell out its full argument (apart from the `$x000` shorthand described above). `8 $1100` ⇒ 1-bit, no skip, fold-clipped — a useful sanity check pattern.
**Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse: **Implementation.** Per-voice state: `bitcrusherDepth` (0..15; 0 = quantiser off), `bitcrusherSkip` (0..255), `bitcrusherCounter` (mod skip+1), `bitcrusherHeld` (last emitted sample), and `clipMode` (0..2, shared with effect 9). On row parse:
@@ -931,7 +943,7 @@ The voice-FX state is preserved verbatim by the NNA-ghost copier, so the post-NN
## 9 $x0zz — Overdrive ## 9 $x0zz — Overdrive
**Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume is *not* re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and must be zero. **Plain.** Amplifies the voice's post-filter signal and routes it through the shared clipper. With `x = 0` (clamp) the effect is a hard-knee soft-clipping distortion; with `x = 1` (fold) it becomes a wave-folder; with `x = 2` (wrap) it produces aggressive aliased fuzz with sawtooth-style discontinuities at the rails. Volume **MUST NOT** be re-normalised after clipping — `9 $00FF` clamp-clipped plays at roughly the same loudness as the dry voice once everything saturates. The middle nibble is reserved and **MUST** be zero.
- **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp. - **x — clipping mode** (shared with effect 8): `0` clamp, `1` fold, `2` wrap (see effect 8 for the precise transfer functions). Values 3..F are reserved and treated as clamp.
- **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS). - **zz — amplification index**, range $00..$FF. The applied gain is `(16 + zz) / 16`, so `$00` is 1.0× (effect inactive), `$10` is 2.0× (+6 dBFS), `$F0` is 16.0× (+24 dBFS), and `$FF` is 16.9375× (≈ +24.55 dBFS).
@@ -975,7 +987,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
**Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects that modifies global behaviour) **Plain.** `$0100` turns filter off; `$0000` turns it on. The parameter of the filter is dependent on the current interpolation mode: follows Amiga 1200 LPF on 1200 mode, Amiga 500 LPF on 500 mode. For other interpolation modes, this command is no-op. (see § Effects that modifies global behaviour)
**Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` maps directly. To actually hear the effect, the interpolation mode must be set to one of the two Amiga modes. **Compatibility.** ST3/IT `S00`/`S01` and PT `E00`/`E01` map directly. To actually hear the effect, the interpolation mode **MUST** be set to one of the two Amiga modes.
**Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping. **Implementation.** Per-playhead boolean `ledFilterOn` (default off). Writes from row are gated on `interpolationMode ∈ {Amiga 500, Amiga 1200}`; in linear / no-interp / default modes the filter chain is bypassed entirely so the toggle is a silent no-op. The post-mix LPF chain runs on the stereo bus (left/right state per playhead) before dithering: in Amiga 500 mode a 1-pole RC LPF (R = 360 Ω, C = 0.1 µF, fc ≈ 4421 Hz) is always applied; in Amiga 1200 mode that LPF is bypassed (cutoff ~34 kHz, well above 32 kHz Nyquist — matches `pt2_paula.c`). When the LED toggle is on, an additional 2-pole Sallen-Key LPF (R1=R2=10 kΩ, C1=6800 pF, C2=3900 pF, fc ≈ 3091 Hz, Q ≈ 0.660) is run after the mode LPF. Coefficients precomputed once at SAMPLING_RATE; recurrence follows musicdsp.org #38 with `pt2_rcfilters.c` parameter mapping.
@@ -983,7 +995,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
## S $1x00 — PT/ST3/IT Glissando control ## S $1x00 — PT/ST3/IT Glissando control
**Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output is quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter still advances smoothly; only the audible pitch steps. **This command is implemented sorely for ST3/IT compatibility** and therefore only works in 12-TET context. **Plain.** `$1000` turns glissando off; `$1100` turns it on. When on, tone portamento (G) output **MUST** be quantised to the nearest semitone ($0155 approximation) before being sent to the mixer. The internal G pitch counter **MUST** still advance smoothly; only the audible pitch steps. **This command is implemented solely for ST3/IT compatibility** and therefore only works in 12-TET context.
**Compatibility.** ST3/IT `S10`/`S11` and PT `E30`/`E31` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid. **Compatibility.** ST3/IT `S10`/`S11` and PT `E30`/`E31` maps directly. In Taud, "nearest semitone" uses the best integer approximation: round `pitch / $155` to the nearest integer, multiply by $155; equivalently, `snapped = (pitch + $AB) / $155 × $155`. Because $155 is an approximation of 4096/12, accumulated rounding across many octaves will drift by up to a few cents; this is documented behaviour and intentional given the microtonal grid.
@@ -995,7 +1007,7 @@ S is a multiplexing opcode; the **high nibble of the high byte** selects the sub
**Plain.** Overrides the current note's fine-tune by applying a fixed 4096-TET offset. The index `$x` selects one of sixteen predefined pitch offsets, following ScreamTracker 3's Hz-based fine-tune table but expressed directly in Taud units. This command is implemented for ST3 compatibility. **Plain.** Overrides the current note's fine-tune by applying a fixed 4096-TET offset. The index `$x` selects one of sixteen predefined pitch offsets, following ScreamTracker 3's Hz-based fine-tune table but expressed directly in Taud units. This command is implemented for ST3 compatibility.
**Compatibility.** The index scheme matches ST3 exactly: `$8` is the baseline (no change), `$0..$7` are progressively flatter, `$9..$F` are progressively sharper. The Hz reference values come from the ST3 User's Manual and are reproduced here for auditability; the Taud offset is `log2(Hz / 8363) × 4096`, rounded to the nearest integer. **Format converters are advised to apply offset to the note value directly.** **Compatibility.** The index scheme matches ST3 exactly: `$8` is the baseline (no change), `$0..$7` are progressively flatter, `$9..$F` are progressively sharper. The Hz reference values come from the ST3 User's Manual and are reproduced here for auditability; the Taud offset is `log2(Hz / 8363) × 4096`, rounded to the nearest integer. **Format converters SHOULD apply the offset to the note value directly.**
| $x | Reference Hz | Taud offset | | $x | Reference Hz | Taud offset |
|---|---|---| |---|---|---|
@@ -1140,7 +1152,7 @@ The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or it
**Compatibility.** ST3 `SBx` maps directly. ProTracker `E6x` maps to Taud `S $Bx00`. **Compatibility.** ST3 `SBx` maps directly. ProTracker `E6x` maps to Taud `S $Bx00`.
ST3 has a long-documented bug where pattern delay (SEx) inside a pattern-loop range causes the loop counter to decrement multiple times per visit, producing unintended behaviour. **Taud fixes this bug.** On import, ST3 songs that relied on the bug will loop fewer times in Taud. Converters that want bit-exact ST3 playback should emit a warning when SBx and SEx appear in the same channel within a loop range, or optionally flatten loops by duplicating rows. ST3 has a long-documented bug where pattern delay (SEx) inside a pattern-loop range causes the loop counter to decrement multiple times per visit, producing unintended behaviour. **Taud fixes this bug.** On import, ST3 songs that relied on the bug will loop fewer times in Taud. Converters that want bit-exact ST3 playback **SHOULD** emit a warning when SBx and SEx appear in the same channel within a loop range, and **MAY** flatten loops by duplicating rows.
**Implementation.** State per channel: `loop_start_row` (defaulting to 0 at each pattern entry) and `loop_count` (defaulting to 0). **Implementation.** State per channel: `loop_start_row` (defaulting to 0 at each pattern entry) and `loop_count` (defaulting to 0).
@@ -1162,7 +1174,7 @@ on row event (S $Bx00):
on pattern change: loop_start_row = 0; loop_count = 0 on pattern change: loop_start_row = 0; loop_count = 0
``` ```
The crucial bug fix relative to ST3: the loop-counter decrement happens **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine treats the whole delay group as a single visit. Implement this by gating the SBx decrement on `pattern_delay_repetition == 0`. The crucial bug fix relative to ST3: the loop-counter decrement **MUST** happen **once per actual row playback**, not once per tick-0 invocation. When SBx shares a row with SEx (pattern delay), the pattern-delay machinery replays the row as a unit, but the SBx state machine **MUST** treat the whole delay group as a single visit. Engines **SHOULD** implement this by gating the SBx decrement on `pattern_delay_repetition == 0`.
--- ---
@@ -1172,7 +1184,7 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
**Compatibility.** ST3 `SCx` maps directly. ProTracker `ECx` also maps directly. ST3 ignores `SC0` (treats it as no cut at all); Taud preserves this. **Compatibility.** ST3 `SCx` maps directly. ProTracker `ECx` also maps directly. ST3 ignores `SC0` (treats it as no cut at all); Taud preserves this.
**Implementation.** On tick `$x`, set `output_volume = 0` but leave `base_volume` unchanged. If `$x ≥ speed`, the cut never fires. If `$x == 0`, the command is ignored. Set the `note_was_cut` flag so a later Q retrigger on the same row is suppressed. **Implementation.** On tick `$x`, the engine **MUST** set `output_volume = 0` but **MUST** leave `base_volume` unchanged. If `$x ≥ speed`, the cut **MUST NOT** fire. If `$x == 0`, the command **MUST** be ignored. The engine **MUST** set the `note_was_cut` flag so that a later Q retrigger on the same row is suppressed.
--- ---
@@ -1180,9 +1192,9 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
**Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues. **Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues.
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed). Taud discards those notes. If such note events have been encountered during conversion, they must be corrected on the converter. **Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note **MUST NOT** play on this row and **MUST NOT** carry over to the next row. Some trackers allow playback of "malformed" note delays (`$x` greater than current tick speed); Taud **MUST** discard those notes. If such note events have been encountered during conversion, they **MUST** be corrected by the converter.
**Implementation.** On row parse, defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger re-fires at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than current tick speed, the note must be discarded (see compatibility notes above) **Implementation.** On row parse, the engine **MUST** defer the note-trigger event (including sample selection, volume, offset, and any volume-column effect) until tick `$x`. On tick `$x`, the engine **MUST** execute the deferred trigger. When combined with pattern delay (S $Ex00), the deferred trigger **MUST** re-fire at the start of each row repetition — matching ST3's `kRowDelayWithNoteDelay` behaviour. If `$x` is greater than the current tick speed, the note **MUST** be discarded (see compatibility notes above).
--- ---
@@ -1190,7 +1202,7 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
**Plain.** Repeats the current row `$x` additional times (so `$x = 0` means no repeat and the row plays once; `$x = 3` means the row plays four times total). Notes do not retrigger across repetitions, but per-tick effects re-run and tick-0 events (fine slides, delayed notes) re-fire on each repetition. **Plain.** Repeats the current row `$x` additional times (so `$x = 0` means no repeat and the row plays once; `$x = 3` means the row plays four times total). Notes do not retrigger across repetitions, but per-tick effects re-run and tick-0 events (fine slides, delayed notes) re-fire on each repetition.
**Compatibility.** ST3 `SEx` maps directly. ProTracker `EEx` also maps directly. Simultaneous SEx on multiple channels: ST3 uses the first SEx in **pan order** (L1..L8 then R1..R8); **Taud uses the first SEx in ascending channel-index order** for predictability. Converters that encounter ST3 songs relying on the pan-order rule should emit a warning. **Compatibility.** ST3 `SEx` maps directly. ProTracker `EEx` also maps directly. Simultaneous SEx on multiple channels: ST3 uses the first SEx in **pan order** (L1..L8 then R1..R8); **Taud uses the first SEx in ascending channel-index order** for predictability. Converters that encounter ST3 songs relying on the pan-order rule **SHOULD** emit a warning.
Q retrigger counters do **not** reset between SEx repetitions. Q retrigger counters do **not** reset between SEx repetitions.
@@ -1202,7 +1214,7 @@ Q retrigger counters do **not** reset between SEx repetitions.
**Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster. **Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster.
**Compatibility.** ProTracker `EFx` is destructive — it XORs bytes directly in the sample data, permanently corrupting the sample. **Taud's implementation is non-destructive**: the XOR is applied at playback time through a per-instrument bit-mask, leaving source samples pristine. ST3 does not implement SFx at all and will parse Taud's S $Fx00 as a no-op; converters targeting ST3 should drop the effect. ProTracker `EFx` imports as Taud `S $Fyyy`, where `yyy = funk_table[x]`. **Compatibility.** ProTracker `EFx` is destructive — it XORs bytes directly in the sample data, permanently corrupting the sample. **Taud's implementation MUST be non-destructive**: the XOR **MUST** be applied at playback time through a per-instrument bit-mask, leaving source samples pristine. ST3 does not implement SFx at all and will parse Taud's S $Fx00 as a no-op; converters targeting ST3 **SHOULD** drop the effect. ProTracker `EFx` imports as Taud `S $Fyyy`, where `yyy = funk_table[x]`.
**Implementation.** Each instrument carries a `funk_mask` bit array, one bit per byte of the loop region, all zero at song start. A per-channel counter `funk_accumulator` and a per-channel `funk_write_pos` track progress. **Implementation.** Each instrument carries a `funk_mask` bit array, one bit per byte of the loop region, all zero at song start. A per-channel counter `funk_accumulator` and a per-channel `funk_write_pos` track progress.
@@ -1224,7 +1236,7 @@ on sample byte read during loop playback:
output_byte = raw_byte output_byte = raw_byte
``` ```
`S $F000` clears `funk_accumulator` but leaves `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` resets to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` persist across notes. The `funk_mask` itself is **only cleared on cue-start reset** (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay always reproduces the same audio without needing to reload the song from disk. `S $F000` **MUST** clear `funk_accumulator` but **MUST** leave `funk_mask` intact (the accumulated inversion pattern persists). **On every fresh note trigger**, `funk_write_pos` **MUST** reset to 0 (matching PT2's `n_wavestart = n_loopstart`); `funk_accumulator` and `funk_speed` **MUST** persist across notes. The `funk_mask` itself **MUST** be cleared only on cue-start reset (i.e. song-start / stop-and-replay) — within a single playback session it accumulates as PT2's destructive in-place edits would, but a clean replay **MUST** reproduce the same audio without needing to reload the song from disk.
--- ---
@@ -1239,7 +1251,7 @@ Each cell carries a 6-bit value field plus a 2-bit selector field for the volume
Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M). Volume-column effects do not consume the main effect slot; a cell can carry both (for instance, a tone portamento in the effect slot and a volume slide in the volume column). Because the volume column writes the per-note axis, an `M $xx00` on the same or following row sets the per-channel axis independently — the two multiply at the mixer (see §3 / §M).
When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). Note that *converted* M and N still target `note_vol` here (vol-col semantics) — to preserve the original per-channel intent, emit them in the main effect column instead. When the converter folds an ST3 K, L, M, or N effect into the volume column, the slide-up / slide-down nibbles map to selectors 1 / 2 (clamped to 6 bits — values above $3F clip). Note that *converted* M and N still target `note_vol` here (vol-col semantics) — to preserve the original per-channel intent, converters **MUST** emit them in the main effect column instead.
NOTE: **`3.00` — is No-op** NOTE: **`3.00` — is No-op**
@@ -1254,7 +1266,7 @@ The panning column uses the same 6-bit value + 2-bit selector layout:
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit). - **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit).
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3. - **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command takes precedence. NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command **MUST** take precedence.
--- ---
@@ -1272,7 +1284,7 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode. - ff = 1: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker. **Coarse and fine E/F arguments are stored as raw tracker period units** (the unscaled byte/nibble from the source PT/S3M/IT file) and applied in Amiga period space. Tone portamento (G) remains linear regardless of mode.
- ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630). - ff = 2: Linear-frequency tone mode (MONOTONE compat). **E, F, and G arguments are stored as Hz/tick** (a signed change in audible frequency per song tick), and the engine converts the channel's stored 4096-TET pitch back to a frequency, adds/subtracts the argument, then converts back to 4096-TET. Reference is fixed at 12-TET A4 = 440 Hz / C4 ≈ 261.6256 Hz, which matches MONOTONE's MT_PLAY.PAS `notesHz` table (A0 = 27.5 Hz, equal-temperament). Unlike Amiga mode, *all three* slide effects use the new arithmetic — Monotone's `1xx`, `2xx`, and `3xx` are all in Hz/tick (see MTSRC/MT_PLAY.PAS:606-630).
- rrr = 0: Yes interpolation. Actual interpolation algorithm is implementation-dependent, but recommended to use either Fast Sinc or Linear. - rrr = 0: Yes interpolation. The actual interpolation algorithm is implementation-dependent; Fast Sinc or Linear is **RECOMMENDED**.
- rrr = 1: No interpolation. - rrr = 1: No interpolation.
- rrr = 2: Amiga 500 interpolation. - rrr = 2: Amiga 500 interpolation.
- rrr = 3: Amiga 1200 interpolation. - rrr = 3: Amiga 1200 interpolation.
@@ -1326,30 +1338,30 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
These quirks of ST3 are worth preserving or flagging when importing S3M files into Taud: These quirks of ST3 are worth preserving or flagging when importing S3M files into Taud:
**Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter must **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000. **Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter **MUST** **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000.
**M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none. **M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none.
**Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Decode on import; encode on export. Out-of-range BCD bytes (decimal 64 or higher) clamp to row 0. **Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Converters **MUST** decode on import and encode on export. Out-of-range BCD bytes (decimal 64 or higher) **SHOULD** clamp to row 0.
**Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 25..280). Imported ST3 tempos must be shifted down by $19; Taud tempos below $07 and above $E6 cannot be represented in ST3 and should clamp on export. **Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 25..280). Imported ST3 tempos **MUST** be shifted down by $19; Taud tempos below $07 and above $E6 cannot be represented in ST3 and **SHOULD** clamp on export.
**SBx + SEx interaction.** ST3 miscounts loop iterations when pattern delay is active inside a pattern loop; Taud fixes this. Songs that depended on the bug for their intended playback will loop fewer times in Taud. Flag such songs on import. **SBx + SEx interaction.** ST3 miscounts loop iterations when pattern delay is active inside a pattern loop; Taud fixes this. Songs that depended on the bug for their intended playback will loop fewer times in Taud. Converters **SHOULD** flag such songs on import.
**Simultaneous SEx priority.** ST3 uses pan order (L1..L8, R1..R8); Taud uses ascending channel-index order. Rare; flag on import if multiple channels carry SEx in the same row. **Simultaneous SEx priority.** ST3 uses pan order (L1..L8, R1..R8); Taud uses ascending channel-index order. Rare; converters **SHOULD** flag on import if multiple channels carry SEx in the same row.
**Muted channels.** ST3 skips all effect processing on muted channels (no volume change, no tempo change, no jumps); Taud follows this rule for strict compatibility but recommends that new compositions avoid muting channels that carry global effects. **Muted channels.** ST3 skips all effect processing on muted channels (no volume change, no tempo change, no jumps); Taud **MUST** follow this rule for strict compatibility, but new compositions **SHOULD NOT** mute channels that carry global effects.
**Volume cap.** ST3's volume caps at $40; Taud's at $3F. Notes that reached $40 in ST3 (a rare edge) will play marginally quieter in Taud. **Volume cap.** ST3's volume caps at $40; Taud's at $3F. Notes that reached $40 in ST3 (a rare edge) will play marginally quieter in Taud.
**Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import, truncated ÷4 on export. **Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import and a truncated ÷4 on export. Converters **MUST** apply these scales.
**Linear pitch slides.** ST3's slide arithmetic is period-based; Taud supports both linear and period-based and selects between them via the song-table `f` flag. Conversion rules: **Linear pitch slides.** ST3's slide arithmetic is period-based; Taud supports both linear and period-based and selects between them via the song-table `f` flag. Conversion rules:
- Clear `linear_slides`. Both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character. - Clear `linear_slides`. Both coarse (Exx/Fxx) and fine/extra-fine (EFx/EEx/FFx/FEx) are stored **verbatim** as raw ST3 period units — coarse as `E/F $00xx`, fine as `E/F $F00x` — with no scaling. Taud `f` flag is **set**; the engine applies both forms in Amiga period space at playback, exactly recovering the source's period-step count and the non-linear pitch character.
- G (tone portamento) is always converted with `round(× 64/3)` and treated as linear, regardless of mode. - G (tone portamento) **MUST** always be converted with `round(× 64/3)` and treated as linear, regardless of mode.
**Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters must remap on both import and export. **Default tempo byte.** Taud's default $64 equals 125 BPM under the $19 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$64` after subtracting $19. Converters **MUST** remap on both import and export.
--- ---
@@ -1361,7 +1373,7 @@ This section documents important implementation details that are not covered by
Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever. Taud's volume fadeout is a single linear decay applied per song tick after key-off (or NNA Note-Fade). It is **the only retirement mechanism** for sustained voices when the volume envelope holds non-zero or has no terminating zero node — without a non-zero stored fadeout, such voices play forever.
The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine maintains a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off: The 12-bit stored fadeout lives at instrument-record bytes 172 (low 8 bits) and 173 (low nibble = high 4 bits; high nibble reserved). Range 0..4095. The engine **MUST** maintain a per-voice `fadeoutVolume ∈ [0, 1]` initialised to 1.0 on note-on, and once per song tick while the voice is keyed off **MUST**:
``` ```
fadeoutVolume -= storedFadeout / 1024.0 fadeoutVolume -= storedFadeout / 1024.0
@@ -1386,7 +1398,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
- `storedFadeout = 32` → fade ≈ 640 ms - `storedFadeout = 32` → fade ≈ 640 ms
- `storedFadeout = 1024` → ~20 ms (one tick) - `storedFadeout = 1024` → ~20 ms (one tick)
**Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters scale the source value into Taud's 0..4095 field. **Converter unit conversion.** Source trackers each expose fadeout in their own unit; converters **MUST** scale the source value into Taud's 0..4095 field.
- **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:** - **IT** (`it2taud.py`): IT files store fadeout as a 16-bit field at instrument-record offset `0x14`, range 0..1024 per ITTECH (some loaders accept up to 2048). Schism's per-tick decrement is `stored / 1024` — identical to Taud's unit. **Pass-through with clamp:**
```python ```python
@@ -1416,7 +1428,7 @@ There is no separate "use fadeout" flag — both extremes share the same field,
- For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached. - For tone portamento (G), `tonePortaSpeed` is also in Hz/tick: each tick walks `freq` toward `noteValToFreq(target)` by `±tonePortaSpeed` until the target frequency is reached.
- Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide. - Like Amiga mode, the per-voice intermediate frequency is cached across ticks (no round-trip rounding) and reseeded on note trigger, S$2x finetune, fine slides, and the start of a fresh multi-tick coarse slide.
**Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player should write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects may override it. **Initialisation from the song table.** The same flags byte is stored in the song-table entry (see file format §Song Table). A Taud player **MUST** write this byte to MMIO playhead register 7 before starting playback; the mixer then applies it as the initial state on every reset, and subsequent in-pattern `1` effects **MAY** override it.
--- ---

View File

@@ -1,12 +1,11 @@
echo "Starting TVDOS..." rem AUTOEXEC.BAT -- per-console launch script. Run once for every console:
rem each virtual-console pane runs it (via vtmgr's bootstrap), and the boot
rem put set-xxx commands here: rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
set INCLPATH=\hopper\include;$INCLPATH rem
set HELPPATH=\hopper\help;$HELPPATH rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
set KEYBOARD=us_colemak rem run per-console here rather than once at boot.
rem this line specifies which shell to be presented after the boot precess:
tvdos/i18n/korean tvdos/i18n/korean
zfm
rem The interactive shell for this console.
command -fancy command -fancy

9
assets/disk0/commandrc Normal file
View 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

View File

@@ -1471,9 +1471,40 @@ try {
serial.println("Warning: Could not load HSDPA driver: " + e.message) serial.println("Warning: Could not load HSDPA driver: " + e.message)
} }
// Boot script // Boot script. The work is split across two files:
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`); // \commandrc -- environment (`set` commands); run in EVERY context.
// \AUTOEXEC.BAT -- per-console launch (IME + interactive shell).
// vtmgr re-evaluates TVDOS.SYS inside each per-VT pane; a pane sets
// _TVDOS_IS_VT_PANE so it only replays the environment here and leaves the
// AUTOEXEC launch to vtmgr's pane bootstrap (which avoids recursively
// spawning vtmgr inside a pane).
{
let cmdsrc = files.open("A:/tvdos/bin/command.js").sread()
let runBatch = (path) => eval(`var _BAT=function(exec_args){${cmdsrc}\n};_BAT`)(["", "-c", path])
let cmdfile = files.open("A:/tvdos/bin/command.js") // Environment first, boot and pane alike. Gives every pane the same
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` + // PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"]) // \commandrc has no .BAT extension (so command.js's batch-file path,
// which keys off the extension, won't pick it up); run it line-by-line.
// `set` mutates the shared _TVDOS.variables, so the effect persists across
// the per-line shell invocations. Skip blanks and `rem` comments.
let rcFile = files.open("A:/commandrc")
if (rcFile.exists) {
rcFile.sread().split('\n').forEach((line) => {
let t = line.trim()
if (t.length > 0 && !/^rem(\s|$)/i.test(t)) runBatch(line)
})
}
if (typeof _TVDOS_IS_VT_PANE === "undefined" || !_TVDOS_IS_VT_PANE) {
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
// Boot console: hand the screen to the virtual-console multiplexer.
// When it exits (Alt-0), fall through to AUTOEXEC so the console is
// never left bare.
runBatch("tvdos/sbin/vtmgr")
runBatch("\\AUTOEXEC.BAT")
}
else {
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
}
}

View File

@@ -30,7 +30,18 @@ function makeHash() {
const shellID = makeHash() const shellID = makeHash()
function print_prompt_text() { function print_prompt_text() {
// VT pane indicator: shown for VT 2..6, not VT 1 (the default) so the
// unmodified prompt is what users see when they never touch virtual
// consoles. VT_NUM is set by vtmgr's pane bootstrap.
let vtPrefix = ""
if (typeof VT_NUM !== "undefined" && VT_NUM > 1) vtPrefix = "[" + VT_NUM + "] "
if (goFancy) { if (goFancy) {
if (vtPrefix) {
con.color_pair(161,253)
print(`\u00DD${VT_NUM}`)
con.color_pair(253,161)
con.addch(16);con.curs_right()
}
con.color_pair(239,161) con.color_pair(239,161)
print(" "+CURRENT_DRIVE+":") print(" "+CURRENT_DRIVE+":")
con.color_pair(161,253) con.color_pair(161,253)
@@ -49,9 +60,9 @@ function print_prompt_text() {
else { else {
// con.color_pair(253,255) // con.color_pair(253,255)
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined) if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT) print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
else else
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT) print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
} }
} }
@@ -620,6 +631,21 @@ shell.coreutils = {
}, },
panic: function(args) { panic: function(args) {
throw Error("Panicking command.js") throw Error("Panicking command.js")
},
chvt: function(args) {
// Request a switch to another virtual console. Only meaningful when
// running inside a pane spawned by vtmgr (VT_CTRL_ADDR is set by the
// pane bootstrap). Outside that environment this is a no-op error.
if (args[1] === undefined) { printerrln("Usage: chvt N (1..6)"); return 1 }
let n = parseInt(args[1])
if (isNaN(n) || n < 1 || n > 6) { printerrln("chvt: N must be in 1..6"); return 1 }
if (typeof VT_CTRL_ADDR === "undefined") {
printerrln("chvt: not running under vtmgr (no VT context)"); return 1
}
// CTRL_SWITCH_REQUEST is byte +1 of the shared CTRL area. Dispatcher
// picks this up on its next 30 Hz tick and performs the switch.
sys.poke(VT_CTRL_ADDR + 1, n)
return 0
} }
} }
// define command aliases here // define command aliases here
@@ -636,10 +662,14 @@ shell.coreutils.where = shell.coreutils.which
Object.freeze(shell.coreutils) Object.freeze(shell.coreutils)
shell.stdio = { shell.stdio = {
out: { out: {
print: function(s) { sys.print(s) }, // When running inside a vtmgr virtual console, __VT_OUT routes output
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") }, // to the pane's text-plane buffer instead of the physical GPU (which
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") }, // the compositor would otherwise overwrite). Outside a VT the hook is
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") }, // absent and these fall through to sys.print exactly as before.
print: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.print(s); else sys.print(s) },
println: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.println(s); else { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") } },
printerr: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerr(s); else sys.print("\x1B[31m"+s+"\x1B[m") },
printerrln: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerrln(s); else { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") } },
}, },
pipe: { pipe: {
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); }, print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
@@ -955,6 +985,180 @@ shell.removePipe = function() {
Object.freeze(shell) Object.freeze(shell)
_G.shell = shell _G.shell = shell
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// TAB AUTOCOMPLETION
//
// Invoked by TAB at the interactive prompt. Only active when BOTH:
// 1. wintex.mjs is available (provides the selection popup), AND
// 2. goFancy == true.
// One candidate -> expand immediately (no popup).
// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to
// discard. The popup over-draws the screen without saving
// what was beneath it, so we snapshot the text plane before
// and copy it back after (the shell can't just redraw like a
// full-screen TUI — there's scrollback above the prompt).
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable.
let _acWin = undefined
function getAutocompleteWin() {
if (_acWin !== undefined) return _acWin
_acWin = null
try {
let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs)
if (w && typeof w.showDialog === "function") _acWin = w
} catch (e) {
debugprintln("command.js > autocomplete: wintex unavailable: " + e)
}
return _acWin
}
// 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 // ensure USERCONFIGPATH directory exists
@@ -1012,23 +1216,133 @@ if (goInteractive) {
print_prompt_text() print_prompt_text()
var cmdbuf = "" var cmdbuf = ""
var caret = 0 // insertion point within cmdbuf, 0..cmdbuf.length
// Self-contained line editor with a movable caret (so command.js does
// NOT depend on wintex being installed). The prompt has just been
// printed, so the current cursor marks where the editable text begins.
// We track that anchor and rebuild the on-screen line from it, decoding
// line-wrap ourselves so the maths holds in both the physical console
// and a vtmgr pane (whose con.move CLAMPS x instead of wrapping it).
let [baseY, baseX] = con.getyx() // 1-based
let termCols = con.getmaxyx()[1]
// absolute (y,x) on screen for caret index `idx`
function caretPos(idx) {
let abs = (baseX - 1) + idx
return [baseY + ((abs / termCols) | 0), (abs % termCols) + 1]
}
function gotoCaret() {
let [cy, cx] = caretPos(caret)
con.move(cy, cx)
}
// reprint cmdbuf from index `from` to the end, optionally padding with
// `clearTrail` blanks to wipe characters left over by a now-shorter
// line, then park the hardware cursor back on the caret.
function refresh(from, clearTrail) {
let [py, px] = caretPos(from)
con.move(py, px)
print(cmdbuf.substring(from))
for (let i = 0; i < clearTrail; i++) print(" ")
gotoCaret()
}
// replace the whole buffer (used by history recall)
function setBuf(next) {
let oldLen = cmdbuf.length
cmdbuf = next
caret = cmdbuf.length
refresh(0, Math.max(0, oldLen - cmdbuf.length))
}
// Replace the word [wordStart, caret) with `value`, keeping any text to
// the right of the caret, then reprint the line from `wordStart`.
function applyCompletion(wordStart, value) {
let oldLen = cmdbuf.length
cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret)
caret = wordStart + value.length
con.color_pair(shell.usrcfg.textCol, 255)
refresh(wordStart, Math.max(0, oldLen - cmdbuf.length))
}
// TAB handler. No-op unless fancy mode is on and wintex is installed.
function tryAutocomplete() {
if (!goFancy) return
let win = getAutocompleteWin()
if (!win) return
let comp = computeCompletion(cmdbuf, caret)
let cands = comp.candidates
if (cands.length === 0) return
if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return }
_acSnapshotScreen()
let chosen = _acShowPopup(win, cands)
_acRestoreScreen()
// The popup drives input through input.withEvent (physical held-key
// state), which bypasses the buffer con.getch reads. Inside a vtmgr
// pane the dispatcher keeps draining physical keystrokes into this
// pane's input ring the whole time the popup is open, so the navigation
// keys (and the closing Enter) would otherwise surface as phantom input
// afterwards. Flush them. (On the physical console readKey self-clears,
// so this is harmless there.)
con.resetkeybuf()
// The popup hid the caret and clobbered colours; restore the prompt
// editing state. The screen content is already back from the snapshot.
con.curs_set(1)
con.color_pair(shell.usrcfg.textCol, 255)
gotoCaret()
if (chosen) applyCompletion(comp.wordStart, chosen.value)
}
while (true) { while (true) {
let key = con.getch() let key = con.getch()
// printable chars // printable chars
if (key >= 32 && key <= 126) { if (key >= 32 && key <= 126) {
var s = String.fromCharCode(key) let s = String.fromCharCode(key)
cmdbuf += s let atEnd = (caret === cmdbuf.length)
print(s) cmdbuf = cmdbuf.substring(0, caret) + s + cmdbuf.substring(caret)
caret += 1
if (atEnd) print(s) // fast path: simple append
else refresh(caret - 1, 0)
} }
// backspace // TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) { else if (key === con.KEY_TAB) {
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1) tryAutocomplete()
print(String.fromCharCode(key)) }
// backspace: delete the char to the left of the caret
else if (key === con.KEY_BACKSPACE && caret > 0) {
cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret)
caret -= 1
refresh(caret, 1)
}
// forward delete: delete the char under the caret
else if (key === con.KEY_DELETE && caret < cmdbuf.length) {
cmdbuf = cmdbuf.substring(0, caret) + cmdbuf.substring(caret + 1)
refresh(caret, 1)
}
// caret left
else if (key === con.KEY_LEFT) {
if (caret > 0) { caret -= 1; gotoCaret() }
}
// caret right
else if (key === con.KEY_RIGHT) {
if (caret < cmdbuf.length) { caret += 1; gotoCaret() }
}
// jump to start of line
else if (key === con.KEY_HOME) {
caret = 0; gotoCaret()
}
// jump to end of line
else if (key === con.KEY_END) {
caret = cmdbuf.length; gotoCaret()
} }
// enter // enter
else if (key === 10 || key === con.KEY_RETURN) { else if (key === 10 || key === con.KEY_RETURN) {
caret = cmdbuf.length; gotoCaret()
println() println()
errorlevel = shell.execute(cmdbuf) errorlevel = shell.execute(cmdbuf)
@@ -1044,32 +1358,17 @@ if (goInteractive) {
// up arrow // up arrow
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) { else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
cmdHistoryScroll += 1 cmdHistoryScroll += 1
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
// back the cursor in order to type new cmd
var x = 0
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
// re-type the new command
print(cmdbuf)
} }
// down arrow // down arrow
else if (key === con.KEY_DOWN) { else if (key === con.KEY_DOWN) {
if (cmdHistoryScroll > 0) { if (cmdHistoryScroll > 1) {
// back the cursor in order to type new cmd
var x = 0
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
// re-type the new command
print(cmdbuf)
cmdHistoryScroll -= 1 cmdHistoryScroll -= 1
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
} }
else { else if (cmdHistoryScroll === 1) {
// back the cursor in order to type new cmd cmdHistoryScroll = 0
var x = 0 setBuf("")
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
cmdbuf = ""
} }
} }
} }

View File

@@ -1042,12 +1042,14 @@ const colVoiceHdr = 230
const colVoiceHdrMuted = 249 const colVoiceHdrMuted = 249
const colVoiceHdrMutedCursorUp = 180 const colVoiceHdrMutedCursorUp = 180
const colSep = 252 const colSep = 252
const colScrollBar = 249
const colPushBtnBack = 143 const colPushBtnBack = 143
const colTabBarBack = 187 const colTabBarBack = 187
const colTabBarBack2 = 136 const colTabBarBack2 = 136
const colTabBarOrn = 136 const colTabBarOrn = 136
const colBrand = 211 const colBrand = 211
const colPopupBack = 244 const colPopupBack = 244
const colPopupBack2 = 243
const colTabActive = 239 const colTabActive = 239
const colTabInactive = 45 const colTabInactive = 45
@@ -1230,7 +1232,7 @@ function drawSeparators(style) {
for (let x = PTNVIEW_OFFSET_X; x < SCRW - 3; x += COLSIZE_TIMELINE_FULL) { for (let x = PTNVIEW_OFFSET_X; x < SCRW - 3; x += COLSIZE_TIMELINE_FULL) {
for (let y = 0; y < PTNVIEW_HEIGHT+1; y++) { for (let y = 0; y < PTNVIEW_HEIGHT+1; y++) {
let memOffset = (y+PTNVIEW_OFFSET_Y-2) * SCRW + (x-1) let memOffset = (y+PTNVIEW_OFFSET_Y-2) * SCRW + (x-1)
let bgColOffset = GPU_MEM - TEXT_BACK_OFF - memOffset let bgColOffset = vaddr(TEXT_BACK_OFF + memOffset)
let oldBgCol = sys.peek(bgColOffset) let oldBgCol = sys.peek(bgColOffset)
if (oldBgCol == 255) { if (oldBgCol == 255) {
sys.poke(bgColOffset, colColumnSep) sys.poke(bgColOffset, colColumnSep)
@@ -1805,6 +1807,17 @@ const TEXT_BACK_OFF = 2 + 2560
const TEXT_CHAR_OFF = 2 + 2560 + 2560 const TEXT_CHAR_OFF = 2 + 2560 + 2560
const TEXT_PLANES = [TEXT_CHAR_OFF, TEXT_BACK_OFF, TEXT_FORE_OFF] const TEXT_PLANES = [TEXT_CHAR_OFF, TEXT_BACK_OFF, TEXT_FORE_OFF]
// Direct text-VRAM addressing. On real hardware the GPU text area is addressed
// backward (byte m at GPU_MEM - m). Under vtmgr's virtual consoles the physical
// GPU is owned by the compositor, so direct writes must instead target this
// pane's forward text-plane buffer (VT_TEXT_PLANE + m), which the compositor
// blits to the screen. vaddr(m) returns the address of text-area byte m for the
// current environment; the physical branch is identical to the old arithmetic.
const _VT_VRAM = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
const VRAM_BASE = _VT_VRAM ? globalThis.VT_TEXT_PLANE : GPU_MEM
const VRAM_SGN = _VT_VRAM ? 1 : -1
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
// One scratch strip, reused across shifts // One scratch strip, reused across shifts
const SCRATCH_PTR = sys.malloc(SCRW * PTNVIEW_HEIGHT) const SCRATCH_PTR = sys.malloc(SCRW * PTNVIEW_HEIGHT)
@@ -1827,8 +1840,8 @@ function shiftPatternArea(dy) {
for (let p = 0; p < 3; p++) { for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p] const chanOff = TEXT_PLANES[p]
const srcAddr = GPU_MEM - chanOff - (srcTopY - 1) * SCRW const srcAddr = vaddr(chanOff + (srcTopY - 1) * SCRW)
const dstAddr = GPU_MEM - chanOff - (dstTopY - 1) * SCRW const dstAddr = vaddr(chanOff + (dstTopY - 1) * SCRW)
sys.memcpy(srcAddr, SCRATCH_PTR, stripBytes) sys.memcpy(srcAddr, SCRATCH_PTR, stripBytes)
sys.memcpy(SCRATCH_PTR, dstAddr, stripBytes) sys.memcpy(SCRATCH_PTR, dstAddr, stripBytes)
} }
@@ -1849,9 +1862,9 @@ function shiftPatternAreaHorizontal(dVoice) {
for (let p = 0; p < 3; p++) { for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p] const chanOff = TEXT_PLANES[p]
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) { for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW const idxBase = chanOff + (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, SALVAGE_HORIZ_LEN) sys.memcpy(vaddr(idxBase + srcOff), SCRATCH_PTR, SALVAGE_HORIZ_LEN)
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, SALVAGE_HORIZ_LEN) sys.memcpy(SCRATCH_PTR, vaddr(idxBase + dstOff), SALVAGE_HORIZ_LEN)
} }
} }
} }
@@ -2164,9 +2177,9 @@ function shiftOrdersAreaHorizontal(dVoice) {
for (let p = 0; p < 3; p++) { for (let p = 0; p < 3; p++) {
const chanOff = TEXT_PLANES[p] const chanOff = TEXT_PLANES[p]
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) { for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW const idxBase = chanOff + (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, stripWidth) sys.memcpy(vaddr(idxBase + srcOff), SCRATCH_PTR, stripWidth)
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, stripWidth) sys.memcpy(SCRATCH_PTR, vaddr(idxBase + dstOff), stripWidth)
} }
} }
} }
@@ -2909,9 +2922,9 @@ function makeExternalPanelDraw(progName) {
} }
// Row offsets (within the meta block at the top of the Project panel) of the editable rows. // Row offsets (within the meta block at the top of the Project panel) of the editable rows.
const PROJ_META_ROW_FLAGS = 5 const PROJ_META_ROW_FLAGS = 6
const PROJ_META_ROW_GVOL = 6 const PROJ_META_ROW_GVOL = 7
const PROJ_META_ROW_MVOL = 7 const PROJ_META_ROW_MVOL = 8
const PROJ_META_VALUE_X = 12 const PROJ_META_VALUE_X = 12
function drawProjectContents(wo) { function drawProjectContents(wo) {
@@ -2922,6 +2935,8 @@ function drawProjectContents(wo) {
let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3] let toneModeStr = ['Linear pitch','Amiga pitch','Linear freq',''][mixerflag & 3]
let intpModeStr = ['Default','None','A500','A1200','SNES','DPCM','',''][(mixerflag >>> 2) & 7] let intpModeStr = ['Default','None','A500','A1200','SNES','DPCM','',''][(mixerflag >>> 2) & 7]
let flagStrSelected = [toneModeStr, intpModeStr] let flagStrSelected = [toneModeStr, intpModeStr]
const sampleCount = (samplesCache || []).length
const sampleKStr = formatSampleRamK(computeSampleRAMBytes())
let projMeta = { let projMeta = {
@@ -2929,6 +2944,7 @@ function drawProjectContents(wo) {
ProjName: songsMeta.projectName || '(unnamed)', ProjName: songsMeta.projectName || '(unnamed)',
Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`, Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`,
Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`, Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`,
Samples: `${sampleCount} (${sampleKStr}k/${SMP_RAM_MAX_K}k)`,
Notation: pitchTablePresets[PITCH_PRESET_IDX].name, Notation: pitchTablePresets[PITCH_PRESET_IDX].name,
Flags: `${flagStrSelected.join(', ')} ($${mixerflag.hex02()})`, Flags: `${flagStrSelected.join(', ')} ($${mixerflag.hex02()})`,
GlobalVol: `$${initialGlobalVolume.hex02()}`, GlobalVol: `$${initialGlobalVolume.hex02()}`,
@@ -2961,7 +2977,7 @@ function drawProjectContents(wo) {
con.color_pair(colStatus, 255) // reset colour con.color_pair(colStatus, 255) // reset colour
} }
const PROJ_SONGLIST_Y = PTNVIEW_OFFSET_Y + 9 // header row of the song list const PROJ_SONGLIST_Y = PTNVIEW_OFFSET_Y + 10 // header row of the song list
const PROJ_SONGLIST_X = 2 const PROJ_SONGLIST_X = 2
function projectSongListRowsVisible() { function projectSongListRowsVisible() {
@@ -3217,6 +3233,13 @@ const colSmpUsedHdr = colVoiceHdr
const colSmpUsedFg = colInst const colSmpUsedFg = colInst
const colSmpWaveLine = 77 // bright cyan-ish; visible on dark bg const colSmpWaveLine = 77 // bright cyan-ish; visible on dark bg
const colSmpWaveMid = 246 // dim grey for zero-line const colSmpWaveMid = 246 // dim grey for zero-line
const colSmpWaveFunk = 221 // orange — loop bytes live-inverted by funk repeat (S$Fx)
// Funk-repeat introspection API (getVoiceFunkSpeed / getInstrumentFunkMask) ships with this
// feature; on an un-rebuilt host VM it's absent and the waveform stays the stored sample.
const hasFunkAPI = (typeof audio !== 'undefined' &&
typeof audio.getVoiceFunkSpeed === 'function' &&
typeof audio.getInstrumentFunkMask === 'function')
let smpListScroll = 0 let smpListScroll = 0
let smpListCursor = 0 let smpListCursor = 0
@@ -3260,7 +3283,7 @@ function drawSamplesListColumn() {
const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0) const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0)
for (let r = 0; r < SMP_LIST_H; r++) { for (let r = 0; r < SMP_LIST_H; r++) {
con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X) con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X)
con.color_pair(colStatus, colSmpListBg) con.color_pair(colScrollBar, colSmpListBg)
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
if (r == indPos) scrollChar += 3; if (r == indPos) scrollChar += 3;
@@ -3337,16 +3360,16 @@ function drawSamplesProperties() {
let smpUsedScroll = 0 let smpUsedScroll = 0
function drawSamplesUsedBy() { function drawSamplesUsedBy() {
const rightW = SCRW - SMP_RIGHT_X + 1
con.move(SMP_USED_Y, SMP_RIGHT_X)
con.color_pair(colSmpUsedHdr, colBackPtn)
print('Used by instruments:'.padEnd(rightW))
const s = (samplesCache && samplesCache[smpListCursor]) || null const s = (samplesCache && samplesCache[smpListCursor]) || null
const used = s ? s.usedBy : [] const used = s ? s.usedBy : []
const names = (songsMeta && songsMeta.instNames) || [] const names = (songsMeta && songsMeta.instNames) || []
const visible = SMP_USED_LIST_H const visible = SMP_USED_LIST_H
const rightW = SCRW - SMP_RIGHT_X + 1
con.move(SMP_USED_Y, SMP_RIGHT_X)
con.color_pair(colSmpUsedHdr, colBackPtn)
print(`Used by instruments (${used.length}):`.padEnd(rightW))
if (smpUsedScroll > Math.max(0, used.length - visible)) if (smpUsedScroll > Math.max(0, used.length - visible))
smpUsedScroll = Math.max(0, used.length - visible) smpUsedScroll = Math.max(0, used.length - visible)
if (smpUsedScroll < 0) smpUsedScroll = 0 if (smpUsedScroll < 0) smpUsedScroll = 0
@@ -3372,10 +3395,11 @@ function drawSamplesUsedBy() {
} }
// ── Waveform rendering ────────────────────────────────────────────────────── // ── Waveform rendering ──────────────────────────────────────────────────────
// Renders one sample under the right panel as a min/max envelope, using the // Renders one sample under the right panel as baseline-filled bars (each bar is
// graphics layer. Samples are unsigned 8-bit; bank-switch is required because // a plotRect anchored at the zero line, extending to the sample amplitude),
// only 512 K of the 8 MB pool is mapped at a time. We restore bank 0 (the // using the graphics layer. Samples are unsigned 8-bit; bank-switch is required
// playback-expected default) when done. // because only 512 K of the 8 MB pool is mapped at a time. We restore bank 0
// (the playback-expected default) when done.
// Pixel rect occupied by the waveform inside the Samples viewer. Both the // Pixel rect occupied by the waveform inside the Samples viewer. Both the
// waveform painter and the leave-Samples cleanup need to reach for the same // waveform painter and the leave-Samples cleanup need to reach for the same
@@ -3394,6 +3418,46 @@ function clearSampleWaveformArea() {
graphics.plotRect(r.x-2, r.y-2, r.w+4, r.h+4, 255) // 255 = transparent graphics.plotRect(r.x-2, r.y-2, r.w+4, r.h+4, 255) // 255 = transparent
} }
// Instrument slot of an active voice that's funk-repeating (S$Fx) one of the sample's `usedBy`
// instruments, or -1. Returns -1 when not playing / no funking voice / API absent. Drives the
// per-frame repaint cadence only — the *displayed* mask comes from funkMaskForSample, which also
// honours masks that persist after the funking voice has gone idle.
function findFunkInstForSample(usedBy) {
if (!hasFunkAPI) return -1
const numVox = (song && song.numVoices) ? song.numVoices : NUM_VOICES
for (let v = 0; v < numVox; v++) {
if (!audio.getVoiceActive(PLAYHEAD, v)) continue
if (audio.getVoiceFunkSpeed(PLAYHEAD, v) <= 0) continue
const inst = audio.getVoiceInstrument(PLAYHEAD, v)
if (usedBy.indexOf(inst) >= 0) return inst
}
return -1
}
// Funk XOR mask to DISPLAY for this sample, or null. The per-instrument mask persists in the engine
// for the whole playback session (cleared only on stop-and-replay), so once a loop has been
// funk-inverted the overlay must stay even after the funking voice goes idle — matching ProTracker,
// whose destructive EFx edits never revert until the song is reloaded. Prefer an actively-funking
// instrument (its mask is live this frame); otherwise show any usedBy instrument that still carries
// a non-empty mask from earlier in the session.
function funkMaskForSample(usedBy, activeInst) {
if (!hasFunkAPI) return null
if (activeInst > 0) {
const m = audio.getInstrumentFunkMask(activeInst)
if (m && m.length > 0) return m
}
for (let i = 0; i < usedBy.length; i++) {
const m = audio.getInstrumentFunkMask(usedBy[i])
if (m && m.length > 0) return m
}
return null
}
// Whether a voice was actively funk-repeating the displayed sample on the last paint. Drives the
// per-frame repaint cadence in tickFunkWaveform (repaint while the live mask changes, plus one
// settling frame after it stops). The painted overlay itself persists — the engine keeps the mask.
let funkWaveLast = false
function drawSampleWaveform() { function drawSampleWaveform() {
const r = sampleWaveformRect() const r = sampleWaveformRect()
const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h const wx0 = r.x, wy0 = r.y, wW = r.w, wH = r.h
@@ -3402,53 +3466,132 @@ function drawSampleWaveform() {
clearSampleWaveformArea() clearSampleWaveformArea()
const s = (samplesCache && samplesCache[smpListCursor]) || null const s = (samplesCache && samplesCache[smpListCursor]) || null
if (!s || s.len === 0) return if (!s || s.len === 0) { funkWaveLast = false; return }
const bankIdxFirst = (s.ptr / TAUT_SBANK_SIZE) | 0 // Funk-repeat overlay. The per-instrument XOR mask flips loop-region bytes by 0xFF and persists
const bankOff = s.ptr - bankIdxFirst * TAUT_SBANK_SIZE // in the engine until stop-and-replay, so the overlay must remain even after the voice that
const memBase = audio.getMemAddr() // funked the sample goes idle — matching ProTracker's destructive EFx, whose inverted bytes
const prevBank = audio.getSampleBank() || 0 // never revert until the song is reloaded. We therefore key the overlay off the persisted mask,
// not off a currently-active funking voice. funkLE is clamped to the snapshot mask's coverage so
// Centre line // the bit lookup can never run off the (host) array.
graphics.plotRect(wx0, wy0 + (wH >>> 1), wW, 1, colSmpWaveMid) let funkMask = null, funkLS = 0, funkLE = 0
let activeFunk = false
// Walk the sample at one column per output pixel. For each column we read if (playbackMode !== PLAYMODE_NONE && s.loopEnd > s.loopStart) {
// a chunk and reduce to min/max; vertical extent comes from (max-min). const activeInst = findFunkInstForSample(s.usedBy)
// Bank switching is per-step: each output column may straddle banks. activeFunk = (activeInst > 0)
const samplesPerCol = Math.max(1, (s.len / wW) | 0) const m = funkMaskForSample(s.usedBy, activeInst)
let pos = 0 // byte offset into the sample, 0..len-1 if (m) {
let curBank = -1 funkMask = m
for (let col = 0; col < wW; col++) { funkLS = s.loopStart
const start = (col * s.len / wW) | 0 funkLE = Math.min(s.loopEnd, funkLS + m.length * 8)
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0)) }
if (end <= start) continue }
funkWaveLast = activeFunk
let mn = 255, mx = 0
// Step in coarse strides for speed when samples are long. const memBase = audio.getMemAddr()
const step = Math.max(1, ((end - start) / 8) | 0) const prevBank = audio.getSampleBank() || 0
for (let p = start; p < end; p += step) { let curBank = -1
const abs = s.ptr + p
const bank = (abs / TAUT_SBANK_SIZE) | 0 // Zero line and value→y mapping (unsigned 8-bit: 255 → top, 0 → bottom).
if (bank !== curBank) { const baseY = wy0 + (wH >>> 1)
audio.setSampleBank(bank) const yOf = (v) => wy0 + (((wH * (255 - v)) / 255) | 0)
curBank = bank
} // Read sample byte p (0..len-1) applying the live funk-flip overlay; sets the
const off = abs - bank * TAUT_SBANK_SIZE // shared `flippedAny` flag whenever a byte was inverted by the funk mask.
const v = sys.peek(memBase - off) & 0xFF let flippedAny = false
if (v < mn) mn = v const readByte = (p) => {
if (v > mx) mx = v const abs = s.ptr + p
const bank = (abs / TAUT_SBANK_SIZE) | 0
if (bank !== curBank) { audio.setSampleBank(bank); curBank = bank }
let v = sys.peek(memBase - (abs - bank * TAUT_SBANK_SIZE)) & 0xFF
if (funkMask !== null && p >= funkLS && p < funkLE) {
const k = p - funkLS
if ((funkMask[k >>> 3] >>> (k & 7)) & 1) { v ^= 0xFF; flippedAny = true }
}
return v
}
// Zero/baseline line
graphics.plotRect(wx0, baseY, wW, 1, colSmpWaveMid)
// Per-sample bar width: how many pixels each sample spans, at least 1px.
const rectW = Math.max(1, Math.ceil(wW / s.len))
if (s.len <= wW) {
// Fewer samples than pixels: one baseline-filled bar per sample.
for (let i = 0; i < s.len; i++) {
flippedAny = false
const yv = yOf(readByte(i))
const top = Math.min(baseY, yv)
graphics.plotRect(wx0 + ((i * wW / s.len) | 0), top, rectW,
Math.max(1, Math.abs(baseY - yv)),
flippedAny ? colSmpWaveFunk : colSmpWaveLine)
}
} else {
// More samples than pixels: reduce each 1px column to its min/max and
// fill from the baseline through the envelope (a solid filled waveform).
for (let col = 0; col < wW; col++) {
const start = (col * s.len / wW) | 0
const end = Math.min(s.len, (((col + 1) * s.len / wW) | 0))
if (end <= start) continue
const step = Math.max(1, ((end - start) / 8) | 0)
let mn = 255, mx = 0
flippedAny = false
for (let p = start; p < end; p += step) {
const v = readByte(p)
if (v < mn) mn = v
if (v > mx) mx = v
}
const yTop = Math.min(baseY, yOf(mx))
const yBot = Math.max(baseY, yOf(mn))
graphics.plotRect(wx0 + col, yTop, 1, Math.max(1, yBot - yTop + 1),
flippedAny ? colSmpWaveFunk : colSmpWaveLine)
} }
// unsigned 8-bit → centred around 128
const yTop = wy0 + ((wH * (255 - mx)) / 255) | 0
const yBot = wy0 + ((wH * (255 - mn)) / 255) | 0
const h = Math.max(1, yBot - yTop + 1)
graphics.plotRect(wx0 + col, yTop, 1, h, colSmpWaveLine)
} }
// Restore bank 0 for playback (engine expects bank 0 as default) // Restore bank 0 for playback (engine expects bank 0 as default)
audio.setSampleBank(prevBank) audio.setSampleBank(prevBank)
} }
// Per-frame driver: while a voice is funk-repeating the displayed sample, repaint the waveform
// each frame so the overlay tracks the live mask. One settling repaint fires after funk stops
// (funkWaveLast); the persisted overlay then stays until the engine clears the mask on replay.
function tickFunkWaveform() {
if (currentPanel !== VIEW_SAMPLES) { funkWaveLast = false; return }
const s = (samplesCache && samplesCache[smpListCursor]) || null
const funking = !!(s && s.len > 0 && playbackMode !== PLAYMODE_NONE &&
findFunkInstForSample(s.usedBy) > 0)
if (funking || funkWaveLast) drawSampleWaveform()
}
function computeSampleRAMBytes() {
if (!samplesCache) return 0
let total = 0
for (let i = 0; i < samplesCache.length; i++) total += samplesCache[i].len
return total
}
// 16 banks x 524288 = 8 MB = 8192k. Hardcoded to match the user-visible budget.
const SMP_RAM_MAX_K = 8192
function formatSampleRamK(bytes) {
const k = bytes / 1024
return (k < 10 ? k.toFixed(2)
: k < 100 ? k.toFixed(1)
: Math.round(k).toString())
}
function drawSamplesRamFooter() {
const bytes = computeSampleRAMBytes()
const ramStr = formatSampleRamK(bytes) + 'k / ' + SMP_RAM_MAX_K + 'k'
const y = PTNVIEW_OFFSET_Y//SMP_RIGHT_Y + SMP_PROP_H - 1
con.move(y, SCRW - 13)
// con.color_pair(colSmpPropLabel, colBackPtn)
// print(('Sample RAM' + ' ').substring(0, 10))
con.color_pair(colSmpPropValue, colBackPtn)
print(ramStr)
}
function drawSamplesEditButton() { function drawSamplesEditButton() {
const y = SMP_BTN_Y const y = SMP_BTN_Y
con.move(y, SMP_RIGHT_X) con.move(y, SMP_RIGHT_X)
@@ -3473,6 +3616,7 @@ function drawSamplesContents(wo) {
drawSamplesListColumn() drawSamplesListColumn()
drawSamplesSeparator() drawSamplesSeparator()
drawSamplesProperties() drawSamplesProperties()
drawSamplesRamFooter()
drawSamplesUsedBy() drawSamplesUsedBy()
drawSampleWaveform() drawSampleWaveform()
drawSamplesEditButton() drawSamplesEditButton()
@@ -3756,7 +3900,7 @@ function drawInstrumentsListColumn() {
const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0) const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0)
for (let r = 0; r < INST_LIST_H; r++) { for (let r = 0; r < INST_LIST_H; r++) {
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X) con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
con.color_pair(colStatus, colInstListBg) con.color_pair(colScrollBar, colInstListBg)
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
if (r == indPos) scrollChar += 3; if (r == indPos) scrollChar += 3;
@@ -4730,10 +4874,19 @@ function nudgeTickRate(delta) {
drawAlwaysOnElems() drawAlwaysOnElems()
} }
// Drop accumulated funk-repeat (S$Fx) run-state and loop-inversion masks so a fresh play
// starts deterministic instead of inheriting a prior session's funkSpeed / inverted bytes.
// Engine still keeps PT2-style persistence across a natural loop; older runtimes lacking the
// API simply retain the inversions.
function clearFunkState() {
if (typeof audio.resetFunkState === 'function') audio.resetFunkState(PLAYHEAD)
}
function startPlaySong() { function startPlaySong() {
restoreFullSongParams() restoreFullSongParams()
reuploadPatternsIfNeeded() reuploadPatternsIfNeeded()
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
clearFunkState()
audio.setCuePosition(PLAYHEAD, cueIdx) audio.setCuePosition(PLAYHEAD, cueIdx)
audio.setTrackerRow(PLAYHEAD, 0) audio.setTrackerRow(PLAYHEAD, 0)
cursorRow = 0 cursorRow = 0
@@ -4748,6 +4901,7 @@ function startPlayCue() {
restoreFullSongParams() restoreFullSongParams()
reuploadPatternsIfNeeded() reuploadPatternsIfNeeded()
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
clearFunkState()
audio.setCuePosition(PLAYHEAD, cueIdx) audio.setCuePosition(PLAYHEAD, cueIdx)
audio.setTrackerRow(PLAYHEAD, 0) audio.setTrackerRow(PLAYHEAD, 0)
playStartCue = cueIdx playStartCue = cueIdx
@@ -4765,6 +4919,7 @@ function startPlayRow(fromRow, fromCue) {
if (fromRow === undefined) fromRow = cursorRow if (fromRow === undefined) fromRow = cursorRow
if (fromCue === undefined) fromCue = cueIdx if (fromCue === undefined) fromCue = cueIdx
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
clearFunkState()
audio.setCuePosition(PLAYHEAD, fromCue) audio.setCuePosition(PLAYHEAD, fromCue)
audio.setTrackerRow(PLAYHEAD, fromRow) audio.setTrackerRow(PLAYHEAD, fromRow)
playStartCue = fromCue playStartCue = fromCue
@@ -4781,6 +4936,7 @@ function startPlayPattern() {
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
audio.setBPM(PLAYHEAD, song.bpm) audio.setBPM(PLAYHEAD, song.bpm)
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx)) audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
clearFunkState()
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX) audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
audio.setTrackerRow(PLAYHEAD, 0) audio.setTrackerRow(PLAYHEAD, 0)
playStartCue = PREVIEW_CUE_IDX playStartCue = PREVIEW_CUE_IDX
@@ -4797,6 +4953,7 @@ function startPlayPatternRow() {
audio.stop(PLAYHEAD) audio.stop(PLAYHEAD)
audio.setBPM(PLAYHEAD, song.bpm) audio.setBPM(PLAYHEAD, song.bpm)
audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx)) audio.uploadCue(PREVIEW_CUE_IDX, buildPreviewCue(patternIdx))
clearFunkState()
audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX) audio.setCuePosition(PLAYHEAD, PREVIEW_CUE_IDX)
audio.setTrackerRow(PLAYHEAD, patternGridRow) audio.setTrackerRow(PLAYHEAD, patternGridRow)
playStartCue = PREVIEW_CUE_IDX playStartCue = PREVIEW_CUE_IDX
@@ -4817,6 +4974,7 @@ function stopPlayback() {
// pass ourselves so stale blobs / hairlines don't linger on Samples / Instruments. // pass ourselves so stale blobs / hairlines don't linger on Samples / Instruments.
drawSamplesPlayBlobs() drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs() drawInstrumentsPlayBlobs()
tickFunkWaveform() // restore the stored waveform now that funk repeat has stopped
drawSampleCursor() drawSampleCursor()
drawEnvelopeCursor() drawEnvelopeCursor()
} }
@@ -4834,6 +4992,7 @@ function updatePlayback() {
// playbackMode is NONE now → these paint a final blob0 / clear-cursor pass. // playbackMode is NONE now → these paint a final blob0 / clear-cursor pass.
drawSamplesPlayBlobs() drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs() drawInstrumentsPlayBlobs()
tickFunkWaveform() // restore the stored waveform now that playback has stopped
drawSampleCursor() drawSampleCursor()
drawEnvelopeCursor() drawEnvelopeCursor()
return return
@@ -4842,6 +5001,7 @@ function updatePlayback() {
drawVoiceMeters() drawVoiceMeters()
drawSamplesPlayBlobs() drawSamplesPlayBlobs()
drawInstrumentsPlayBlobs() drawInstrumentsPlayBlobs()
tickFunkWaveform() // realtime funk-repeat overlay (no-op unless funking this sample)
drawSampleCursor() drawSampleCursor()
drawEnvelopeCursor() drawEnvelopeCursor()
@@ -4985,6 +5145,7 @@ function openHelpPopup() {
bg: colPopupBack, bg: colPopupBack,
height: HELP_CONTENT_H, height: HELP_CONTENT_H,
width: HELP_CONTENT_W+4, width: HELP_CONTENT_W+4,
scrollbarChars: popupScrollbarChars,
selectable: () => false, selectable: () => false,
renderItem: (ctx) => { renderItem: (ctx) => {
con.color_pair(colText, ctx.listBg) con.color_pair(colText, ctx.listBg)
@@ -5029,6 +5190,12 @@ const popupDrawFrame = (wo) => {
} }
} }
// Taut's charset carries dedicated scrollbar glyphs at 0xBA..0xBF (empty
// top/mid/bottom caps 0xBA..0xBC, filled top/mid/bottom thumb 0xBD..0xBF).
// wintex defaults to the CP437-safe 0xBA/0xDB pair, so pass these to every
// list popup to render the scrollbar in taut's style.
const popupScrollbarChars = [0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF]
// Standard colour palette shared by every taut popup so wintex's defaults blend // Standard colour palette shared by every taut popup so wintex's defaults blend
// with taut's popup chrome. // with taut's popup chrome.
const popupColours = { const popupColours = {
@@ -5143,6 +5310,7 @@ function openRetunePopup() {
height: listH, height: listH,
width: 36, width: 36,
cursor: selIdx, cursor: selIdx,
scrollbarChars: popupScrollbarChars,
renderItem: (ctx) => { renderItem: (ctx) => {
const e = ctx.item.preset const e = ctx.item.preset
const isCur = (e.index === PITCH_PRESET_IDX) const isCur = (e.index === PITCH_PRESET_IDX)
@@ -5212,7 +5380,9 @@ function openFlagsPopup() {
items: items, items: items,
height: items.length, height: items.length,
width: 22, width: 22,
drawWell: false,
showScrollbar: false, showScrollbar: false,
scrollbarChars: popupScrollbarChars,
selectable: (it) => it.kind === 'tone' || it.kind === 'intp', selectable: (it) => it.kind === 'tone' || it.kind === 'intp',
renderItem: (ctx) => { renderItem: (ctx) => {
const it = ctx.item const it = ctx.item

Binary file not shown.

View File

@@ -332,12 +332,6 @@ const AG_BX_TL = 0xC9, AG_BX_TR = 0xBB, AG_BX_BL = 0xC8, AG_BX_BR = 0xBC
const AG_BX_V = 0xBA, AG_BX_H = 0xCD const AG_BX_V = 0xBA, AG_BX_H = 0xCD
const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6 const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6
// Half-block glyphs for wavescope
const AG_HB_NONE = 0x20 // ' '
const AG_HB_TOP = 0xDF // '▀'
const AG_HB_BOT = 0xDC // '▄'
const AG_HB_BOTH = 0xDB // '█'
// Density stairs for visualiser + stereo bar // Density stairs for visualiser + stereo bar
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █ const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
@@ -596,17 +590,358 @@ function ag_analyseHaar() {
ag_bassEnergy = bassRms > 1 ? 1 : bassRms ag_bassEnergy = bassRms > 1 ? 1 : bassRms
} }
// ── Mini-AAlib (embedded, for the wavescope) ───────────────────────────────
//
// Stripped port of `disk0/hopper/include/aa.mjs`, sized to one job: convert a
// small pixel-space brightness buffer into ASCII glyphs with three monochrome
// intensities (DIM / NORMAL / BOLD). No dither. No brightness / contrast /
// gamma / inversion. No REVERSE / SPECIAL / BOLDFONT attribute support.
// See aa.mjs for the full algorithm, credits (Jan Hubicka & the AA-group,
// 1997), and the long-form comments — those are not duplicated here.
//
// Tables (params + 65536-entry LUT + filltable) are built once on first use
// from the TSVM 7×14 font ROM, so the wavescope's glyph-selection matches the
// brightness profile of the cells the hardware text mode actually paints.
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
const AA_NORMAL = 0
const AA_DIM = 1
const AA_BOLD = 2
const AA_NATTRS = 3
const AA_NCHARS = 256 * AA_NATTRS
const AA_DIMMUL = 5.3
const AA_BOLDMUL = 2.7
const AA_MUL = 8
const AA_VAL = 13 // uniform-cell threshold
const AA_PRIORITY = [4, 5, 3] // NORMAL, DIM, BOLD (matches aalib)
let aa_font = null // { width, height, data }
let aa_params = null // Uint16Array((NCHARS+1)*5)
let aa_table = null // Uint16Array(65536)
let aa_filltable = null // Uint16Array(256)
function aa_loadFont() {
if (aa_font) return aa_font
const fh = files.open(AA_FONT_PATH)
if (!fh.exists) throw Error("playgui: font ROM not found: " + AA_FONT_PATH)
const blob = fh.bread()
const FW = 7, FH = 14, ROM = 1920
if (blob.length !== ROM && blob.length !== ROM * 2) {
throw Error("playgui: bad font ROM size " + blob.length)
}
const data = new Uint8Array(256 * FW * FH)
const halves = blob.length / ROM
const startHalf = (halves === 2) ? 0 : 1
for (let h = 0; h < halves; h++) {
const romStart = h * ROM
const charBase = (startHalf + h) * 128
for (let c = 0; c < 128; c++) {
const srcBase = romStart + c * FH
const dstBase = (charBase + c) * FW * FH
for (let r = 0; r < FH; r++) {
const b = blob[srcBase + r] & 0xFF
for (let x = 0; x < FW; x++) {
data[dstBase + r * FW + x] = ((b >> (6 - x)) & 1) ? 0xFF : 0x00
}
}
}
}
aa_font = { width: FW, height: FH, data: data }
return aa_font
}
function aa_alowed(i) {
const c = i & 0xff
const attr = (i >>> 8)
if (attr >= AA_NATTRS) return false
// printable ASCII, space, or extended (>160) — keep AA_EIGHT chars so the
// glyph palette includes the TSVM ROM's box-drawing / shade / dot range.
if (!(c >= 33 && c <= 126) && c !== 0x20 && !(c > 160)) return false
return true
}
// (NE, NW, SE, SW) brightness for glyph `code` under `attr`. Quadrant labelling
// follows aalib's bit-numbering quirk; the LUT lookup later swaps the halves
// back to natural orientation. See aa.mjs:_glyphValues for the long-form note.
function aa_glyphValues(code, attr, out) {
const fd = aa_font.data
const fw = aa_font.width
const fh = aa_font.height
const base = code * fw * fh
const halfW = fw >> 1
const halfH = fh >> 1
const leftW = halfW
const topH = halfH
let v1 = 0, v2 = 0, v3 = 0, v4 = 0
for (let r = 0; r < topH; r++) {
const rowBase = base + r * fw
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v2++
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v1++
}
for (let r = topH; r < fh; r++) {
const rowBase = base + r * fw
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v4++
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v3++
}
v1 *= AA_MUL; v2 *= AA_MUL; v3 *= AA_MUL; v4 *= AA_MUL
if (attr === AA_DIM) {
v1 = (v1 + 1) / AA_DIMMUL
v2 = (v2 + 1) / AA_DIMMUL
v3 = (v3 + 1) / AA_DIMMUL
v4 = (v4 + 1) / AA_DIMMUL
} else if (attr === AA_BOLD) {
v1 *= AA_BOLDMUL
v2 *= AA_BOLDMUL
v3 *= AA_BOLDMUL
v4 *= AA_BOLDMUL
}
out[0] = v1; out[1] = v2; out[2] = v3; out[3] = v4
}
function aa_calcparams() {
aa_loadFont()
aa_params = new Uint16Array((AA_NCHARS + 1) * 5)
const tmp = new Float64Array(4)
let ma1 = 0, ma2 = 0, ma3 = 0, ma4 = 0, msum = 0
let mi1 = 50000, mi2 = 50000, mi3 = 50000, mi4 = 50000, misum = 50000
for (let i = 0; i < AA_NCHARS; i++) {
if (!aa_alowed(i)) continue
aa_glyphValues(i & 0xff, i >>> 8, tmp)
const v1 = tmp[0], v2 = tmp[1], v3 = tmp[2], v4 = tmp[3]
if (v1 > ma1) ma1 = v1
if (v2 > ma2) ma2 = v2
if (v3 > ma3) ma3 = v3
if (v4 > ma4) ma4 = v4
const s = v1 + v2 + v3 + v4
if (s > msum) msum = s
if (v1 < mi1) mi1 = v1
if (v2 < mi2) mi2 = v2
if (v3 < mi3) mi3 = v3
if (v4 < mi4) mi4 = v4
if (s < misum) misum = s
}
msum -= misum
mi1 = misum / 4; mi2 = misum / 4; mi3 = misum / 4; mi4 = misum / 4
ma1 = msum / 4; ma2 = msum / 4; ma3 = msum / 4; ma4 = msum / 4
for (let i = 0; i < AA_NCHARS; i++) {
aa_glyphValues(i & 0xff, i >>> 8, tmp)
const v1r = tmp[0], v2r = tmp[1], v3r = tmp[2], v4r = tmp[3]
const sr = v1r + v2r + v3r + v4r
let sum = Math.floor((sr - misum) * (1020 / msum) + 0.5)
let v1 = Math.floor((v1r - mi1) * (255 / ma1) + 0.5)
let v2 = Math.floor((v2r - mi2) * (255 / ma2) + 0.5)
let v3 = Math.floor((v3r - mi3) * (255 / ma3) + 0.5)
let v4 = Math.floor((v4r - mi4) * (255 / ma4) + 0.5)
if (v1 > 255) v1 = 255; else if (v1 < 0) v1 = 0
if (v2 > 255) v2 = 255; else if (v2 < 0) v2 = 0
if (v3 > 255) v3 = 255; else if (v3 < 0) v3 = 0
if (v4 > 255) v4 = 255; else if (v4 < 0) v4 = 0
if (sum > 1020) sum = 1020; else if (sum < 0) sum = 0
aa_params[i * 5 + 0] = v1
aa_params[i * 5 + 1] = v2
aa_params[i * 5 + 2] = v3
aa_params[i * 5 + 3] = v4
aa_params[i * 5 + 4] = sum
}
}
function aa_pow2(x) { return x * x }
function aa_pos(i1, i2, i3, i4) { return (i1 << 12) + (i2 << 8) + (i3 << 4) + i4 }
function aa_dist(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
return 2 * (aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4))
+ aa_pow2(i5 - y5)
}
function aa_dist1(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
return aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4)
+ 2 * aa_pow2(i5 - y5)
}
function aa_mktable() {
if (!aa_params) aa_calcparams()
aa_table = new Uint16Array(65536)
aa_filltable = new Uint16Array(256)
const next = new Int32Array(65536)
for (let i = 0; i < 65536; i++) next[i] = i
let first = -1, last = -1
function add(i) {
if (next[i] === i && last !== i) {
if (last !== -1) { next[last] = i; last = i }
else { last = first = i }
}
}
for (let i = 0; i < AA_NCHARS; i++) {
if (!aa_alowed(i)) continue
const i1 = aa_params[i * 5 + 0]
const i2 = aa_params[i * 5 + 1]
const i3 = aa_params[i * 5 + 2]
const i4 = aa_params[i * 5 + 3]
const i5 = aa_params[i * 5 + 4]
const p1 = i1 >> 4, p2 = i2 >> 4, p3 = i3 >> 4, p4 = i4 >> 4
const p = aa_pos(p1, p2, p3, p4)
if (aa_table[p]) {
const ex = aa_table[p]
const ex1 = aa_params[ex * 5 + 0]
const ex2 = aa_params[ex * 5 + 1]
const ex3 = aa_params[ex * 5 + 2]
const ex4 = aa_params[ex * 5 + 3]
const ex5 = aa_params[ex * 5 + 4]
const pp1 = (p1 << 4) | p1
const pp2 = (p2 << 4) | p2
const pp3 = (p3 << 4) | p3
const pp4 = (p4 << 4) | p4
const ppsum = pp1 + pp2 + pp3 + pp4
const dNew = aa_dist(i1, i2, i3, i4, i5, pp1, pp2, pp3, pp4, ppsum)
const dOld = aa_dist(ex1, ex2, ex3, ex4, ex5, pp1, pp2, pp3, pp4, ppsum)
if (dNew > dOld) continue
if (dNew === dOld && AA_PRIORITY[(i >>> 8)] <= AA_PRIORITY[(ex >>> 8)]) continue
}
aa_table[p] = i
add(p)
}
for (let q = 0; q < 256; q++) {
let mindist = Infinity
let best = 0
for (let i = 0; i < AA_NCHARS; i++) {
if (!aa_alowed(i)) continue
const d1 = aa_dist1(aa_params[i * 5 + 0], aa_params[i * 5 + 1],
aa_params[i * 5 + 2], aa_params[i * 5 + 3],
aa_params[i * 5 + 4],
q, q, q, q, q * 4)
if (d1 < mindist ||
(d1 === mindist && AA_PRIORITY[(i >>> 8)] > AA_PRIORITY[(best >>> 8)])) {
aa_filltable[q] = i
mindist = d1
best = i
}
}
}
// BFS propagation: claim neighbour slots that we cover better than whoever
// got there first. Lifted verbatim from aamktabl.c via aa.mjs.
while (true) {
if (last !== -1) next[last] = last
else break
const blocked = last
let i = first
if (i === -1) break
first = -1; last = -1
let prev
do {
const m0 = (i >> 12) & 15
const m1 = (i >> 8) & 15
const m2 = (i >> 4) & 15
const m3 = i & 15
const c = aa_table[i]
const cp0 = aa_params[c * 5 + 0]
const cp1 = aa_params[c * 5 + 1]
const cp2 = aa_params[c * 5 + 2]
const cp3 = aa_params[c * 5 + 3]
const cp4 = aa_params[c * 5 + 4]
for (let dm = 0; dm < 4; dm++) {
for (let sgn = -1; sgn <= 1; sgn += 2) {
let n0 = m0, n1 = m1, n2 = m2, n3 = m3
if (dm === 0) { n0 += sgn; if (n0 < 0 || n0 >= 16) continue }
else if (dm === 1) { n1 += sgn; if (n1 < 0 || n1 >= 16) continue }
else if (dm === 2) { n2 += sgn; if (n2 < 0 || n2 >= 16) continue }
else { n3 += sgn; if (n3 < 0 || n3 >= 16) continue }
const index = aa_pos(n0, n1, n2, n3)
const ch = aa_table[index]
if (ch === c || index === blocked) continue
let replace = !ch
if (!replace) {
const ii1 = (n0 << 4) | n0
const ii2 = (n1 << 4) | n1
const ii3 = (n2 << 4) | n2
const ii4 = (n3 << 4) | n3
const iisum = ii1 + ii2 + ii3 + ii4
const dNew = aa_dist(ii1, ii2, ii3, ii4, iisum,
cp0, cp1, cp2, cp3, cp4)
const dOld = aa_dist(ii1, ii2, ii3, ii4, iisum,
aa_params[ch * 5 + 0],
aa_params[ch * 5 + 1],
aa_params[ch * 5 + 2],
aa_params[ch * 5 + 3],
aa_params[ch * 5 + 4])
if (dNew < dOld) replace = true
}
if (replace) { aa_table[index] = c; add(index) }
}
}
prev = i
i = next[i]
next[prev] = prev
} while (i !== prev)
}
}
// Render an imgW × imgH brightness buffer (imgW = scrW*2, imgH = scrH*2) into
// per-cell (glyph, attr) outputs. No dither, no params.
function aa_render(img, scrW, scrH, tbOut, attrOut) {
if (!aa_table) aa_mktable()
const tbl = aa_table
const fill = aa_filltable
const wi = scrW * 2
for (let y = 0; y < scrH; y++) {
let pos = 2 * y * wi
let pos1 = y * scrW
for (let x = 0; x < scrW; x++) {
const i1 = img[pos + 1] // NE
const i2 = img[pos] // NW
const i3 = img[pos + wi + 1] // SE
const i4 = img[pos + wi] // SW
const s = i1 + i2 + i3 + i4
const avg = s >> 2
let val
if (Math.abs(i1 - avg) < AA_VAL &&
Math.abs(i2 - avg) < AA_VAL &&
Math.abs(i3 - avg) < AA_VAL &&
Math.abs(i4 - avg) < AA_VAL) {
val = fill[avg]
} else {
val = tbl[((i2 >> 4) << 12) | ((i1 >> 4) << 8) |
((i4 >> 4) << 4) | (i3 >> 4)]
}
attrOut[pos1] = val >> 8
tbOut[pos1] = val & 0xff
pos += 2
pos1 += 1
}
}
}
// ── Wavescope (rows 3..5) ────────────────────────────────────────────────── // ── Wavescope (rows 3..5) ──────────────────────────────────────────────────
// //
// Peak-detected envelope: each column shows the range [min, max] of its slice // Peak-detected envelope plotted into a 156×6 pixel buffer (2× cell res),
// of the snapshot using half-block characters for 6 vertical sub-positions. // then converted to ASCII glyphs by the mini-AAlib above. Mid-signal only —
// Mid-signal only — for stereo information you read the bottom bar. // stereo info lives on the bottom bar.
//
// Three monochrome intensities pick out the wave's body / peaks: DIM cells
// are the dim trace, NORMAL cells are the bulk of the waveform, BOLD cells
// land on the brightest patches (full-blocked peaks). Amber → white ramp
// mimics phosphor bloom.
const AA_WAVE_W = AG_LANE_W // 78 cells
const AA_WAVE_H = AG_ROW_WAVE_BOT - AG_ROW_WAVE_TOP + 1 // 3 cells
const AA_WAVE_IW = AA_WAVE_W * 2 // 156 px
const AA_WAVE_IH = AA_WAVE_H * 2 // 6 px
const ag_waveImg = new Uint8Array(AA_WAVE_IW * AA_WAVE_IH)
const ag_waveTb = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
const ag_waveAttr = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
// AA_NORMAL=0, AA_DIM=1, AA_BOLD=2 → amber phosphor palette.
const AG_WAVE_FG = [166, 130, AG_COL_LABEL]
function ag_drawWavescope() { function ag_drawWavescope() {
const N = AG_SNAPSHOT_N const N = AG_SNAPSHOT_N
const samplesPerCol = N / AG_LANE_W const IW = AA_WAVE_IW
// 6 sub-positions: 0..5 from top to bottom. const IH = AA_WAVE_IH
for (let c = 0; c < AG_LANE_W; c++) { const img = ag_waveImg
img.fill(0)
// Per-pixel-column envelope: vertical line from max to min sample value.
const samplesPerCol = N / IW
const yScale = (IH - 1) * 0.5
for (let c = 0; c < IW; c++) {
const s = (c * samplesPerCol) | 0 const s = (c * samplesPerCol) | 0
const e = (((c + 1) * samplesPerCol) | 0) const e = (((c + 1) * samplesPerCol) | 0)
let mn = 1.0, mx = -1.0 let mn = 1.0, mx = -1.0
@@ -615,26 +950,27 @@ function ag_drawWavescope() {
if (v < mn) mn = v if (v < mn) mn = v
if (v > mx) mx = v if (v > mx) mx = v
} }
// Map [-1, 1] → [0, 5] (top..bottom). +1 → 0, -1 → 5. // [-1, 1] → [0, IH-1]; +1 sits at the top, -1 at the bottom.
let yMax = ((1 - mx) * 0.5 * 6) | 0 let yT = ((1 - mx) * yScale + 0.5) | 0
let yMin = ((1 - mn) * 0.5 * 6) | 0 let yB = ((1 - mn) * yScale + 0.5) | 0
if (yMax < 0) yMax = 0; if (yMax > 5) yMax = 5 if (yT < 0) yT = 0; else if (yT > IH - 1) yT = IH - 1
if (yMin < 0) yMin = 0; if (yMin > 5) yMin = 5 if (yB < 0) yB = 0; else if (yB > IH - 1) yB = IH - 1
// yMax is the top of the bar (smaller y = higher up), yMin is bottom. for (let y = yT; y <= yB; y++) img[y * IW + c] = 0xFF
for (let row = 0; row < 3; row++) { }
const subTop = row * 2
const subBot = row * 2 + 1 aa_render(img, AA_WAVE_W, AA_WAVE_H, ag_waveTb, ag_waveAttr)
const hitTop = (yMax <= subTop) && (yMin >= subTop)
const hitBot = (yMax <= subBot) && (yMin >= subBot) // Blit, skipping cells whose packed (attr<<8 | glyph) key is unchanged.
let g = AG_HB_NONE for (let r = 0; r < AA_WAVE_H; r++) {
if (hitTop && hitBot) g = AG_HB_BOTH for (let c = 0; c < AA_WAVE_W; c++) {
else if (hitTop) g = AG_HB_TOP const idx = r * AA_WAVE_W + c
else if (hitBot) g = AG_HB_BOT const att = ag_waveAttr[idx]
const idx = row * AG_LANE_W + c const ch = ag_waveTb[idx]
if (ag_waveGlyph[idx] === g) continue const key = (att << 8) | ch
ag_waveGlyph[idx] = g if (ag_waveGlyph[idx] === key) continue
ag_color(AG_COL_LABEL, AG_COL_BG) ag_waveGlyph[idx] = key
ag_mvprn(AG_ROW_WAVE_TOP + row, AG_COL_INSIDE_L + c, g) ag_color(AG_WAVE_FG[att] || AG_COL_LABEL, AG_COL_BG)
ag_mvprn(AG_ROW_WAVE_TOP + r, AG_COL_INSIDE_L + c, ch)
} }
} }
} }

View File

@@ -83,11 +83,13 @@ function uploadTaudFile(inFile, songIndex, playhead) {
pos = 8 pos = 8
// -- 3. Parse header ------------------------------------------------------ // -- 3. Parse header ------------------------------------------------------
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + signature(14)
// = 32 bytes (terranmon.txt §Header).
let version = sys.peek(filePtr + pos) & 0xFF; pos++ let version = sys.peek(filePtr + pos) & 0xFF; pos++
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++ let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
let compressedSize = _peekU32LE(filePtr, pos); pos += 4 let compressedSize = _peekU32LE(filePtr, pos); pos += 4
pos += 18 // skip reserved(2) + signature(16) let projOff = _peekU32LE(filePtr, pos); pos += 4
pos += 14 // signature
// pos == 32 == TAUD_HEADER_SIZE // pos == 32 == TAUD_HEADER_SIZE
if (songIndex < 0 || songIndex >= numSongs) { if (songIndex < 0 || songIndex >= numSongs) {
@@ -155,6 +157,50 @@ function uploadTaudFile(inFile, songIndex, playhead) {
audio.setSongGlobalVolume(playhead, songGlobalVolume) audio.setSongGlobalVolume(playhead, songGlobalVolume)
audio.setSongMixingVolume(playhead, songMixingVolume) audio.setSongMixingVolume(playhead, songMixingVolume)
// -- 9. Project Data — walk Ixmp blocks for multi-sample instruments -----
// Terranmon spec: Project Data starts at `projOff` (zero = absent), magic is
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,
// etc.) are skipped so the player apps remain free to parse them.
if (projOff !== 0 && projOff + 16 <= fileSize) {
const projMagic = [0x1E,0x54,0x61,0x75,0x64,0x50,0x72,0x4A] // \x1ETaudPrJ
let prjOk = true
for (let i = 0; i < 8; i++) {
if ((sys.peek(filePtr + projOff + i) & 0xFF) !== projMagic[i]) { prjOk = false; break }
}
if (prjOk) {
const PATCH_SIZE = 31
let p = projOff + 16 // skip magic(8) + reserved(8)
while (p + 8 <= fileSize) {
const fc = String.fromCharCode(
sys.peek(filePtr + p) & 0xFF, sys.peek(filePtr + p + 1) & 0xFF,
sys.peek(filePtr + p + 2) & 0xFF, sys.peek(filePtr + p + 3) & 0xFF)
const secLen = _peekU32LE(filePtr, p + 4)
const payload = p + 8
if (payload + secLen > fileSize) break
if (fc === 'Ixmp') {
// Each entry: Uint8 instId + Uint24 patchCount + (patchCount × PATCH_SIZE) bytes.
let q = payload
const qEnd = payload + secLen
while (q + 4 <= qEnd) {
const instId = sys.peek(filePtr + q) & 0xFF; q++
const cntLo = sys.peek(filePtr + q) & 0xFF; q++
const cntMid = sys.peek(filePtr + q) & 0xFF; q++
const cntHi = sys.peek(filePtr + q) & 0xFF; q++
const patchCnt = cntLo | (cntMid << 8) | (cntHi << 16)
const blobLen = patchCnt * PATCH_SIZE
if (q + blobLen > qEnd) break
let buf = new Array(blobLen)
for (let k = 0; k < blobLen; k++) buf[k] = sys.peek(filePtr + q + k) & 0xFF
audio.uploadInstrumentPatches(instId, buf)
q += blobLen
}
}
p = payload + secLen
}
}
}
fileHandle.close() fileHandle.close()
sys.free(filePtr) sys.free(filePtr)

View File

@@ -217,6 +217,14 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
// action string to close the dialog, // action string to close the dialog,
// or null to stay open. // or null to stay open.
// showScrollbar: bool?, -- default: auto (true when overflowing). // showScrollbar: bool?, -- default: auto (true when overflowing).
// scrollbarChars: number[6]?, -- glyph codes for the scrollbar:
// [troughTopEmpty, troughMidEmpty,
// troughBotEmpty, troughTopFilled,
// troughMidFilled, troughBotFilled].
// Default [0xBA,0xBA,0xBA,0xDB,0xDB,0xDB]
// (CP437-safe). Callers with a custom
// charset (e.g. taut) pass their own.
// drawWell: bool?, -- draw the list background
// bg: number?, -- list background colour (default 242). // bg: number?, -- list background colour (default 242).
// }, // },
// //
@@ -287,6 +295,8 @@ function showDialog(opts) {
: Array.isArray(message) ? message : Array.isArray(message) ? message
: ('' + message).split('\n') : ('' + message).split('\n')
const list = opts.list || null
const drawWell = list?.drawWell ?? true
const c = opts.colours || {} const c = opts.colours || {}
const fg = (c.fg != null) ? c.fg : 254 const fg = (c.fg != null) ? c.fg : 254
const bg = (c.bg != null) ? c.bg : 244 const bg = (c.bg != null) ? c.bg : 244
@@ -294,17 +304,22 @@ function showDialog(opts) {
const dimFg = (c.dimFg != null) ? c.dimFg : 249 const dimFg = (c.dimFg != null) ? c.dimFg : 249
const hlFg = (c.hlFg != null) ? c.hlFg : 240 const hlFg = (c.hlFg != null) ? c.hlFg : 240
const focusBg = (c.focusBg != null) ? c.focusBg : 253 const focusBg = (c.focusBg != null) ? c.focusBg : 253
const listBg = (c.listBg != null) ? c.listBg : 243 const listBg = (c.listBg != null) ? c.listBg : (drawWell) ? 243 : bg
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
// List state // List state
const list = opts.list || null
const listItems = list ? (list.items || []) : [] const listItems = list ? (list.items || []) : []
const listSelectable = list && list.selectable ? list.selectable : (() => true) const listSelectable = list && list.selectable ? list.selectable : (() => true)
const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0 const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0
const hasList = !!list const hasList = !!list
const listOnActivate = list ? list.onActivate : null const listOnActivate = list ? list.onActivate : null
const listBgColour = (list && list.bg != null) ? list.bg : listBg const listBgColour = (list && list.bg != null) ? list.bg : listBg
// Scrollbar glyphs: [trough top/mid/bottom empty, then top/mid/bottom filled].
// Default is CP437-safe (0xBA track, 0xDB thumb); callers with their own
// charset (e.g. taut's 0xBA..0xBF) pass a 6-item override.
const listScrollbarChars = (list && Array.isArray(list.scrollbarChars) && list.scrollbarChars.length >= 6)
? list.scrollbarChars
: [0xBA, 0xBA, 0xBA, 0xDB, 0xDB, 0xDB]
function firstSelectable(from, dir) { function firstSelectable(from, dir) {
if (!hasList || listItems.length === 0) return -1 if (!hasList || listItems.length === 0) return -1
let i = from let i = from
@@ -474,17 +489,21 @@ function showDialog(opts) {
const sbar = listScrollbarNeeded() const sbar = listScrollbarNeeded()
// Top border (drawField style) // Top border (drawField style)
con.color_pair(listBgColour, bg) if (drawWell) {
con.move(lbRow, lbCol) con.color_pair(listBgColour, bg)
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED') con.move(lbRow, lbCol)
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
}
// Side borders + rows // Side borders + rows
for (let r = 0; r < listHeight; r++) { for (let r = 0; r < listHeight; r++) {
con.color_pair(listBgColour, bg) if (drawWell) {
con.move(lbRow + 1 + r, lbCol) con.color_pair(listBgColour, bg)
print('\u00AB') con.move(lbRow + 1 + r, lbCol)
con.move(lbRow + 1 + r, lbCol + lw - 1) print('\u00AB')
print('\u00AA') con.move(lbRow + 1 + r, lbCol + lw - 1)
print('\u00AA')
}
const idx = listScroll + r const idx = listScroll + r
con.move(lbRow + 1 + r, lbCol + 1) con.move(lbRow + 1 + r, lbCol + 1)
@@ -525,16 +544,20 @@ function showDialog(opts) {
con.move(lbRow + 1 + r, lbCol + lw - 2) con.move(lbRow + 1 + r, lbCol + lw - 2)
const maxScroll = Math.max(1, listItems.length - listHeight) const maxScroll = Math.max(1, listItems.length - listHeight)
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0) const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB // seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
con.addch(r === indPos ? (trough + 3) : trough) // filled (thumb) variant over the empty (trough) one.
const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1
con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg])
} }
} }
// Bottom border // Bottom border
con.color_pair(listBgColour, bg) if (drawWell) {
con.move(lbRow + 1 + listHeight, lbCol) con.color_pair(listBgColour, bg)
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5') con.move(lbRow + 1 + listHeight, lbCol)
con.color_pair(fg, bg) print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
con.color_pair(fg, bg)
}
} }
function drawButton(i, regions) { function drawButton(i, regions) {

View 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

View File

@@ -57,6 +57,7 @@ from taud_common import (
normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob,
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
build_project_data, detect_subsongs, build_project_data, detect_subsongs,
IXMP_PAN_NO_OVERRIDE,
) )
@@ -435,7 +436,10 @@ class ITInstrument:
'vol_env_loop', 'pan_env_loop', 'pf_env_loop', 'vol_env_loop', 'pan_env_loop', 'pf_env_loop',
'vol_env_sus', 'pan_env_sus', 'pf_env_sus', 'vol_env_sus', 'pan_env_sus', 'pf_env_sus',
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna', 'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
'dct', 'dca') 'dct', 'dca', 'keyboard')
# keyboard: list[int], 120 entries — keyboard[it_note] = sample_1based (0 = none).
# Carried verbatim from the IT file so the Ixmp emitter can build patches that
# cover non-canonical-sample note ranges. terranmon.txt "Ixmp" + Schism iti.c:80.
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None # vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
# *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope
# pf_is_filter: bool — pf envelope mode (False = pitch, True = filter) # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter)
@@ -478,6 +482,7 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
kb_note = data[ptr + 0x44 + n*2] kb_note = data[ptr + 0x44 + n*2]
kb_smp = data[ptr + 0x44 + n*2 + 1] kb_smp = data[ptr + 0x44 + n*2 + 1]
keyboard.append(kb_smp) # 0 = no sample keyboard.append(kb_smp) # 0 = no sample
inst.keyboard = keyboard
# Pick C-5 (note 60) sample; fall back to most-frequent non-zero # Pick C-5 (note 60) sample; fall back to most-frequent non-zero
c5_smp = keyboard[60] if 60 < len(keyboard) else 0 c5_smp = keyboard[60] if 60 < len(keyboard) else 0
@@ -1119,6 +1124,133 @@ def _remap_bc_effects(chunks: list, chunk_map: list,
f"subsong boundary; clamped to cue {default_target}") f"subsong boundary; clamped to cue {default_target}")
# ── Ixmp patch builder (multi-sample IT instruments) ─────────────────────────
def _it_note_to_taud(note: int, clamp_low: bool = False, clamp_high: bool = False) -> int:
"""IT note (0..119, C-5 = 60) → Taud 4096-TET noteVal anchored at TAUD_C4.
`clamp_low`/`clamp_high` expand the bottom/top of the keyboard to cover the
full Taud playable range, so patches at the keyboard's edges don't leave
notes outside the trigger rectangle unmatched."""
if clamp_low: return 0x0000
if clamp_high: return 0xFFFF
val = round(TAUD_C4 + (note - 60) * 4096 / 12)
return max(0x0020, min(0xFFFF, val))
def _build_it_ixmp_patches(inst, samples, extras_offsets) -> list:
"""For one IT instrument, return a list of Ixmp patch dicts covering every
keyboard cell that maps to a NON-canonical sample. The canonical sample is
served by the base instrument record so no patch is emitted for it (the
engine falls through to the base inst when no patch matches).
Note ranges are contiguous runs of keyboard cells that point at the same
sample. Per the Ixmp spec each (pitch_start..pitch_end, volume_start..end)
rectangle MUST NOT overlap any other patch on the same instrument; this is
guaranteed here because the keyboard mapping itself is a partition."""
canonical = inst.canonical_sample
kbd = getattr(inst, 'keyboard', None)
if not kbd:
return []
# Distinct non-canonical samples referenced.
distinct = []
seen = set()
for kb_smp in kbd:
if kb_smp == 0 or kb_smp == canonical:
continue
if kb_smp not in seen and 1 <= kb_smp <= len(samples) and samples[kb_smp - 1] is not None:
seen.add(kb_smp); distinct.append(kb_smp)
if not distinct:
return []
patches = []
for smp_1based in distinct:
si = smp_1based - 1
s = samples[si]
if not s.sample_data:
continue
sample_ptr = extras_offsets.get(('it_smp', si))
if sample_ptr is None:
continue # not in the pool — bin overflow or corrupt source
# Per-sample loop / sustain encoding (mirrors build_sample_inst_bin_it).
if s.flags & IT_SMP_SUS_LOOP:
ls = min(s.sus_beg, 65535); le = min(s.sus_end, 65535)
sustain_bit = 0x4
pingpong = bool(s.flags & IT_SMP_PINGPONG_SUS)
has_loop = True
elif s.has_loop:
ls = min(s.loop_beg, 65535); le = min(s.loop_end, 65535)
sustain_bit = 0x0
pingpong = bool(s.flags & IT_SMP_PINGPONG)
has_loop = True
else:
ls = 0; le = 0
sustain_bit = 0x0
pingpong = False
has_loop = False
loop_mode = (2 if (has_loop and pingpong) else (1 if has_loop else 0)) | sustain_bit
# Per-sample default volume / pan / auto-vibrato — mirrors the
# use_instruments inst-record path so behaviour is identical when the
# patch sample matches what the base instrument would have stored.
smp_vol = min(getattr(s, 'vol', 64), 64)
dnv = min(255, round(smp_vol * 255 / 64))
smp_dfp = getattr(s, 'dfp', 0)
default_pan = (min(255, max(0, round((smp_dfp & 0x7F) * 255 / 64)))
if (smp_dfp & 0x80) else IXMP_PAN_NO_OVERRIDE)
vib_speed_taud = min(255, round(getattr(s, 'av_speed', 0) * 255 / 64))
vib_depth_taud = min(255, round(getattr(s, 'av_depth', 0) * 255 / 64))
vib_rate_taud = getattr(s, 'av_sweep', 0) & 0xFF
vib_wave_taud = getattr(s, 'av_wave', 0) & 0x07
# Find contiguous IT-note ranges where the keyboard points at this sample.
run_start = None
for n in range(120):
if kbd[n] == smp_1based:
if run_start is None:
run_start = n
else:
if run_start is not None:
_emit_patch(patches, run_start, n - 1, sample_ptr, s,
ls, le, loop_mode, default_pan, dnv,
vib_speed_taud, vib_depth_taud, vib_rate_taud, vib_wave_taud)
run_start = None
if run_start is not None:
_emit_patch(patches, run_start, 119, sample_ptr, s,
ls, le, loop_mode, default_pan, dnv,
vib_speed_taud, vib_depth_taud, vib_rate_taud, vib_wave_taud)
return patches
def _emit_patch(patches, it_lo, it_hi, sample_ptr, s,
ls, le, loop_mode, default_pan, dnv,
vib_speed, vib_depth, vib_rate, vib_wave):
"""Append one patch dict covering IT-note range [it_lo, it_hi] inclusive."""
taud_lo = _it_note_to_taud(it_lo, clamp_low=(it_lo == 0))
taud_hi = _it_note_to_taud(it_hi, clamp_high=(it_hi == 119))
patches.append({
'pitch_start': taud_lo,
'pitch_end': taud_hi,
'volume_start': 0,
'volume_end': 63,
'sample_ptr': sample_ptr,
'sample_length': min(s.length, 65535),
'play_start': 0,
'loop_start': ls,
'loop_end': le,
'sampling_rate': min(getattr(s, 'c5_speed', 8363), 65535),
'sample_detune': 0,
'loop_mode': loop_mode,
'default_pan': default_pan,
'default_note_volume': dnv,
'vibrato_speed': vib_speed,
'vibrato_sweep': 0, # IT-side; FT2 sweep stays 0
'vibrato_depth': vib_depth,
'vibrato_rate': vib_rate,
'vibrato_waveform': vib_wave,
})
# ── Sample / instrument bin (same as s3m2taud) ──────────────────────────────── # ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
def build_sample_inst_bin_it(samples_or_proxy: list, def build_sample_inst_bin_it(samples_or_proxy: list,
@@ -1190,13 +1322,29 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
sample_bin = bytearray(SAMPLEBIN_SIZE) sample_bin = bytearray(SAMPLEBIN_SIZE)
offsets = {} offsets = {}
pos = 0 pos = 0
# IT use_instruments mode points many Taud instrument slots at the same
# underlying sample object (e.g. seven "ChipBass.*" instruments all play
# "ChipBass.looped"). Write each distinct sample's PCM into the pool once and
# let every referencing slot share the offset, rather than emitting one
# identical copy per slot. `pool_order` records the distinct samples in
# ascending-offset order — the order taut.js's sample viewer expects SNam to
# follow (it dedupes instrument records by (ptr,len), sorts by ptr, and
# matches SNam[i+1] positionally — see taut.js buildSampleIndex).
written = {} # id(sample) -> pool offset already written
pool_order = [] # distinct sample objects, in pool (ascending-offset) order
for idx, s in pcm_list: for idx, s in pcm_list:
shared = written.get(id(s))
if shared is not None:
offsets[idx] = shared
continue
n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos) n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos)
if n <= 0: if n <= 0:
vprint(f" warning: sample bin full, dropping '{s.name}'") vprint(f" warning: sample bin full, dropping '{s.name}'")
offsets[idx] = 0; s.length = 0; continue offsets[idx] = 0; s.length = 0; continue
sample_bin[pos:pos+n] = s.sample_data[:n] sample_bin[pos:pos+n] = s.sample_data[:n]
offsets[idx] = pos offsets[idx] = pos
written[id(s)] = pos
pool_order.append(s)
if n < len(s.sample_data): if n < len(s.sample_data):
vprint(f" warning: '{s.name}' truncated {len(s.sample_data)}{n}") vprint(f" warning: '{s.name}' truncated {len(s.sample_data)}{n}")
s.length = n s.length = n
@@ -1384,7 +1532,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}") vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio return bytes(sample_bin) + bytes(inst_bin), offsets, ratio, pool_order
# ── Pattern builder ─────────────────────────────────────────────────────────── # ── Pattern builder ───────────────────────────────────────────────────────────
@@ -1805,6 +1953,10 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
# Pattern cells carry IT instrument numbers; for use_instruments mode, those # Pattern cells carry IT instrument numbers; for use_instruments mode, those
# are instrument indices; we remap to samples below. # are instrument indices; we remap to samples below.
# Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order. # Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order.
# Map IT sample (0-based) → IXMP patch dict template used when building the
# per-instrument patch list. Populated by the use_instruments branch below.
it_sample_patch_meta = {}
if h.use_instruments: if h.use_instruments:
# Build a proxy sample list where Taud inst slot = IT inst index, # Build a proxy sample list where Taud inst slot = IT inst index,
# resolved to the canonical sample. Slot 0 unused. # resolved to the canonical sample. Slot 0 unused.
@@ -1899,16 +2051,60 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
'dct': inst.dct, 'dct': inst.dct,
'dca': inst.dca, 'dca': inst.dca,
} }
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot) # ── Ixmp: pool keyboard-referenced extra samples beyond slot 255 ───────
# IT instruments can map different IT notes to different samples via the
# keyboard table (IMPI+0x44). The canonical sample is already in the proxy
# at the instrument's Taud slot; extras (any other sample referenced in
# the keyboard) get appended past index 256 so build_sample_inst_bin_it
# pools them (its inst-record loop skips i >= 256 — see the same file).
# We then look up their bin offsets via the returned offsets dict and
# emit one Ixmp patch per (sample, contiguous-note-range) pair.
extras_keys = [] # ordered list of ('it_smp', si) — index into the proxy is 256 + position
for ii, inst in enumerate(instruments):
if inst is None: continue
canonical = inst.canonical_sample
kbd = getattr(inst, 'keyboard', None) or []
for kb_smp in kbd:
if kb_smp == 0 or kb_smp == canonical:
continue
si = kb_smp - 1
if 0 <= si < len(samples) and samples[si] is not None and samples[si].sample_data:
key = ('it_smp', si)
if key not in extras_keys:
extras_keys.append(key)
extras_base = len(proxy)
for key in extras_keys:
proxy.append(samples[key[1]])
sampleinst_raw, bin_offsets, sample_ratio, pool_order = build_sample_inst_bin_it(proxy, instr_data_by_slot)
# Map ('it_smp', si) → sample-bin offset.
extras_offsets = {key: bin_offsets.get(extras_base + j, 0)
for j, key in enumerate(extras_keys)}
# Also include each canonical sample at its taud-slot offset so the patch
# builder can reuse them when an instrument's keyboard cell references the
# canonical sample at a non-canonical note range.
for ii, inst in enumerate(instruments):
if inst is None: continue
taud_slot = ii + 1
if taud_slot >= 256: continue
canon = inst.canonical_sample
if canon == 0: continue
si = canon - 1
if 0 <= si < len(samples) and samples[si] is not None and ('it_smp', si) not in extras_offsets:
# Look up the pool offset for the canonical via the proxy slot.
if taud_slot in bin_offsets:
extras_offsets[('it_smp', si)] = bin_offsets[taud_slot]
else: else:
# Samples referenced directly; proxy is samples list (0-based, slot 0 unused) # Samples referenced directly; proxy is samples list (0-based, slot 0 unused).
# No instruments in the file → no multi-sample mapping → no Ixmp patches.
proxy = [None] + list(samples) proxy = [None] + list(samples)
inst_vols = { inst_vols = {
i+1: min(s.vol, 0x3F) i+1: min(s.vol, 0x3F)
for i, s in enumerate(samples) for i, s in enumerate(samples)
if s is not None if s is not None
} }
sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy) sampleinst_raw, bin_offsets, sample_ratio, pool_order = build_sample_inst_bin_it(proxy)
extras_offsets = {}
assert len(sampleinst_raw) == SAMPLEINST_SIZE assert len(sampleinst_raw) == SAMPLEINST_SIZE
@@ -1961,12 +2157,36 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
if with_project_data: if with_project_data:
inst_names = [''] + [(inst.name if inst is not None else '') inst_names = [''] + [(inst.name if inst is not None else '')
for inst in instruments[:255]] for inst in instruments[:255]]
smp_names = [''] + [(s.name if s is not None else '') # SNam mirrors the deduplicated sample pool: one entry per distinct
for s in samples[:255]] # sample, in pool order, named after the sample itself. taut.js dedupes
# instrument records by (ptr,len), sorts ascending by ptr, and matches
# SNam[i+1] positionally to that list, so this ordering labels every
# sample correctly and a shared sample (e.g. "ChipBass.looped") appears
# exactly once instead of once per referencing instrument slot.
smp_names = [''] + [(getattr(s, 'name', '') or '')
for s in pool_order[:255]]
# Ixmp patches — only the use_instruments branch maps IT notes to multiple
# samples; the sample-mode branch has nothing to emit because there's no
# keyboard table on a raw IT sample.
ixmp_patches = {}
if h.use_instruments and extras_offsets:
for ii, inst in enumerate(instruments):
if inst is None: continue
taud_slot = ii + 1
if taud_slot >= 256: continue
patches = _build_it_ixmp_patches(inst, samples, extras_offsets)
if patches:
ixmp_patches[taud_slot] = patches
if ixmp_patches:
vprint(f" ixmp: {sum(len(p) for p in ixmp_patches.values())} "
f"patches across {len(ixmp_patches)} instruments")
proj_data = build_project_data( proj_data = build_project_data(
project_name=h.title, project_name=h.title,
instrument_names=inst_names, instrument_names=inst_names,
sample_names=smp_names, sample_names=smp_names,
ixmp_patches=ixmp_patches or None,
) )
if proj_data: if proj_data:
proj_off = cur_off proj_off = cur_off

View File

@@ -138,7 +138,11 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
continue continue
inst = S3MInstrument() inst = S3MInstrument()
inst.itype = data[ptr] inst.itype = data[ptr]
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace') # 12-byte DOS filename field; null-terminated with possible trailing
# garbage after the terminator (ST3 doesn't zero the tail). Truncate at
# the first null. This field carries the per-sample short name (e.g.
# 'HIT1') as distinct from the 28-byte title at 0x30.
inst.filename = data[ptr+1:ptr+13].split(b'\x00', 1)[0].decode('latin-1', errors='replace')
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk) # memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
memseg_hi = data[ptr + 0x0D] memseg_hi = data[ptr + 0x0D]
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0] memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
@@ -939,17 +943,21 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
cur_off += len(pat_comp) + len(cue_comp) cur_off += len(pat_comp) + len(cue_comp)
# ── Project Data (optional) ────────────────────────────────────────────── # ── Project Data (optional) ──────────────────────────────────────────────
# S3M instruments and samples share the same slot space, so the names go # S3M instruments and samples share the same slot space, but carry two
# into both INam and SNam (1-based; slot 0 empty). # distinct name fields: the 28-byte title (inst.name → INam) and the
# 12-byte DOS filename (inst.filename → SNam). e.g. WHEN.s3m instrument #1
# is titled "(c) Purple Motion / 1994" with sample name 'HIT1'.
proj_data = b'' proj_data = b''
proj_off = 0 proj_off = 0
if with_project_data: if with_project_data:
names = [''] + [(inst.name if inst is not None else '') inst_names = [''] + [(inst.name if inst is not None else '')
for inst in instruments[:255]] for inst in instruments[:255]]
sample_names = [''] + [(inst.filename if inst is not None else '')
for inst in instruments[:255]]
proj_data = build_project_data( proj_data = build_project_data(
project_name=h.title, project_name=h.title,
instrument_names=names, instrument_names=inst_names,
sample_names=names, sample_names=sample_names,
) )
if proj_data: if proj_data:
proj_off = cur_off proj_off = cur_off

View File

@@ -543,13 +543,93 @@ def _name_table_blob(names) -> bytes:
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end]) return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
# ── Ixmp encoder (terranmon.txt §Project Data → Ixmp) ───────────────────────
# Per-patch byte layout. Field offsets must match AudioJSR223Delegate.uploadInstrumentPatches
# (Kotlin parser) and terranmon.txt "Ixmp. Instrument extra samples".
IXMP_PATCH_SIZE = 31
IXMP_PAN_NO_OVERRIDE = 0xFF
IXMP_DNV_NO_OVERRIDE = 0
IXMP_VIBWAVE_NO_OVERRIDE = 0xFF
def encode_ixmp_patch(p: dict) -> bytes:
"""Encode a single patch dict into 31 bytes.
Expected keys (numeric values; defaults are applied for missing optional fields):
pitch_start, pitch_end : Taud 4096-TET noteVal (Uint16)
volume_start, volume_end : 0..63 (Uint8)
sample_ptr : Uint32 (sample bin offset)
sample_length : Uint16
play_start, loop_start, loop_end : Uint16
sampling_rate : Uint16 (same encoding as base inst byte 6-7)
sample_detune : Int16, signed 4096-TET (default 0)
loop_mode : Uint8 (default 0)
default_pan : Uint8, 0xFF = no override (default 0xFF)
default_note_volume : Uint8 IT-scaled (0 = no override, default 0)
vibrato_speed/sweep/depth/rate: Uint8 (default 0)
vibrato_waveform : Uint8 (0..7 or 0xFF for no override, default 0xFF)
"""
pitch_start = max(0, min(0xFFFF, int(p['pitch_start'])))
pitch_end = max(0, min(0xFFFF, int(p['pitch_end'])))
vol_start = max(0, min(63, int(p.get('volume_start', 0))))
vol_end = max(0, min(63, int(p.get('volume_end', 63))))
sample_ptr = int(p['sample_ptr']) & 0xFFFFFFFF
sample_len = max(0, min(0xFFFF, int(p['sample_length'])))
play_start = max(0, min(0xFFFF, int(p.get('play_start', 0))))
loop_start = max(0, min(0xFFFF, int(p.get('loop_start', 0))))
loop_end = max(0, min(0xFFFF, int(p.get('loop_end', 0))))
rate = max(0, min(0xFFFF, int(p.get('sampling_rate', 0))))
detune = max(-0x8000, min(0x7FFF, int(p.get('sample_detune', 0))))
return struct.pack(
'<BHHBBIHHHHHhBBBBBBBB',
1, # patch version
pitch_start, pitch_end,
vol_start, vol_end,
sample_ptr,
sample_len,
play_start, loop_start, loop_end,
rate,
detune,
int(p.get('loop_mode', 0)) & 0x07,
int(p.get('default_pan', IXMP_PAN_NO_OVERRIDE)) & 0xFF,
int(p.get('default_note_volume', IXMP_DNV_NO_OVERRIDE)) & 0xFF,
int(p.get('vibrato_speed', 0)) & 0xFF,
int(p.get('vibrato_sweep', 0)) & 0xFF,
int(p.get('vibrato_depth', 0)) & 0xFF,
int(p.get('vibrato_rate', 0)) & 0xFF,
int(p.get('vibrato_waveform', IXMP_VIBWAVE_NO_OVERRIDE)) & 0xFF,
)
def encode_ixmp_payload(patches_by_inst: dict) -> bytes:
"""Encode a dict {instrument_id: [patch_dict, ...]} as one Ixmp section payload
(the body that follows the FourCC + length header). Instruments are written in
ascending id order. Overlapping pitch+volume rectangles within one instrument
are INVALID per spec and the caller is responsible for keeping them disjoint."""
if not patches_by_inst:
return b''
out = bytearray()
for inst_id in sorted(patches_by_inst):
patches = patches_by_inst[inst_id]
if not patches:
continue
out.append(int(inst_id) & 0xFF)
cnt = len(patches)
out += bytes([cnt & 0xFF, (cnt >> 8) & 0xFF, (cnt >> 16) & 0xFF]) # Uint24 LE
for patch in patches:
out += encode_ixmp_patch(patch)
return bytes(out)
def build_project_data(*, project_name: str = '', def build_project_data(*, project_name: str = '',
author: str = '', author: str = '',
copyright_str: str = '', copyright_str: str = '',
sample_names=None, sample_names=None,
instrument_names=None, instrument_names=None,
pattern_names=None, pattern_names=None,
song_metadata=None) -> bytes: song_metadata=None,
ixmp_patches=None) -> bytes:
"""Build the optional PROJECT DATA section payload. """Build the optional PROJECT DATA section payload.
Returns the full block (8-byte magic + 8 reserved bytes + concatenated Returns the full block (8-byte magic + 8 reserved bytes + concatenated
@@ -604,6 +684,9 @@ def build_project_data(*, project_name: str = '',
smet += payload smet += payload
add(b'sMet', bytes(smet)) add(b'sMet', bytes(smet))
if ixmp_patches:
add(b'Ixmp', encode_ixmp_payload(ixmp_patches))
if not sections: if not sections:
return b'' return b''

View File

@@ -2409,8 +2409,10 @@ TODO:
[x] expose song table on UI (test with `insaniq2.taud`) [x] expose song table on UI (test with `insaniq2.taud`)
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF [x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
[ ] establish hooks for the interrupts [ ] establish hooks for the interrupts
[ ] Samples and Instruments view (viewer on taut.js; editor on separate .js) [x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
follow the ImpulseTracker design first, then improve from there follow the ImpulseTracker design first, then improve from there
[?] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
[ ] Needs .it and .xm test file to complete it2taud and xm2taud
TODO - list of demo songs that MUST ship with Microtone: TODO - list of demo songs that MUST ship with Microtone:
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes * 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
@@ -2469,7 +2471,7 @@ Audio Adapter MMIO
Write 16 to initialise the MP2 context (call this before the decoding of NEW music) Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
Write 1 to decode the frame as MP2 Write 1 to decode the frame as MP2
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR Calling with more than one bit set will result in UNDEFINED BEHAVIOUR — except for the flag 0x11, in which the hardware must initialise then immediately start decoding.
41 RO: MP2 Decoder Status 41 RO: MP2 Decoder Status
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status. Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
@@ -2619,6 +2621,17 @@ This is a file format for Taud tracker data. Taud can be extended with Microtone
Endianness: Little Endianness: Little
# Conformance language
(RFC 2119+8174)
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
(IMPLEMENTATION DETAILS)
- **INVALID.** Blame the encoder; decoder MUST stop decoding with appropriate errors.
- **UNDEFINED BEHAVIOUR.** Encoder MAY encode it; decoder MAY do whatever it wants to, including spawning a daemon out of your nose.
- **IGNORED.** Encoder MAY encode it; decoder MUST skip past it.
- **RESERVED.** Encoder MUST NOT encode it. Decoder MUST skip past it.
# File Structure # File Structure
\x1F T S V M a u d \x1F T S V M a u d
[HEADER] [HEADER]
@@ -2656,7 +2669,7 @@ Endianness: Little
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1') Uint8 Flags for Global Behaviour (effect symbol '1')
0b 000 rrr ff 0b 000 rrr ff
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved) ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: RESERVED)
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation) rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation)
Uint8 Song global volume Uint8 Song global volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
@@ -2664,7 +2677,7 @@ Endianness: Little
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int * ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint32 Compressed size of PATTERN BIN for this song Uint32 Compressed size of PATTERN BIN for this song
Uint32 Compressed size of CUE SHEET for this song Uint32 Compressed size of CUE SHEET for this song
Byte[6] Reserved Byte[6] RESERVED
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song. Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
@@ -2686,7 +2699,7 @@ Endianness: Little
Project Data is just a concatenation of blocks identified by their FourCC. Project Data is just a concatenation of blocks identified by their FourCC.
Byte[8] Magic (\x1E T a u d P r J) Byte[8] Magic (\x1E T a u d P r J)
Byte[8] Reserved Byte[8] RESERVED
* Repetition of * Repetition of
Byte[4] Title of the section (fourcc) Byte[4] Title of the section (fourcc)
Uint32 Section length Uint32 Section length
@@ -2737,11 +2750,11 @@ prefixes:
* Repetition of: * Repetition of:
Uint8 Notation index (starting from zero) used by songs Uint8 Notation index (starting from zero) used by songs
Uint32 Size of this notation following this field Uint32 Size of this notation following this field
Uint16 Reserved for flags Uint16 RESERVED for flags
Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0. Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0.
Uint16 Reserved for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?) Uint16 RESERVED for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11 Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11
Byte[8] Reserved Byte[8] RESERVED
Byte[*] Name, null terminated. Encoding: UTF-8 Byte[*] Name, null terminated. Encoding: UTF-8
Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset
Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right
@@ -2762,6 +2775,45 @@ prefixes:
Uint8 Version (Ascii 'a') Uint8 Version (Ascii 'a')
Bytes Notation definitions (see above) Bytes Notation definitions (see above)
* Ixmp. Instrument extra samples
* Repetition of:
Uint8 Instrument ID
Uint24 Count of patches
** Repetition of:
Uint8 Patch definition version (always 1)
Uint16 Pitch start ; Taud 4096-TET noteVal (same scale as pattern-cell note)
Uint16 Pitch end (inclusive)
Uint8 Volume start ; 0..63
Uint8 Volume end (inclusive) ; 0..63
- Above four parameters define a rectangle over the Pitch-Volume space. See Notes 4 and 5
Uint32 Sample pointer
Uint16 Sample length
Uint16 Play Start (usually 0 but not always)
Uint16 Loop Start (can be smaller than Play Start)
Uint16 Loop End
Uint16 samplingRate ; per-sample C-5 speed; same encoding as base instrument byte 6-7
Int16 sampleDetune ; per-sample fine detune in signed 4096-TET units (XM finetune; IT samples leave 0)
Uint8 loopMode ; same encoding as base instrument byte 14 (bits 0-1 = mode, bit 2 = sustain loop)
Uint8 defaultPan ; per-sample default pan (0..255; 0x80 = centre); 0xFF = "no override"
Uint8 defaultNoteVolume ; per-sample default note volume (0..255 scaled from IT 0..64); 0 = "no override"
Uint8 vibratoSpeed ; per-sample auto-vibrato (mirrors base inst byte 175)
Uint8 vibratoSweep ; per-sample auto-vibrato (mirrors base inst byte 176)
Uint8 vibratoDepth ; per-sample auto-vibrato (mirrors base inst byte 187)
Uint8 vibratoRate ; per-sample auto-vibrato (mirrors base inst byte 188)
Uint8 vibratoWaveform ; bits 0-2 only (mirrors instrumentFlag bits 2-4); 0xFF = "no override"
Notes:
0. this extension is made to support IT/XM instrument spec as well as partial compatibility to SF2 (Soundfont format two)
1. Envelopes (vol/pan/pf), fadeout, NNA / DCT / DCA, pitch-pan, filter, IGV and any other "instrument-scope" parameters all follow the base instrument definition. Only sample-scope parameters (the patch fields listed above) override.
2. overlapping regions are considered INVALID
3. multiple Ixmp blocks pointing the same instrument are considered INVALID
4. IT and XM does not define volumes. Keep the Volume rectangle at 0..63 — the engine clamps to that range when matching.
5. SF2 does define volumes (because MIDI). Convert it using `round(velocity * (63/127))`
On import, `initialAttenuation`, filters and ADSR shall be ignored
6. Patch selection at trigger time walks the patch list in order; the first patch whose rectangle contains the trigger's (noteVal, rowVolume) wins. When no patch matches, the base instrument's sample fields are used unchanged.
7. Sentinel values listed above ("no override") let a patch defer to the base instrument for a given field — used by converters that don't carry per-sample data for one of the dimensions (e.g. SF2 ignoring per-sample pan).
8. Total per-patch payload is 31 bytes.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
**S3M (ScreamTracker 3) to Taud conversion notes** **S3M (ScreamTracker 3) to Taud conversion notes**

View File

@@ -163,6 +163,24 @@ class AudioJSR223Delegate(private val vm: VM) {
return counts return counts
} }
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
fun getVoiceFunkSpeed(playhead: Int, voice: Int): Int {
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
if (!v.active) return 0
return v.funkSpeed
}
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
* empty array when the instrument has never been funk-repeated. The render thread mutates the
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
fun getInstrumentFunkMask(slot: Int): IntArray {
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
}
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using /** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for * *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
* inactive voices. */ * inactive voices. */
@@ -247,6 +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). */ /** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
fun uploadPattern(slot: Int, bytes: IntArray) { fun uploadPattern(slot: Int, bytes: IntArray) {
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat -> getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
@@ -297,6 +373,13 @@ class AudioJSR223Delegate(private val vm: VM) {
getPlayhead(playhead)?.resetParams() getPlayhead(playhead)?.resetParams()
} }
/** Clear funk-repeat (S$Fx) state (per-voice run-state + per-instrument loop-inversion masks)
* without disturbing tempo / volume / position. Call on a fresh play-from-start so stale funk
* state from a prior playback doesn't bleed into the replay. */
fun resetFunkState(playhead: Int) {
getPlayhead(playhead)?.resetFunkState()
}
fun purgeQueue(playhead: Int) { fun purgeQueue(playhead: Int) {
getPlayhead(playhead)?.purgeQueue() getPlayhead(playhead)?.purgeQueue()
} }

View File

@@ -1357,9 +1357,55 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val OP_Z = 0x23 const val OP_Z = 0x23
} }
private fun computePlaybackRate(inst: TaudInst, noteVal: Int): Double = // Active-sample-aware playback rate. Reads from the Voice's snapshotted sample
inst.samplingRate.toDouble() / SAMPLING_RATE * // view (set by [applyActiveSample]) so Ixmp-overlaid instruments use the patch's
2.0.pow((noteVal - MIDDLE_C + inst.sampleDetuneSigned) / 4096.0) // samplingRate / detune, not the base inst's.
private fun computePlaybackRate(voice: Voice, noteVal: Int): Double =
voice.activeSamplingRate.toDouble() / SAMPLING_RATE *
2.0.pow((noteVal - MIDDLE_C + voice.activeSampleDetune) / 4096.0)
/**
* Snapshot the sample-scope state for [voice] from either the base instrument
* or a resolved Ixmp patch. Called by every fresh trigger; the per-tick read
* sites then go through voice.active* instead of inst.* so multi-sample
* (IT/XM keyboard table) instruments select the right sample per note.
*
* Sentinels on the patch: defaultPan == 0xFF, defaultNoteVolume == 0,
* vibratoWaveform == 0xFF all defer to the base instrument. Other fields
* are always carried by the patch (converter responsibility).
*/
private fun applyActiveSample(voice: Voice, inst: TaudInst, patch: TaudInstPatch?) {
if (patch == null) {
voice.activeSamplePtr = inst.samplePtr
voice.activeSampleLength = inst.sampleLength
voice.activeSamplePlayStart = inst.samplePlayStart
voice.activeSampleLoopStart = inst.sampleLoopStart
voice.activeSampleLoopEnd = inst.sampleLoopEnd
voice.activeSamplingRate = inst.samplingRate
voice.activeSampleDetune = inst.sampleDetuneSigned
voice.activeLoopMode = inst.loopMode
voice.activeVibratoSpeed = inst.vibratoSpeed
voice.activeVibratoSweep = inst.vibratoSweep
voice.activeVibratoDepth = inst.vibratoDepth
voice.activeVibratoRate = inst.vibratoRate
voice.activeVibratoWaveform = inst.vibratoWaveform
} else {
voice.activeSamplePtr = patch.samplePtr
voice.activeSampleLength = patch.sampleLength
voice.activeSamplePlayStart = patch.playStart
voice.activeSampleLoopStart = patch.loopStart
voice.activeSampleLoopEnd = patch.loopEnd
voice.activeSamplingRate = patch.samplingRate
voice.activeSampleDetune = patch.sampleDetune
voice.activeLoopMode = patch.loopMode
voice.activeVibratoSpeed = patch.vibratoSpeed
voice.activeVibratoSweep = patch.vibratoSweep
voice.activeVibratoDepth = patch.vibratoDepth
voice.activeVibratoRate = patch.vibratoRate
voice.activeVibratoWaveform =
if (patch.vibratoWaveform == 0xFF) inst.vibratoWaveform else patch.vibratoWaveform
}
}
// Convert a 4096-TET noteVal to its Amiga-period equivalent (Double, no rounding). // Convert a 4096-TET noteVal to its Amiga-period equivalent (Double, no rounding).
private fun noteValToAmigaPeriod(noteVal: Int): Double = private fun noteValToAmigaPeriod(noteVal: Int): Double =
@@ -1754,16 +1800,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* 0 means full depth immediately). * 0 means full depth immediately).
*/ */
private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int { private fun advanceAutoVibrato(voice: Voice, inst: TaudInst): Int {
// Depth from byte 187 (full 0..255). Speed from byte 175 (FT2 0..255 scale). // Reads come from the voice's active-sample snapshot (patch-aware) so multi-sample
val depth0 = inst.vibratoDepth // IT/XM instruments use the per-sample auto-vibrato that the trigger resolved to.
if (depth0 == 0 || inst.vibratoSpeed == 0) return 0 // [inst] is retained in the signature for callsite continuity but only the voice's
// active fields are consulted here.
val depth0 = voice.activeVibratoDepth
if (depth0 == 0 || voice.activeVibratoSpeed == 0) return 0
// Two ramp-in semantics: // Two ramp-in semantics:
// FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep. // FT2 vibratoSweep (byte 176): "ticks to fully ramp" — depth = depth0 * t / sweep.
// IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick, // IT vibratoRate (byte 188): "ramp acceleration" — accumulator += rate per tick,
// capped at depth0 * 256, then divided by 256. // capped at depth0 * 256, then divided by 256.
val ftSweep = inst.vibratoSweep val ftSweep = voice.activeVibratoSweep
val itRate = inst.vibratoRate val itRate = voice.activeVibratoRate
val t = voice.autoVibTicksSinceTrigger val t = voice.autoVibTicksSinceTrigger
val rampDepth = when { val rampDepth = when {
ftSweep != 0 -> ((depth0 * t / ftSweep).coerceAtMost(depth0)) ftSweep != 0 -> ((depth0 * t / ftSweep).coerceAtMost(depth0))
@@ -1772,17 +1821,17 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
voice.autoVibTicksSinceTrigger++ voice.autoVibTicksSinceTrigger++
// Vibrato waveform selector lives in instrumentFlag bits 2-4. // Vibrato waveform selector lives in instrumentFlag bits 2-4 (snapshotted onto voice).
// 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only). // 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2 only).
// lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down. // lfoSample handles 0..3; treat 4 (ramp-up) as negated ramp-down.
val wave = inst.vibratoWaveform val wave = voice.activeVibratoWaveform
val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1) val rawSample = if (wave == 4) -lfoSample(voice.autoVibPhase, 1)
else lfoSample(voice.autoVibPhase, wave and 3) else lfoSample(voice.autoVibPhase, wave and 3)
// 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the // 4096-TET delta. depth0 is now 0..255 (was 0..15 in old layout); the
// shift compensates so depth ≈255 yields a similar musical excursion // shift compensates so depth ≈255 yields a similar musical excursion
// (~±9 cents) to the old depth ≈15. // (~±9 cents) to the old depth ≈15.
val pitchDelta = (rawSample * rampDepth) shr 10 val pitchDelta = (rawSample * rampDepth) shr 10
voice.autoVibPhase = (voice.autoVibPhase + inst.vibratoSpeed * 2) and 0xFF voice.autoVibPhase = (voice.autoVibPhase + voice.activeVibratoSpeed * 2) and 0xFF
return pitchDelta return pitchDelta
} }
@@ -1790,10 +1839,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's * Read one PCM sample (in [-1, 1]) at integer index [idx], honouring the instrument's
* funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the * funk-repeat mask. Out-of-range indices are clamped to the sample bounds; the
* caller is responsible for wrapping into a loop region first if loop semantics apply. * caller is responsible for wrapping into a loop region first if loop semantics apply.
*
* Sample-geometry reads come from the voice's active-sample snapshot so Ixmp-patched
* voices read the right bytes. The funk-mask continues to live on the base instrument
* (PT2 effect; doesn't combine with multi-sample IT/XM in practice).
*/ */
private fun readSamplePoint(inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double { private fun readSamplePoint(voice: Voice, inst: TaudInst, idx: Int, sampleLen: Int, binMax: Int): Double {
val i = idx.coerceIn(0, sampleLen - 1) val i = idx.coerceIn(0, sampleLen - 1)
var b = sampleBin[(inst.samplePtr + i).coerceAtMost(binMax).toLong()].toUint() var b = sampleBin[(voice.activeSamplePtr + i).coerceAtMost(binMax).toLong()].toUint()
if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) { if (inst.funkMask != null && inst.sampleLoopEnd > inst.sampleLoopStart) {
val ls = inst.sampleLoopStart val ls = inst.sampleLoopStart
if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF if (i in ls until inst.sampleLoopEnd && inst.funkBit(i - ls)) b = b xor 0xFF
@@ -1804,9 +1857,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double { private fun fetchTrackerSample(voice: Voice, inst: TaudInst, interpMode: Int): Double {
if (inst.index == 0) return 0.0 if (inst.index == 0) return 0.0
val sampleLen = inst.sampleLength.coerceAtLeast(1) val sampleLen = voice.activeSampleLength.coerceAtLeast(1)
val loopStart = inst.sampleLoopStart.toDouble() val loopStart = voice.activeSampleLoopStart.toDouble()
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0) val loopEnd = voice.activeSampleLoopEnd.toDouble().coerceAtLeast(1.0)
val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked) val binMax = (SAMPLE_BIN_TOTAL - 1).toInt() // 8 MB pool, addressed via samplePtr directly (not banked)
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1) val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
@@ -1826,7 +1879,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac. // Taps span [i0 - WIDTH, i0 + WIDTH], with the kernel centred on i0+frac.
for (j in -SINC_WIDTH .. SINC_WIDTH) { for (j in -SINC_WIDTH .. SINC_WIDTH) {
val coeff = sincTap(frac, j) val coeff = sincTap(frac, j)
if (coeff != 0.0) acc += readSamplePoint(inst, i0 + j, sampleLen, binMax) * coeff if (coeff != 0.0) acc += readSamplePoint(voice, inst, i0 + j, sampleLen, binMax) * coeff
} }
acc acc
} }
@@ -1837,10 +1890,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// formula in integer arithmetic, then map (out >> 1) back to [-1, 1]. // formula in integer arithmetic, then map (out >> 1) back to [-1, 1].
// The (out & 0xffff) → int16 cast after the third tap reproduces the // The (out & 0xffff) → int16 cast after the third tap reproduces the
// SNES hardware mid-sum overflow (the famous gauss "chirp"). // SNES hardware mid-sum overflow (the famous gauss "chirp").
val oldest = (readSamplePoint(inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt() val oldest = (readSamplePoint(voice, inst, i0 - 1, sampleLen, binMax) * 32767.0).toInt()
val olders = (readSamplePoint(inst, i0, sampleLen, binMax) * 32767.0).toInt() val olders = (readSamplePoint(voice, inst, i0, sampleLen, binMax) * 32767.0).toInt()
val olds = (readSamplePoint(inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt() val olds = (readSamplePoint(voice, inst, i0 + 1, sampleLen, binMax) * 32767.0).toInt()
val news = (readSamplePoint(inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt() val news = (readSamplePoint(voice, inst, i0 + 2, sampleLen, binMax) * 32767.0).toInt()
val offset = (frac * 256.0).toInt().coerceIn(0, 255) val offset = (frac * 256.0).toInt().coerceIn(0, 255)
var out = (SNES_GAUSS[0xff - offset] * oldest) shr 10 var out = (SNES_GAUSS[0xff - offset] * oldest) shr 10
out += (SNES_GAUSS[0x1ff - offset] * olders) shr 10 out += (SNES_GAUSS[0x1ff - offset] * olders) shr 10
@@ -1868,7 +1921,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// a mid-rail seed), reproducing DMC's coarse quantisation. Per-voice // a mid-rail seed), reproducing DMC's coarse quantisation. Per-voice
// counter persists across samples and is reseeded to mid-rail on note // counter persists across samples and is reseeded to mid-rail on note
// trigger (see triggerNote). // trigger (see triggerNote).
val target = readSamplePoint(inst, i0, sampleLen, binMax) val target = readSamplePoint(voice, inst, i0, sampleLen, binMax)
val targetLevel = ((target + 1.0) * 63.5).toInt().coerceIn(0, 127) val targetLevel = ((target + 1.0) * 63.5).toInt().coerceIn(0, 127)
when { when {
targetLevel > voice.nesDpcmCounter && voice.nesDpcmCounter <= 125 -> targetLevel > voice.nesDpcmCounter && voice.nesDpcmCounter <= 125 ->
@@ -1881,8 +1934,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
INTERP_NONE, INTERP_A500, INTERP_A1200 -> INTERP_NONE, INTERP_A500, INTERP_A1200 ->
// Paula-style ZOH — emit the integer-indexed sample byte without // Paula-style ZOH — emit the integer-indexed sample byte without
// sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs. // sub-sample fade. Aliasing is removed by the post-mix Amiga LPFs.
readSamplePoint(inst, i0, sampleLen, binMax) readSamplePoint(voice, inst, i0, sampleLen, binMax)
else -> readSamplePoint(inst, i0, sampleLen, binMax) else -> readSamplePoint(voice, inst, i0, sampleLen, binMax)
} }
// While ramping out at sample end, hold position so the mixer keeps emitting the // While ramping out at sample end, hold position so the mixer keeps emitting the
@@ -1895,7 +1948,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// When the sustain bit is set, key-off escapes the loop: the sample plays past // When the sustain bit is set, key-off escapes the loop: the sample plays past
// loopEnd until it ends naturally (loopMode 0 semantics). // loopEnd until it ends naturally (loopMode 0 semantics).
val effectiveLoopMode = val effectiveLoopMode =
if (inst.sampleLoopSustain && voice.keyOff) 0 else (inst.loopMode and 3) if (voice.activeSampleLoopSustain && voice.keyOff) 0 else (voice.activeLoopMode and 3)
when (effectiveLoopMode) { when (effectiveLoopMode) {
0 -> if (voice.samplePos >= sampleLen) { 0 -> if (voice.samplePos >= sampleLen) {
voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0) voice.samplePos = (sampleLen - 1).toDouble().coerceAtLeast(0.0)
@@ -1977,16 +2030,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
* unconditionally on inst-column rows, regardless of porta). Sets * unconditionally on inst-column rows, regardless of porta). Sets
* noteVolume only — channelVolume (IT chan->global_volume) survives. * noteVolume only — channelVolume (IT chan->global_volume) survives.
*/ */
private fun rowVolumeFromDefault(inst: TaudInst): Int { private fun rowVolumeFromDefault(inst: TaudInst, patch: TaudInstPatch? = null): Int {
val dnv = inst.defaultNoteVolume // Patch overrides the base inst's DNV unless the sentinel (0 = no override).
val dnv = patch?.defaultNoteVolume?.takeIf { it != 0 } ?: inst.defaultNoteVolume
return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255 return if (dnv == 0) 0x3F else (dnv * 63 + 127) / 255
} }
private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) { private fun triggerNote(voice: Voice, noteVal: Int, instId: Int, volOverride: Int) {
if (instId != 0) voice.instrumentId = instId if (instId != 0) voice.instrumentId = instId
val inst = instruments[voice.instrumentId] val inst = instruments[voice.instrumentId]
// Resolve the Ixmp patch (if any) for this trigger. Volume axis uses the
// pre-patch seed so the rectangle test is well-defined; the patch's own
// DNV is then layered onto the final voice.noteVolume below.
val seedVolForLookup = when {
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
instId != 0 -> rowVolumeFromDefault(inst, null)
else -> voice.noteVolume.coerceIn(0, 0x3F)
}
val patch = inst.resolvePatch(noteVal, seedVolForLookup)
applyActiveSample(voice, inst, patch)
voice.tonePortaTarget = -1 // fresh note trigger cancels any running porta voice.tonePortaTarget = -1 // fresh note trigger cancels any running porta
voice.samplePos = inst.samplePlayStart.toDouble() voice.samplePos = voice.activeSamplePlayStart.toDouble()
voice.forward = true voice.forward = true
voice.active = true voice.active = true
voice.keyOff = false voice.keyOff = false
@@ -2042,8 +2106,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (instId != 0) { if (instId != 0) {
// Default pan: applied unless the pattern row has already overridden channelPan. // Default pan: applied unless the pattern row has already overridden channelPan.
// The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7. // The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
// An Ixmp patch's defaultPan (when non-sentinel, i.e. != 0xFF) takes precedence over
// the base instrument's defaultPan.
if ((inst.panEnvLoop ushr 7) and 1 != 0) { if ((inst.panEnvLoop ushr 7) and 1 != 0) {
voice.channelPan = inst.defaultPan val patchPan = patch?.defaultPan?.takeIf { it != 0xFF }
voice.channelPan = patchPan ?: inst.defaultPan
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
} }
// Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan. // Pitch-pan separation: when PPS != 0, played notes far from PPC drift in pan.
@@ -2066,7 +2133,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.basePitch = noteVal voice.basePitch = noteVal
voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal voice.amigaPeriod = -1.0 // fresh trigger: period state must reseed from the new noteVal
voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2) voice.linearFreq = -1.0 // ditto for linear-freq mode (toneMode == 2)
voice.playbackRate = computePlaybackRate(inst, noteVal) voice.playbackRate = computePlaybackRate(voice, noteVal)
// Fresh trigger seeds noteVolume from the per-instrument "default note volume" // Fresh trigger seeds noteVolume from the per-instrument "default note volume"
// (byte 196) when the row carried an instrument byte but no explicit V column — // (byte 196) when the row carried an instrument byte but no explicit V column —
// matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302 // matching IT's `chan->volume = psmp->volume` rule (Schism player/effects.c:1302
@@ -2078,9 +2145,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// chan->global_volume across sample changes, so M / N writes persist. // chan->global_volume across sample changes, so M / N writes persist.
// Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the // Continuous per-instrument scaling lives in instGlobalVolume (byte 171), which the
// mixer applies independently of this seed. // mixer applies independently of this seed.
// When an Ixmp patch overrides DNV (non-sentinel), the patch wins via rowVolumeFromDefault.
voice.noteVolume = when { voice.noteVolume = when {
volOverride >= 0 -> volOverride.coerceIn(0, 0x3F) volOverride >= 0 -> volOverride.coerceIn(0, 0x3F)
instId != 0 -> rowVolumeFromDefault(inst) instId != 0 -> rowVolumeFromDefault(inst, patch)
else -> voice.noteVolume else -> voice.noteVolume
} }
voice.rowVolume = voice.noteVolume voice.rowVolume = voice.noteVolume
@@ -2126,14 +2194,21 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun applyDuplicateCheck(ts: TrackerState, channel: Int, newInstId: Int, newNote: Int) { private fun applyDuplicateCheck(ts: TrackerState, channel: Int, newInstId: Int, newNote: Int) {
if (newInstId == 0) return if (newInstId == 0) return
val newInst = instruments[newInstId] val newInst = instruments[newInstId]
// For DCT=2 (sample match) we compare canonical sample identity. With Ixmp, the
// new note's effective sample is the patch's (or the base inst's if no patch).
// Volume axis defaults to full (0x3F) at this resolution point — the actual
// trigger volume isn't known yet and the IT DCT model is volume-agnostic anyway.
val newPatch = newInst.resolvePatch(newNote, 0x3F)
val newSmpPtr = newPatch?.samplePtr ?: newInst.samplePtr
val newSmpLen = newPatch?.sampleLength ?: newInst.sampleLength
fun isDuplicate(v: Voice): Boolean { fun isDuplicate(v: Voice): Boolean {
val existInst = instruments[v.instrumentId] val existInst = instruments[v.instrumentId]
return when (existInst.duplicateCheckType) { return when (existInst.duplicateCheckType) {
1 -> v.noteVal == newNote && v.instrumentId == newInstId 1 -> v.noteVal == newNote && v.instrumentId == newInstId
2 -> v.instrumentId == newInstId && 2 -> v.instrumentId == newInstId &&
existInst.samplePtr == newInst.samplePtr && v.activeSamplePtr == newSmpPtr &&
existInst.sampleLength == newInst.sampleLength v.activeSampleLength == newSmpLen
3 -> v.instrumentId == newInstId 3 -> v.instrumentId == newInstId
else -> false else -> false
} }
@@ -2254,6 +2329,22 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
v.bitcrusherHeld = src.bitcrusherHeld v.bitcrusherHeld = src.bitcrusherHeld
v.overdriveAmp = src.overdriveAmp v.overdriveAmp = src.overdriveAmp
v.sourceChannel = channel v.sourceChannel = channel
// Active-sample snapshot must follow the foreground voice so the ghost's per-tick
// playback (samplingRate, loop bounds, auto-vibrato) keeps using the patch the
// foreground had bound — not the base instrument it would otherwise re-derive.
v.activeSamplePtr = src.activeSamplePtr
v.activeSampleLength = src.activeSampleLength
v.activeSamplePlayStart = src.activeSamplePlayStart
v.activeSampleLoopStart = src.activeSampleLoopStart
v.activeSampleLoopEnd = src.activeSampleLoopEnd
v.activeSamplingRate = src.activeSamplingRate
v.activeSampleDetune = src.activeSampleDetune
v.activeLoopMode = src.activeLoopMode
v.activeVibratoSpeed = src.activeVibratoSpeed
v.activeVibratoSweep = src.activeVibratoSweep
v.activeVibratoDepth = src.activeVibratoDepth
v.activeVibratoRate = src.activeVibratoRate
v.activeVibratoWaveform = src.activeVibratoWaveform
return v return v
} }
@@ -2433,7 +2524,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
0x0000 -> { 0x0000 -> {
if (row.instrment != 0) { if (row.instrment != 0) {
voice.instrumentId = row.instrment voice.instrumentId = row.instrment
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId]) // Re-resolve the patch on the new instrument against the voice's
// current note so multi-sample IT/XM instruments pick up the right
// sample (and per-patch DNV) even on a continue row. samplePos is
// preserved — Schism csf_instrument_change reloads sample geometry
// but does not retrigger.
val newInst = instruments[voice.instrumentId]
val newPatch = newInst.resolvePatch(voice.noteVal, voice.noteVolume)
applyActiveSample(voice, newInst, newPatch)
val seedVol = rowVolumeFromDefault(newInst, newPatch)
voice.noteVolume = seedVol voice.noteVolume = seedVol
voice.rowVolume = seedVol voice.rowVolume = seedVol
voice.keyOff = false voice.keyOff = false
@@ -2470,7 +2569,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// and the bump persisted through the following vibrato rows). // and the bump persisted through the following vibrato rows).
if (row.instrment != 0) { if (row.instrment != 0) {
voice.instrumentId = row.instrment voice.instrumentId = row.instrment
val seedVol = rowVolumeFromDefault(instruments[voice.instrumentId]) // Porta + inst-byte: re-resolve the patch on the new instrument
// against the voice's current note (Schism evaluates the keyboard
// table at csf_instrument_change time; the porta target row.note
// is only the slide destination, not the sample selector).
val newInst = instruments[voice.instrumentId]
val newPatch = newInst.resolvePatch(voice.noteVal, voice.noteVolume)
applyActiveSample(voice, newInst, newPatch)
val seedVol = rowVolumeFromDefault(newInst, newPatch)
voice.noteVolume = seedVol voice.noteVolume = seedVol
voice.rowVolume = seedVol voice.rowVolume = seedVol
voice.keyOff = false voice.keyOff = false
@@ -2609,7 +2715,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.basePitch = voice.noteVal voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 // reseed on next per-tick slide voice.amigaPeriod = -1.0 // reseed on next per-tick slide
voice.linearFreq = -1.0 voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
} else { } else {
voice.slideMode = 1; voice.slideArg = -arg voice.slideMode = 1; voice.slideArg = -arg
voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide voice.amigaPeriod = -1.0 // reseed at the start of a fresh multi-tick slide
@@ -2628,7 +2734,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.basePitch = voice.noteVal voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 voice.amigaPeriod = -1.0
voice.linearFreq = -1.0 voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
} else { } else {
voice.slideMode = 2; voice.slideArg = arg voice.slideMode = 2; voice.slideArg = arg
voice.amigaPeriod = -1.0 voice.amigaPeriod = -1.0
@@ -2741,12 +2847,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
} }
EffectOp.OP_O -> { EffectOp.OP_O -> {
// Sample-offset O: clamps into the active sample's loop region when an O$xx
// value lands past loopEnd. Reads from the patch-aware active-sample view.
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it } val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
val inst = instruments[voice.instrumentId]
var off = arg var off = arg
if ((inst.loopMode and 3) != 0 && inst.sampleLoopEnd > inst.sampleLoopStart && off > inst.sampleLoopEnd) { if ((voice.activeLoopMode and 3) != 0 &&
val loopLen = (inst.sampleLoopEnd - inst.sampleLoopStart).coerceAtLeast(1) voice.activeSampleLoopEnd > voice.activeSampleLoopStart &&
off = inst.sampleLoopStart + ((off - inst.sampleLoopStart) % loopLen) off > voice.activeSampleLoopEnd) {
val loopLen = (voice.activeSampleLoopEnd - voice.activeSampleLoopStart).coerceAtLeast(1)
off = voice.activeSampleLoopStart + ((off - voice.activeSampleLoopStart) % loopLen)
} }
voice.samplePos = off.toDouble() voice.samplePos = off.toDouble()
} }
@@ -2837,7 +2946,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.basePitch = voice.noteVal voice.basePitch = voice.noteVal
voice.amigaPeriod = -1.0 voice.amigaPeriod = -1.0
voice.linearFreq = -1.0 voice.linearFreq = -1.0
voice.playbackRate = computePlaybackRate(instruments[voice.instrumentId], voice.noteVal) voice.playbackRate = computePlaybackRate(voice, voice.noteVal)
} }
0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 } 0x3 -> { voice.vibratoWave = x and 3; voice.vibratoRetrig = (x and 4) == 0 }
0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 } 0x4 -> { voice.tremoloWave = x and 3; voice.tremoloRetrig = (x and 4) == 0 }
@@ -2905,7 +3014,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
for (vi in 0 until ts.voices.size) { for (vi in 0 until ts.voices.size) {
val voice = ts.voices[vi] val voice = ts.voices[vi]
if (!voice.active && voice.noteDelayTick < 0) continue if (!voice.active && voice.noteDelayTick < 0) continue
val inst = instruments[voice.instrumentId] var inst = instruments[voice.instrumentId]
// Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume // Note cut. Zero noteVolume / rowVolume (silence this note) but leave channelVolume
// alone — IT's note cut stops the sample, it doesn't reset chan->global_volume. // alone — IT's note cut stops the sample, it doesn't reset chan->global_volume.
@@ -2921,6 +3030,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
maybeSpawnBackgroundForNNA(ts, voice, vi) maybeSpawnBackgroundForNNA(ts, voice, vi)
triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol) triggerNote(voice, voice.delayedNote, voice.delayedInst, voice.delayedVol)
voice.noteDelayTick = -1 voice.noteDelayTick = -1
// triggerNote may have swapped in a new instrument; re-bind so the rest of this
// tick's per-voice work (playbackRate at L3090, envelope/fadeout/auto-vibrato)
// uses the instrument that just fired, not the one the voice held on entry. On a
// never-triggered voice the stale binding is instruments[0] (samplingRate 0),
// which would zero playbackRate and freeze the sample — the "first note on a
// fresh channel via S$Dx is silent" bug.
inst = instruments[voice.instrumentId]
} }
if (!voice.active) { if (!voice.active) {
@@ -3059,7 +3175,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (voice.retrigCounter >= voice.retrigInterval) { if (voice.retrigCounter >= voice.retrigInterval) {
voice.retrigCounter = 0 voice.retrigCounter = 0
val retrigInst = instruments[voice.instrumentId] val retrigInst = instruments[voice.instrumentId]
voice.samplePos = retrigInst.samplePlayStart.toDouble() // Use the voice's active sample's playStart (patch-aware) — without this
// a Q retrigger on a multi-sample instrument would jump to the base sample
// even though the voice is bound to a patch.
voice.samplePos = voice.activeSamplePlayStart.toDouble()
voice.keyOff = false voice.keyOff = false
voice.envIndex = 0; voice.envTimeSec = 0.0 voice.envIndex = 0; voice.envTimeSec = 0.0
voice.envPanIndex = 0; voice.envPanTimeSec = 0.0 voice.envPanIndex = 0; voice.envPanTimeSec = 0.0
@@ -3087,7 +3206,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
else 0 else 0
val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF) val finalPitch = (pitchToMixer + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
voice.playbackRate = computePlaybackRate(inst, finalPitch) voice.playbackRate = computePlaybackRate(voice, finalPitch)
// Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity). // Filter envelope (filter mode): scale baseCut by envValue (0..1, 0.5 = unity).
// Schism filters.c:80-86 computes `cutoff_used = chan->cutoff * (flt_modifier+256)/256` // Schism filters.c:80-86 computes `cutoff_used = chan->cutoff * (flt_modifier+256)/256`
@@ -3191,7 +3310,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt() ((bg.envPfValue - 0.5) * 2.0 * 16.0 * 4096.0 / 12.0).toInt()
else 0 else 0
val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF) val finalPitch = (bg.noteVal + autoVibDelta + pitchEnvDelta).coerceIn(0x20, 0xFFFF)
bg.playbackRate = computePlaybackRate(inst, finalPitch) bg.playbackRate = computePlaybackRate(bg, finalPitch)
// Filter-mode pf envelope: same scaling rule as foreground. // Filter-mode pf envelope: same scaling rule as foreground.
if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) { if (bg.hasPfEnv && bg.pfEnvOn && bg.envPfIsFilter) {
val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254 val baseCut = if (inst.defaultCutoff < 255) inst.defaultCutoff else 254
@@ -3683,6 +3802,27 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var autoVibPhase = 0 // 8-bit phase counter var autoVibPhase = 0 // 8-bit phase counter
var autoVibTicksSinceTrigger = 0 // for sweep ramp-up var autoVibTicksSinceTrigger = 0 // for sweep ramp-up
// Active-sample view — snapshot of either the base instrument's sample-scope
// fields or, when an Ixmp patch covers (noteVal, rowVolume) at trigger time,
// the matching TaudInstPatch overlay. Per-tick and per-row code reads from
// these instead of `inst.*` so multi-sample (IT keyboard table) instruments
// play the correct sample for the triggered note. Snapshotted by triggerNote
// and the equivalent paths (Q retrigger, NNA ghosting).
var activeSamplePtr = 0
var activeSampleLength = 0
var activeSamplePlayStart = 0
var activeSampleLoopStart = 0
var activeSampleLoopEnd = 0
var activeSamplingRate = 0
var activeSampleDetune = 0 // signed 4096-TET
var activeLoopMode = 0 // bits 0-1 = direction, bit 2 = sustain (matches inst byte 14)
var activeVibratoSpeed = 0
var activeVibratoSweep = 0
var activeVibratoDepth = 0
var activeVibratoRate = 0
var activeVibratoWaveform = 0 // bits 0-2 only
val activeSampleLoopSustain: Boolean get() = (activeLoopMode and 0x04) != 0
// NES 2A03 DMC counter for INTERP_NES_DPCM (interpolation mode 5). // NES 2A03 DMC counter for INTERP_NES_DPCM (interpolation mode 5).
// 7-bit unsigned (0..127), slews ±2 per output sample as the sigma-delta // 7-bit unsigned (0..127), slews ±2 per output sample as the sigma-delta
// bitstream is generated on the fly. Seeded to mid-rail (63) on every // bitstream is generated on the fly. Seeded to mid-rail (63) on every
@@ -4094,6 +4234,20 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
} }
/** Clear funk-repeat (S$Fx) state only — per-voice run-state plus the per-instrument
* loop-inversion masks — without touching tempo / volume / position. taut calls this on
* every fresh play-from-start so accumulated inversions and a stale funkSpeed don't bleed
* from a prior session into the replay; full resetParams would also clobber bpm / tickRate /
* volume, which a replay must preserve. Masks still persist across a natural song loop. */
fun resetFunkState() {
trackerState?.voices?.forEach {
it.funkSpeed = 0
it.funkAccumulator = 0
it.funkWritePos = 0
}
parent.instruments.forEach { it.funkMask = null }
}
fun purgeQueue() { fun purgeQueue() {
pcmQueue.clear() pcmQueue.clear()
if (isPcmMode) { if (isPcmMode) {
@@ -4152,6 +4306,41 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat) data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
/**
* One Ixmp "extra sample" patch — overlays sample-scope state on a base instrument
* for a (noteVal, rowVolume) rectangle. See terranmon.txt "Ixmp. Instrument extra
* samples" for the on-wire layout. Envelopes, fadeout, NNA / DCT / DCA, filter,
* pitch-pan, IGV and other instrument-scope fields stay on the base TaudInst —
* only the fields below override.
*
* Sentinels: defaultPan == 0xFF, defaultNoteVolume == 0, vibratoWaveform == 0xFF
* all mean "inherit the base instrument's value". samplingRate == 0 would silence
* the patch (same semantics as base inst), so converters must always supply it.
*/
data class TaudInstPatch(
val pitchStart: Int,
val pitchEnd: Int,
val volumeStart: Int,
val volumeEnd: Int,
val samplePtr: Int,
val sampleLength: Int,
val playStart: Int,
val loopStart: Int,
val loopEnd: Int,
val samplingRate: Int,
val sampleDetune: Int, // signed 4096-TET
val loopMode: Int, // matches base inst byte 14 (bits 0-1 = mode, bit 2 = sustain)
val defaultPan: Int, // 0..255; 0xFF = no override
val defaultNoteVolume: Int, // 0..255 IT-scaled; 0 = no override
val vibratoSpeed: Int,
val vibratoSweep: Int,
val vibratoDepth: Int,
val vibratoRate: Int,
val vibratoWaveform: Int // 0..7; 0xFF = no override
) {
val sampleLoopSustain: Boolean get() = (loopMode and 0x04) != 0
}
/** /**
* 256-byte instrument record (terranmon.txt:2001+). * 256-byte instrument record (terranmon.txt:2001+).
* *
@@ -4276,12 +4465,31 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Byte 196 is the new "default note volume" field — see triggerNote. // Byte 196 is the new "default note volume" field — see triggerNote.
private val reserved = ByteArray(59) private val reserved = ByteArray(59)
// Optional Ixmp "extra sample" patches — non-null when an Ixmp block was uploaded
// for this instrument. Patches are scanned in order at trigger time; first hit on
// (noteVal, rowVolume) wins (overlapping rectangles are INVALID per spec).
var extraPatches: Array<TaudInstPatch>? = null
/** Walk [extraPatches] and return the first patch whose pitch+volume rectangle
* contains the given trigger. Returns null when no patches are bound or none match. */
fun resolvePatch(noteVal: Int, rowVolume: Int): TaudInstPatch? {
val patches = extraPatches ?: return null
for (p in patches) {
if (noteVal in p.pitchStart..p.pitchEnd &&
rowVolume in p.volumeStart..p.volumeEnd) return p
}
return null
}
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region. // Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact. // Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
// Mask is sized for the loop length at allocation time; if the loop bounds change // Mask is sized for the loop length at allocation time; if the loop bounds change
// (e.g. a new song reuses this instrument slot with different sample data) the old // (e.g. a new song reuses this instrument slot with different sample data) the old
// mask is stale and must be discarded — otherwise indexing past its end crashes the // mask is stale and must be discarded — otherwise indexing past its end crashes the
// render thread with ArrayIndexOutOfBoundsException. // render thread with ArrayIndexOutOfBoundsException.
// Note: with Ixmp patches active the mask still indexes the BASE instrument's loop
// region, not the active patch's. Funk repeat (S$Fx) is a PT2 effect and doesn't
// coexist with multi-sample IT/XM instruments in practice.
var funkMask: ByteArray? = null var funkMask: ByteArray? = null
fun toggleFunkBit(loopOffset: Int) { fun toggleFunkBit(loopOffset: Int) {
val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1) val len = (sampleLoopEnd - sampleLoopStart).coerceAtLeast(1)

View File

@@ -16,6 +16,11 @@ Limits:
- Multi-sample instruments use the sample selected by the *current - Multi-sample instruments use the sample selected by the *current
note's* keymap entry; the converter materialises one Taud note's* keymap entry; the converter materialises one Taud
instrument slot per (XM instrument, sample-in-instrument) pair. instrument slot per (XM instrument, sample-in-instrument) pair.
(Note: it2taud uses the alternate Ixmp project-data extension
instead — one Taud instrument per IT instrument, plus an Ixmp
patch list for the keyboard mapping. XM could be retrofitted the
same way to conserve Taud instrument slots; deferred until any
real XM file actually hits the 255-slot cap.)
Pattern length policy: Pattern length policy:
- XM patterns ≤ 64 rows → 1 Taud cue with the LEN ($02xx) - XM patterns ≤ 64 rows → 1 Taud cue with the LEN ($02xx)