Compare commits

...

11 Commits

Author SHA1 Message Date
minjaesong
2b91251d6e fix: random pitch changes; NNA note cut (not off!) for MOD and S3M 2026-05-02 03:25:57 +09:00
minjaesong
f84d317f95 NNA impl 2026-05-02 03:17:07 +09:00
minjaesong
f295223f15 IT instrument shenanigans 2026-05-02 02:48:24 +09:00
minjaesong
6de9476c4f fix: unmatched brackets :( 2026-05-02 02:23:38 +09:00
minjaesong
e317d79a21 S3M eff X; PT funk repeat 2026-05-02 02:22:20 +09:00
minjaesong
fe59df18f7 IT filters 2026-05-01 23:19:49 +09:00
minjaesong
a4adc428d0 taud: spec elaboration on filter cutoff and resonance 2026-05-01 18:07:13 +09:00
minjaesong
31e46b78ce notefx support for amiga freq mode 2026-05-01 17:54:17 +09:00
minjaesong
ac94a52329 it2taud to use new Taud instrument fields 2026-05-01 12:42:27 +09:00
minjaesong
01ff4b1d47 reflecting spec changes 2026-05-01 12:25:47 +09:00
minjaesong
50802186ce taud inst spec change 2026-05-01 07:42:08 +09:00
12 changed files with 1651 additions and 728 deletions

View File

@@ -18,6 +18,26 @@ Documentation for TSVM and TVDOS are available on `./doc/*.tex` as machine-reada
Documentatino for TSVM architecture is available on `terranmon.txt`
## Reference Materials
Third-party source-code references that inform TSVM implementations live in
`reference_materials/<topic>/`. Each topic folder has a `README.md` that
summarises the takeaway and points back into the verbatim source files.
**Consult these before reimplementing tracker / codec / DSP behaviour from
memory** — TSVM aims to match the audible behaviour of the originals.
Current topics:
- `reference_materials/tracker_filter/` — Impulse Tracker / OpenMPT / Schism
Tracker resonant low-pass filter source. Defines the cutoff formula, the
resonance damping curve, and the **IIR-only 2-pole topology** (NOT a
biquad — no feedforward x[n1] / x[n2] terms) that `AudioAdapter.kt` uses
for Taud playback.
When fetching new references, copy the relevant upstream files verbatim into
a topic folder, write a `README.md` summarising the relevant maths /
algorithms with file:line citations, and add an entry here.
## Architecture
### Core Components

View File

@@ -111,7 +111,7 @@ Opcodes are single base-36 digits (0-9, then A-Z); arguments are 16-bit hexadeci
---
## D — Volume slide (multiple forms)
## D $xy00 — Volume slide (multiple forms)
D's 16-bit argument encodes four mutually exclusive modes using the top nibble and the following byte. All forms operate on the channel's current volume and clip to $00..$3F after each step.
@@ -151,7 +151,12 @@ D's 16-bit argument encodes four mutually exclusive modes using the top nibble a
## E $xxxx — Pitch slide down by $xxxx
**Plain.** Lowers the channel's pitch by the argument per tick. By default (linear mode, `f` bit unset in effect `1`) the coarse slide value is subtracted directly from the stored pitch in the 4096-TET grid. When Amiga mode is active (`f` bit set), coarse slides are instead applied in Amiga period space: the stored value is converted back to Amiga period units and subtracted from the equivalent period, producing the characteristic non-linear pitch drift of ProTracker-style slides. Fine slides (`E $Fxxx`) are always applied in linear pitch-unit space regardless of mode. A coarse slide uses the full value range; a fine slide applies only once per row.
**Plain.** Lowers the channel's pitch by the argument per tick. The coarse argument has **two distinct interpretations** chosen by the song-table `f` flag (effect `1`, bit 1):
- **Linear mode** (`f` unset, default): the argument is a value in the 4096-TET pitch grid, subtracted directly from the stored pitch. `E $0155` ≈ one semitone per tick.
- **Amiga (cycle-based) mode** (`f` set): the argument is a **raw ProTracker/ST3 period unit count** — the same byte the original tracker stored on disk, *unscaled*. The engine converts the channel's stored 4096-TET pitch back to an Amiga period, subtracts the argument from that period directly, then converts the result back to 4096-TET. `E $0001` therefore corresponds to PT `201` and produces the characteristic non-linear pitch drift of ProTracker-style slides (lower pitches drift more slowly in semitone terms than higher pitches).
Because Amiga period units fit in a single byte (PT/ST3 max value $FF), the coarse range in Amiga mode never approaches the $F000 fine-slide marker, so the same argument-format selector still distinguishes coarse from fine. **Fine slides (`E $Fxxx`) follow the same dual-interpretation rule as coarse**: in linear mode the low 12 bits are 4096-TET units; in Amiga mode they are raw tracker period units (the PT `E2x` / ST3 `EFx` or `EEx` digit), applied once per row at tick 0 in period space. A coarse slide uses the full value range; a fine slide applies only once per row.
Coarse and fine modes are distinguished by the high nibble of the argument:
@@ -159,13 +164,18 @@ Coarse and fine modes are distinguished by the high nibble of the argument:
- `E $F000..$FFFF` — fine slide: on tick 0 only, subtracts `arg & $0FFF` from pitch.
- `E $0000` — recalls the last E-or-F argument and applies it as a down-slide, preserving the original form (coarse or fine).
**Compatibility.** This is **the single intentionally ST3-incompatible command in Taud**. ST3 pitch slides operate on Amiga periods or linear slide units; Taud operates directly on 4096-TET pitch units. Coarse and fine forms use different unit sizes:
**Compatibility.** ST3 pitch slides operate on Amiga periods or linear slide units; Taud's storage depends on the song-table mode flag:
- ST3 `Exx` coarse (where `xx < $E0`) → Taud `E round($00xx × 64/3)` (1 ST3 coarse unit = 1/16 semitone = 64/3 ≈ 21.33 Taud units, rounded).
- ST3 `EFx` fine → Taud `E $F0 round(x × 16/3)` (1 ST3 fine unit = 1/64 semitone = 16/3 ≈ 5.33 Taud units, applied once per row).
- ST3 `EEx` extra-fine → Taud `E $F0 round(x × 16/3)` (same unit as fine, applied once per row).
- **Linear-source ST3 song** (`linear_slides` set in S3M flags → Taud `f` bit clear):
- ST3 `Exx` coarse (where `xx < $E0`) → Taud `E round($00xx × 64/3)` (1 ST3 coarse unit = 1/16 semitone = 64/3 ≈ 21.33 Taud units, rounded).
- ST3 `EFx` fine → Taud `E $F0 round(x × 16/3)` (1 ST3 fine unit = 1/64 semitone = 16/3 ≈ 5.33 Taud units, applied once per row).
- ST3 `EEx` extra-fine → Taud `E $F0 round(x × 16/3)` (same unit as fine, applied once per row).
ST3 Amiga-mode coarse slides do not have a clean conversion and should be treated as linear-mode equivalents during import (same `round(× 64/3)` scale). The Amiga-mode flag (`f` bit in effect `1` or the song-table flags byte) is set in the output file to signal the mixer to apply the stored values in period space rather than directly in pitch space. This preserves the characteristic non-linearity of Amiga slides (lower pitches slide more slowly in semitone terms) without requiring a different numeric encoding. Fine and extra-fine slides (`E $Fxxx`) are always applied in linear pitch-unit space regardless of the Amiga-mode flag, as they are ST3-specific extensions absent from ProTracker.
- **Amiga-source ST3/PT song** (`linear_slides` clear → Taud `f` bit set):
- ST3 `Exx` coarse / PT `2xx` → Taud `E $00xx` **verbatim**, with no `× 64/3` scaling. The engine reads the stored byte as Amiga period units and applies it in period space, recovering the original tracker's exact period-step count.
- ST3 `EFx` fine / `EEx` extra-fine / PT `E2x` → Taud `E $F00x` **verbatim** (raw period-unit nibble in the low 4 bits), with no `× 16/3` scaling. The engine performs the once-per-row fine slide in Amiga period space, mirroring the coarse arithmetic.
The Amiga-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 bit 1 (`f`) of the song-table flags byte whenever they emit raw period-unit coarse arguments, and MUST NOT mix the two 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.
@@ -176,8 +186,13 @@ on row start:
raw = arg
if raw == 0: raw = memory_EF
else: memory_EF = raw
if (raw & $F000) == $F000: # fine
pitch -= (raw & $0FFF)
if (raw & $F000) == $F000: # fine, applied once on tick 0
mag = raw & $0FFF
if amiga_mode:
# mag is a raw tracker period-unit count; subtract pitch ⇒ add period.
pitch = amiga_slide_down(pitch, mag)
else:
pitch -= mag
mode_this_row = FINE
else: # coarse
slide_amount_this_row = raw
@@ -186,9 +201,10 @@ on row start:
on tick > 0:
if mode_this_row == COARSE:
if amiga_mode:
# period = AMIGA_BASE_PERIOD × 2^((pitch C3) / 4096)
# period += slide_amount_this_row × (3/64) # convert Taud units → Amiga period units
# pitch = C3 + 4096 × log2(AMIGA_BASE_PERIOD / period)
# slide_amount_this_row is a raw tracker period-unit count (no × 64/3 scaling).
# period = AMIGA_BASE_PERIOD × 2^((pitch C4) / 4096)
# period_new = period + slide_amount_this_row # E subtracts pitch ⇒ adds period
# pitch = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
pitch = amiga_slide_down(pitch, slide_amount_this_row)
else:
pitch -= slide_amount_this_row
@@ -200,9 +216,9 @@ Glissando control (S $1x) snaps the output pitch to the nearest semitone after e
## F $xxxx — Pitch slide up by $xxxx
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, memory behaviour, and Amiga-mode handling are identical in form but inverted in direction.
**Plain.** Raises the channel's pitch by the argument per tick, with the same mode-selection scheme as E. Coarse, fine, memory behaviour, and Amiga-mode handling are identical in form but inverted in direction. The same dual-interpretation rule applies to **both** coarse and fine arguments: 4096-TET units in linear mode, raw tracker period units in Amiga mode.
**Compatibility.** Same as E. ST3 `Fxx` coarse converts using `round(x × 64/3)`; `FFx` fine and `FEx` extra-fine convert using `round(x × 16/3)`. F and E share one memory slot in Taud. Amiga-mode behaviour is controlled by the same `f` flag as E; coarse F slides are applied in period space when the flag is set, while fine slides remain linear.
**Compatibility.** Same as E. In linear-source songs, ST3 `Fxx` coarse converts using `round(x × 64/3)` and `FFx`/`FEx` fine/extra-fine use `round(x × 16/3)`. In Amiga-source songs (PT or S3M with `linear_slides` clear), both forms are stored verbatim: `Fxx` coarse → `F $00xx`, and `FFx`/`FEx` fine/extra-fine / PT `E1x``F $F00x`. F and E share one memory slot in Taud. Amiga-mode behaviour is controlled by the same `f` flag as E; under that flag, both coarse (per-tick) and fine (tick-0 only) F slides are applied in period space.
**Implementation.** As for E, but add instead of subtract. No upper pitch cap is defined by the effect itself, but the sample-rate conversion at the mixer will saturate well before arithmetic overflow at reasonable playing ranges.
@@ -499,6 +515,26 @@ A tempo slide's memory slot is separate from the set-tempo path and is private t
---
## W $xy00 — Global volume slide
**Plain.** Similar to `D $xy00`, but applies to the global volume.
**Compatibility.** IT `Wxy` maps directly.
**Implementation.** See effect D, apply to the global volume instead.
---
## X $xx00 — Fine Set Panning
**Plain.** **Unimplemented**. On IT, sets the panning position of the current channel, $00 being full-left and $FF being full-right.
**Compatibility.** Convert to `S $80xx`.
**Implementation.** Not applicable.
---
## Y $xxyy — Panbrello (panning vibrato) with speed $xx and depth $yy
**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.
@@ -523,16 +559,6 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
---
## X $xx00 — Fine Set Panning
**Plain.** **Unimplemented**. On IT, sets the panning position of the current channel, $00 being full-left and $FF being full-right.
**Compatibility.** Convert directly into panning effect `0.$xx`, rounded down to nearest 6-bit value.
**Implementation.** Not applicable.
---
## 8 $xyzz — Bitcrusher
**Plain.** Applies Bitcrusher to the current voice.
@@ -552,11 +578,11 @@ Peak at maximum settings: $7F × $FF >> 9 = $3F — the full panning range. Retr
S is a multiplexing opcode; the **high nibble of the high byte** selects the sub-effect, and the remainder is the sub-argument.
## S $1x00 — 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 compatibility.**
**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.
**Compatibility.** ST3 `S10`/`S11` 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.
**Implementation.** Maintain a per-channel boolean `glissando_on`. When G updates `pitch`, if `glissando_on` is set, compute `display_pitch = round(pitch × 12 / 4096) × 4096 / 12` (using integer division with rounding) and send `display_pitch` to the mixer; otherwise send `pitch` directly.
@@ -608,7 +634,7 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
| $6 | Square | No |
| $7 | Random | No |
**Compatibility.** ST3 `S3x` maps directly.
**Compatibility.** ST3 `S3x` and ProTracker `E4x` maps directly.
**Implementation.** Store `vibrato_waveform = $x & $3` and `vibrato_retrigger = (($x & $4) == 0)` for the channel. The ramp-down shape is `$7F ((pos & $3F) << 2)` across one logical cycle; the square shape is `sign(sine(pos)) × $7F`; random draws a fresh `rand() & $FF $80` every tick. On a new note, if `vibrato_retrigger` is true, reset `lfo_pos = 0`.
@@ -618,7 +644,7 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
**Plain.** Selects the shape of the tremolo (R) oscillator; value encoding is identical to S $3x.
**Compatibility.** ST3 `S4x` maps directly. ProTracker `E7x` maps to Taud `S $4x00`.
**Compatibility.** ST3 `S4x` and ProTracker `E7x` maps directly.
**Implementation.** As for S $3x, but applied to R's separate state (`tremolo_waveform`, `tremolo_retrigger`, and tremolo `lfo_pos`).
@@ -632,14 +658,64 @@ ProTracker `E5x` maps to Taud `S $2x00` with the same index meaning.
**Implementation.** As for S $3x, but applied to Y's separate state (`panbrello_waveform`, `panbrello_retrigger`, and panbrello `lfo_pos`).
---
## S $6x00 — Fine pattern delay
**Plain.** Extends the current row by $x ticks. If multiple S6x commands are on the same row, the sum of their parameters is used.
**Compatibility.** IT `S6x` maps directly.
**Implementation.** TODO
---
## S $7x00 — Note/Instrument actions
**Plain.** Performs following action to the note.
| $x | Operation | Description |
|---|---|---|
| $0 | Past Note Cut | Cuts all notes playing as a result of New Note Actions on the current channel |
| $1 | Past Note Off | Sends a Note Off to all notes playing as a result of New Note Actions on the current channel |
| $2 | Past Note Fade | Fades out all notes playing as a result of New Note Actions on the current channel |
| $3 | NNA Note Cut | Sets the currently active note's New Note Action to Note Cut |
| $4 | NNA Note Continue | Sets the currently active note's New Note Action to Continue |
| $5 | NNA Note Off | Sets the currently active note's New Note Action to Note Off |
| $6 | NNA Note Fade | Sets the currently active note's New Note Action to Note Fade |
| $7 | Volume Envelope Off | Disables the currently active note's volume envelope |
| $8 | Volume Envelope On | Enables the currently active note's volume envelope |
| $9 | Panning Envelope Off | Disables the currently active note's panning envelope |
| $A | Panning Envelope On | Enables the currently active note's panning envelope |
| $B | Pitch Envelope Off | Disables the currently active note's pitch or filter envelope |
| $C | Pitch Envelope On | Enables the currently active note's pitch envelope |
**Compatibility.** IT `S7x` maps directly.
**Implementation.** Engines maintain a *mixer-private* background-voice pool per playhead, separate from the addressable foreground voices. When a fresh note retriggers a still-active foreground voice, the engine reads the effective NNA — the per-voice override set by `S $73..$76` if present, otherwise the instrument's default NNA (instrument record byte 186, low two bits) — and acts on the displaced voice as follows:
- **Note Cut (1):** discard the foreground state in place; no ghost is created.
- **Note Off (0):** clone the foreground voice into the background pool and set its key-off flag, releasing any sustain loop. The clone's volume envelope plays out and fadeout decays from full.
- **Continue (2):** clone the foreground voice into the background pool unchanged; envelopes and sample position continue from where they were.
- **Note Fade (3):** clone the foreground voice into the background pool and immediately begin fadeout decay without releasing sustain. The volume envelope keeps looping its sustain region while fadeoutVolume drains to zero.
Note Fade and Note Off are distinct: Note Fade does **not** set key-off, so the volume envelope's sustain loop continues to cycle; Note Off does set key-off, breaking sustain. Both share the same fadeout slope (`volumeFadeoutLow + (fadeoutHigh & 0x0F << 8)` units per tick out of 1024).
The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or its sample finishes (non-looping). Pool size is implementation-defined; the reference engine caps it at 64 ghosts per playhead and evicts the oldest on overflow. Background voices receive only passive per-tick maintenance (envelope advance, fadeout decay, auto-vibrato, filter coefficient refresh) — no row-driven effects (vibrato/tremolo/arpeggio/Q-retrigger/cut/delay) ever target them, since they are not addressable from the pattern.
`S $70..$72` (Past Note Cut/Off/Fade) operate on every ghost whose `sourceChannel` matches the issuing channel: $70 drops them outright, $71 sets key-off on each, $72 begins fadeout on each.
`S $73..$76` write the per-voice NNA override on the **currently active foreground voice** so that *its* next NNA event uses the overridden action. The override is cleared on every fresh trigger.
`S $77..$7C` toggle the volume / panning / pitch-or-filter envelope on the currently active voice. While disabled, the envelope is frozen (no advancement) and the mixer treats its contribution as unity (envVolume / envPan / envPfValue all replaced by the neutral 1.0 / 0.5 / 0.5).
---
## S $80xx — Set channel pan position
**Plain.** Sets the channel pan to `$xx`, with $00 being full left and $FF being full right. $80 is centre.
**Plain.** Sets the channel pan to `$xx`, with $00 being full left and $FF being full right. $80 is centre. When this command and panning column's Set Pan are both present, this command takes precedence.
**Compatibility.** ST3 `S8x` uses a 4-bit value.
**Compatibility.** IT `Xxx` maps directly. ST3 `S8x` uses a 4-bit value.
1. convert by nibble-repeat: ST3 `S83` → Taud `S $8033`. Panning column command `0.$xx` has the same semantics and is the preferred form when a pan column is available in the pattern. ProTracker `8xx` (fine pan) and `E8x` (coarse pan) both map into Taud's 8-bit pan — the ProTracker 8-bit form maps directly; the 4-bit form nibble-repeats.
2. convert to PanEff: ST3 `S8x` → PanEff `0.yy`, where `yy = round(4.2 * x)`
@@ -693,9 +769,9 @@ The crucial bug fix relative to ST3: the loop-counter decrement happens **once p
**Plain.** Delays the triggering of the note (and any co-row instrument, offset, and volume event) until tick `$x`. Until then, any currently playing note continues.
**Compatibility.** ST3 `SDx` maps directly. ProTracker `EDx` also maps directly. `SD0` plays the note normally on tick 0. If `$x ≥ speed`, the note never plays on this row and does not carry over to the next row.
**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.
**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.
**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)
---
@@ -711,19 +787,19 @@ Q retrigger counters do **not** reset between SEx repetitions.
---
## S $Fx00 — Funk repeat with speed $x (non-destructive)
## S $Fxxx — Funk repeat with speed $xxx (non-destructive)
**Plain.** Produces a hiss-like progressive inversion of the sample loop, toggling individual bytes over time for a gritty textural effect. Setting `$x = 0` turns the effect off; higher `$x` advances the inversion faster.
**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 directly as Taud `S $Fx00`.
**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]`.
**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.
```
funk_table[16] = { 0, 5, 6, 7, 8, $A, $B, $D, $10, $13, $16, $1A, $20, $2B, $40, $80 }
on every tick (when S $Fx00 is active with x != 0):
funk_accumulator += funk_table[x]
on every tick (when S $Fxxxx is active with x != 0):
funk_accumulator += funk_length
while funk_accumulator >= $80:
funk_accumulator -= $80
bit = funk_mask[funk_write_pos]
@@ -768,7 +844,7 @@ The panning column uses the same 6-bit value + 2-bit selector layout:
- **`2.$xx` — Pan slide left** by `$xx` per non-first tick (4-bit).
- **`3.$Sx` — Fine pan slide** on tick 0 only, same direction-bit encoding as the volume column's selector 3.
NOTE: **`3.00` — is No-op**
NOTE: **`3.00` — is No-op**. When Set Pan and S $80xx are both present, S-command takes precedence.
---
@@ -785,8 +861,8 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting.
- p set: Equal-power panning mode. L/R amplitude is at 0.707 when centre-panned.
- f unset: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode.
- f set: Amiga tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode.
- f unset: Linear tone mode. Pitch shift will behave like MIDI/ImpulseTracker/ScreamTracker linear mode. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch.
- f set: Amiga (cycle-based) tone mode. Pitch shift will behave like ProTracker/ScreamTracker default mode. **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.
**Implementation.**
- Panning-linear:
@@ -795,12 +871,11 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
- Panning-equal-power:
- L_gain = cos(pi*x / 512.0)
- R_gain = sin(pi*x / 512.0)
- Amiga tone (coarse E/F pitch slides only; fine slides are always linear):
- AMIGA_BASE_PERIOD = 214.0 (period at the Taud reference pitch C3 for a standard 8363 Hz instrument, NTSC clock)
- AMIGA_PERIOD_SCALE = 3.0 / 64.0 (converts stored Taud coarse-slide units back to Amiga period units)
- period = AMIGA_BASE_PERIOD × 2^((noteVal C3) / 4096)
- period_new = period slideArg × AMIGA_PERIOD_SCALE (slideArg < 0 for E, > 0 for F)
- noteVal_new = C3 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
- Amiga tone (both coarse and fine E/F pitch slides). The `slideArg` is a **raw tracker period-unit count** (no scaling), with sign matching linear mode (negative for E, positive for F). Coarse slides apply on every non-first tick; fine slides apply once on tick 0 — the per-step arithmetic is identical:
- AMIGA_BASE_PERIOD = 428.0 (period at the Taud reference pitch C4 for a standard 8363 Hz instrument, NTSC clock — identical to PT "C-2" period 428)
- period = AMIGA_BASE_PERIOD × 2^((noteVal C4) / 4096)
- period_new = period slideArg (E subtracts pitch ⇒ adds period; F adds pitch ⇒ subtracts period)
- noteVal_new = C4 + 4096 × log2(AMIGA_BASE_PERIOD / period_new)
**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.
@@ -813,9 +888,9 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
| PT effect | Taud effect | Notes |
|---------|-----------|-------|
| `0 $xy` | `J $xxyy` | Arpeggio; nibble-repeat each byte. See the 12-TET → Taud table above for conversion losses |
| `1 $xx` | `F round($0xxx × 64/3)` | Portamento up; ST3 coarse slide unit = 1/16 semitone |
| `2 $xx` | `E round($0xxx × 64/3)` | Portamento down |
| `3 $xx` | `G round($0xxx × 64/3)` | Portamento to note |
| `1 $xx` | `F $00xx` (Amiga mode, `f` set) | Portamento up; raw PT period units, applied in period space |
| `2 $xx` | `E $00xx` (Amiga mode, `f` set) | Portamento down; raw PT period units, applied in period space |
| `3 $xx` | `G round($0xxx × 64/3)` | Portamento to note; G is always linear (4096-TET units) regardless of mode |
| `4 $xy` | `H $xxyy` | Vibrato; nibble-repeat each byte. |
| `5 $xy` | `L $xy00` | Combined portamento + volume slide (see compatibility note) |
| `6 $xy` | `K $xy00` | Combined vibrato + volume slide (see compatibility note) |
@@ -827,8 +902,8 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two
| `C $xx` | Volume column `0.$xx` | Set volume |
| `D $xx` | `C $00xx` (after BCD decode) | Pattern break |
| `E $0x` | `S $000x` | (UNIMPLEMENTED) Set filter |
| `E $1x` | `E $F000 + round($0xxx × 16/3)` | Fine pitch slide up |
| `E $2x` | `E $F000 + round($0xxx × 16/3)` | Fine pitch slide down |
| `E $1x` | `F $F00x` (Amiga mode, `f` set) | Fine pitch slide up; raw PT period units, applied in period space at tick 0 |
| `E $2x` | `E $F00x` (Amiga mode, `f` set) | Fine pitch slide down; raw PT period units, applied in period space at tick 0 |
| `E $3x` | `S $1x00` | Glissando control |
| `E $4x` | `S $3x00` | Vibrato waveform |
| `E $5x` | `S $2x00` | Set fine-tune |
@@ -867,7 +942,11 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in
**Global volume scale.** ST3's 0..$40 maps to Taud's 0..$FF with a ×4 scale on import, truncated ÷4 on export.
**Linear pitch slides.** ST3's slide arithmetic is period-based (Amiga) or linear-table-indexed; Taud's default is purely linear in 4096-TET units. ST3 songs in linear mode convert cleanly: coarse forms (Exx/Fxx/Gxx) use `round(× 64/3)` (1/16 semitone per unit), fine/extra-fine forms (EFx/EEx/FFx/FEx) use `round(× 16/3)` (1/64 semitone per unit). ST3 songs in Amiga mode use the **same numeric conversion** for coarse E/F (the exact period-step count is not preserved), but the converter sets bit 1 (`f`) of the song-table flags byte and Taud's mixer re-applies the stored coarse slide values in Amiga period space at playback, recovering the non-linear pitch character. G is always treated as linear regardless of mode. Fine/extra-fine slides are always linear.
**Linear pitch slides.** ST3's slide arithmetic is period-based (Amiga) or linear-table-indexed; Taud carries both interpretations and selects between them via the song-table `f` flag. Conversion rules:
- **ST3 linear mode** (`linear_slides` set in S3M flags): coarse forms (Exx/Fxx) use `round(× 64/3)` (1/16 semitone per ST3 unit); fine/extra-fine (EFx/EEx/FFx/FEx) use `round(× 16/3)` (1/64 semitone per ST3 unit). Taud `f` flag is **clear**; the engine subtracts the stored 4096-TET argument directly from the channel pitch.
- **ST3 Amiga mode** (`linear_slides` clear): 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.
**Default tempo byte.** Taud's default $65 equals 125 BPM under the $18 offset; this is not the same as ST3's `$7D` default, which maps to Taud `$65` after subtracting $18. Converters must remap on both import and export.

View File

@@ -310,7 +310,7 @@ function buildRowCell(ptnDat, row) {
else if (voleff >>> 6 == 3) {
if (voleffarg == 0) {
sVolEff = sym.middot
sVolArg = sym.middot.repeat(1)
sVolArg = sym.middot
}
else if (voleffarg >= 32) {
sVolEff = volEffSym[3]
@@ -334,7 +334,7 @@ function buildRowCell(ptnDat, row) {
else if (paneff >>> 6 == 3) {
if (paneffarg == 0) {
sPanEff = sym.middot
sPanArg = sym.middot.repeat(1)
sPanArg = sym.middot
}
else if (paneffarg >= 32) {
sPanEff = panEffSym[4]
@@ -359,7 +359,7 @@ function buildRowCell(ptnDat, row) {
const EMPTY_CELL = {
sNote: sym.middot.repeat(4),
sInst: sym.middot.repeat(3),
sInst: sym.middot.repeat(2),
sVolEff: '',
sVolArg: sym.middot.repeat(2),
sPanEff: '',
@@ -2370,6 +2370,8 @@ taud.uploadTaudFile(fullPathObj.full, 0, PLAYHEAD)
audio.setMasterVolume(PLAYHEAD, 255)
audio.setMasterPan(PLAYHEAD, 128)
const initialTrackerMixerflags = audio.getTrackerMixerFlags(PLAYHEAD)
//const initialGlobalVolume =
//const initialMixingVolume =
function isExternalPanel(p) {
return p === VIEW_SAMPLES || p === VIEW_INSTRMNT || p === VIEW_FILE

View File

@@ -10,7 +10,7 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
const TAUD_VERSION = 1
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + rsvd(2) + sig(16)
const TAUD_SONG_ENTRY = 16 // bytes per song-table row (offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+pad(7))
const SAMPLEINST_SIZE = 786432 // 770047 sample + 16384 instrument
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
const NUM_PATTERNS_MAX = 256
const NUM_CUES = 1024
@@ -95,6 +95,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
// Write decompressed data to peripheral memory (backwards addressing:
// peripheral byte k lives at memBase - k).
for (let i = 0; i < SAMPLEINST_SIZE; i++) {
// TODO use sys.memcpy
sys.poke(memBase - i, sys.peek(decompPtr + i))
}
sys.free(decompPtr)

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ Usage:
Limits:
- Up to 20 MOD channels (excess disabled; hard error if pattern count
× channel count > 4095).
- Sample bin is 770048 bytes; if all samples together exceed this, every
- Sample bin is 737280 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is
preserved.
@@ -34,7 +34,7 @@ from taud_common import (
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
@@ -64,7 +64,7 @@ PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
SIGNATURE = b"mod2taud/TSVM " # 14 bytes
# PT period 428 (PT "C-2") corresponds to OpenMPT/IT C-4 which s3m2taud
# anchors to Taud C3 (0x4000). We use the same anchor so MOD/S3M imports
# anchors to Taud C4 (0x5000). We use the same anchor so MOD/S3M imports
# share a pitch reference.
PT_REFERENCE_PERIOD = 428.0
@@ -224,7 +224,7 @@ def _signed4(nibble: int) -> int:
def period_to_taud_note(period: int) -> int:
if period <= 0:
return NOTE_NOP
val = round(TAUD_C3 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
val = round(TAUD_C4 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
return max(1, min(0xFFFD, val))
@@ -244,11 +244,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
lo = arg & 0xF
return (TOP_J, (J_SEMI_TABLE[hi] << 8) | J_SEMI_TABLE[lo], None, None)
# PT is Amiga-cycle-based by definition (the Taud Amiga-mode flag is set in
# the song table, see end of build_taud()). E/F coarse pitch-slide arguments
# are therefore stored as raw PT period units; the engine consumes them
# directly in period space. G (tone portamento) is treated as linear even
# in Amiga mode per the Taud spec, so its argument is still quantised to
# 4096-TET units. Fine slides (E1x/E2x below) likewise remain linear.
if cmd == 0x1:
return (TOP_F, round(arg * 64 / 3) & 0xFFFF, None, None)
return (TOP_F, arg & 0xFFFF, None, None)
if cmd == 0x2:
return (TOP_E, round(arg * 64 / 3) & 0xFFFF, None, None)
return (TOP_E, arg & 0xFFFF, None, None)
if cmd == 0x3:
return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None)
@@ -304,11 +310,11 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
# E0x = filter on/off (Amiga LED filter); no Taud equivalent.
return (TOP_NONE, 0, None, None)
if sub == 0x1:
# Fine pitch up.
return (TOP_F, 0xF000 | (round(x * 16 / 3) & 0xFFF), None, None)
# Fine pitch up — raw PT period units in Amiga mode (file is always Amiga).
return (TOP_F, 0xF000 | (x & 0xFFF), None, None)
if sub == 0x2:
# Fine pitch down.
return (TOP_E, 0xF000 | (round(x * 16 / 3) & 0xFFF), None, None)
# Fine pitch down — raw PT period units in Amiga mode.
return (TOP_E, 0xF000 | (x & 0xFFF), None, None)
if sub == 0x3:
return (TOP_S, 0x1000 | (x << 8), None, None)
if sub == 0x4:
@@ -337,7 +343,8 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
if sub == 0xE:
return (TOP_S, 0xE000 | (x << 8), None, None)
if sub == 0xF:
return (TOP_S, 0xF000 | (x << 8), None, None)
funk_table = [0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80]
return (TOP_S, 0xF000 | funk_table[x], None, None)
return (TOP_NONE, 0, None, None)
if cmd == 0xF:
@@ -350,6 +357,61 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
return (TOP_NONE, 0, None, None)
def relocate_late_note_delays(patterns: list, order_list: list,
n_channels: int, initial_speed: int) -> None:
"""Move EDx-delayed notes to the next row when x ≥ tick speed.
PT triggers a Note Delay during the current row; if x reaches the tick
speed, the trigger never lands. When the next row in the same channel is
empty, relocate the note (with delay = x speed) so it actually plays.
"""
visited = set()
for order in order_list:
if order >= 0xFF:
break
if order >= len(patterns) or order in visited:
continue
visited.add(order)
grid = patterns[order]
speed = initial_speed
for r in range(MOD_PATTERN_ROWS):
for ch in range(min(n_channels, len(grid))):
row = grid[ch][r]
if row.effect == 0xF and 0 < row.effect_arg < 0x20:
speed = row.effect_arg
break
if r + 1 >= MOD_PATTERN_ROWS or speed <= 0:
continue
for ch in range(min(n_channels, len(grid))):
row = grid[ch][r]
if row.effect != 0xE or row.period == 0:
continue
if ((row.effect_arg >> 4) & 0xF) != 0xD:
continue
x = row.effect_arg & 0xF
if x < speed:
continue
nxt = grid[ch][r + 1]
if (nxt.period or nxt.inst or nxt.effect or nxt.effect_arg
or nxt.vol_set != -1):
continue
new_delay = x - speed
nxt.period = row.period
nxt.inst = row.inst
nxt.vol_set = row.vol_set
if new_delay > 0:
nxt.effect = 0xE
nxt.effect_arg = 0xD0 | (new_delay & 0xF)
row.period = 0
row.inst = 0
row.effect = 0
row.effect_arg = 0
row.vol_set = -1
vprint(f" fix: pat{order} ch{ch} row{r}: ED{x:X} ≥ speed{speed}, "
f"moved note to row{r+1}"
+ (f" with ED{new_delay:X}" if new_delay > 0 else ""))
def resolve_pt_recalls(patterns: list, order_list: list, n_channels: int) -> None:
"""In-place: replace PT zero-arg recalls with each effect's last non-zero arg.
@@ -427,6 +489,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
s.loop_end = min(s.loop_end, n)
pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070).
inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples):
taud_idx = i + 1 # 1-based instrument number
@@ -434,29 +497,35 @@ def build_sample_inst_bin(samples: list) -> tuple:
break
if not s.sample_data:
continue
ptr = offsets.get(i, 0)
ptr_lo = ptr & 0xFFFF
ptr_hi = (ptr >> 16)
ptr = offsets.get(i, 0) & 0xFFFFFFFF
s_len = min(s.length, 65535)
c2spd = min(s.c2spd, 65535)
ps = 0
ls = min(s.loop_begin, 65535)
le = min(s.loop_end, 65535)
loop_mode = 1 if (s.flags & 1) else 0
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3)
base = taud_idx * 64
struct.pack_into('<H', inst_bin, base + 0, ptr_lo)
struct.pack_into('<H', inst_bin, base + 2, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd)
struct.pack_into('<H', inst_bin, base + 6, ps)
struct.pack_into('<H', inst_bin, base + 8, ls)
struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte
inst_bin[base + 15] = 0xFF # global volume — full
flags_byte = loop_mode & 0x3
env_vol = min(s.volume, 63)
inst_bin[base + 16] = env_vol # envelope hold value
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
vol_env_flags = 0x0020 # use-envelope bit
base = taud_idx * 192
struct.pack_into('<I', inst_bin, base + 0, ptr)
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd)
struct.pack_into('<H', inst_bin, base + 8, ps)
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xFF # instrument global volume
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
@@ -505,9 +574,11 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit Cxx volume)")
elif note_triggers and last_inst > 0:
elif note_triggers and row.inst > 0:
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif retrigger and last_vol is not None:
vol_sel, vol_value = SEL_SET, last_vol
elif vol_override is not None:
@@ -626,6 +697,9 @@ def assemble_taud(mod: dict) -> bytes:
vprint(" resolving PT per-effect recalls…")
resolve_pt_recalls(patterns, order_list, n_channels)
init_speed, _ = find_initial_bpm_speed(patterns, order_list)
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(samples)
assert len(sampleinst_raw) == SAMPLEINST_SIZE

View File

@@ -7,7 +7,7 @@ Usage:
Limits:
- Up to 20 S3M channels (excess disabled; hard error if pattern count
× channel count > 4095).
- Sample bin is 770048 bytes; if all samples together exceed this, every
- Sample bin is 737280 bytes; if all samples together exceed this, every
sample is globally resampled down (with c2spd adjusted) so pitch is
preserved.
- AdLib instruments are skipped.
@@ -34,7 +34,7 @@ from taud_common import (
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C3,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
@@ -231,16 +231,23 @@ def encode_note(s3m_note: int) -> int:
if pitch > 11:
return NOTE_NOP
semitones = (octave - 4) * 12 + pitch
val = round(TAUD_C3 + semitones * 4096 / 12)
val = round(TAUD_C4 + semitones * 4096 / 12)
return max(1, min(0xFFFD, val))
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
amiga_mode: bool = False) -> tuple:
"""Return (taud_op, taud_arg16, vol_override, pan_override).
vol/pan_override is None or (selector, value). The caller is responsible
for resolving ST3 zero-arg recalls before this point — see
resolve_st3_recalls().
amiga_mode mirrors the inverse of the S3M ``linear_slides`` flag. When
set, E/F coarse pitch-slide arguments are emitted as raw ST3 period units
(the engine applies them directly in period space); when clear they are
quantised to 4096-TET units via ``round(× 64/3)``. Fine/extra-fine
slides and tone portamento (G) are always linear regardless of mode.
"""
if cmd == 0:
return (TOP_NONE, 0, None, None)
@@ -265,12 +272,20 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
return (TOP_D, (arg & 0xFF) << 8, None, None)
if cmd in (EFF_E, EFF_F):
# Coarse: 1/16 semitone = 64/3 Taud units. Fine/extra-fine: 1/64 semitone = 16/3.
# Coarse: 1/16 semitone = 64/3 Taud units in linear mode; raw ST3 period
# units in Amiga mode (engine consumes them in period space).
# Fine/extra-fine (Exx with hi ∈ {E,F}): 1/64 semitone = 16/3 Taud units
# in linear mode; raw ST3 period units in Amiga mode (engine consumes
# them in period space, applied once per row at tick 0).
op = TOP_E if cmd == EFF_E else TOP_F
hi = (arg >> 4) & 0xF
lo = arg & 0xF
if hi in (0xE, 0xF) and lo > 0:
if amiga_mode:
return (op, 0xF000 | (lo & 0xFFF), None, None)
return (op, 0xF000 | (round(lo * 16 / 3) & 0xFFF), None, None)
if amiga_mode:
return (op, arg & 0xFFFF, None, None)
return (op, round(arg * 64 / 3) & 0xFFFF, None, None)
if cmd == EFF_G:
@@ -315,14 +330,19 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
if cmd == EFF_S:
sub = (arg >> 4) & 0xF
val = arg & 0xF
if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE, 0xF):
if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE):
vprint(f" dropped S{sub:01X} at ch{ch} row{row}")
return (TOP_S, (sub << 12) | (val << 8), None, None)
if sub == 0x5:
# Panbrello LFO waveform — maps directly to Taud S$5x00.
return (TOP_S, 0x5000 | (val << 8), None, None)
if sub == 0x8:
# S8x → PanEff 0.yy where yy = round(x * 4.2), mapping nibble 0-15 to pan 0-63.
return (TOP_NONE, 0, None, (SEL_SET, round(val * 4.2)))
# S8x: 4-bit → nibble-repeat into 8-bit SEL_SET pan
pan8 = (val << 4) | val
return (TOP_S, 0x8000 | pan8, None, None)
if sub == 0xF:
funk_table = [0, 5, 6, 7, 8, 0xA, 0xB, 0xD, 0x10, 0x13, 0x16, 0x1A, 0x20, 0x2B, 0x40, 0x80]
return (TOP_S, 0xF000 | funk_table[x], None, None)
# S0/S6/S7/S9/SA: filter, NNA, sound-control, stereo — drop silently.
return (TOP_NONE, 0, None, None)
@@ -340,7 +360,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
return (TOP_NONE, 0, None, None)
if cmd == EFF_X:
return (TOP_NONE, 0, None, (SEL_SET, min(arg >> 2, 0x3F)))
return (TOP_S, 0x8000 | (arg & 0xFF), None, None)
if cmd == EFF_Y:
hi = (arg >> 4) & 0xF
@@ -455,7 +475,9 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst.loop_end = min(inst.loop_end, n)
pos += n
# Build instrument bin (256 × 64 bytes)
# Build instrument bin (256 × 192 bytes)
# New layout (terranmon.txt:1997-2070): u32 sample ptr, ..., 25-point envelopes,
# plus a host of optional fields. S3M doesn't supply most of those — they default to 0.
inst_bin = bytearray(INSTBIN_SIZE)
for i, inst in enumerate(instruments):
taud_idx = i + 1
@@ -463,33 +485,41 @@ def build_sample_inst_bin(instruments: list) -> tuple:
break
if inst is None or inst.itype != S3M_TYPE_PCM:
continue
ptr = offsets.get(i, 0)
ptr_lo = ptr & 0xFFFF
ptr_hi = (ptr >> 16)
ptr = offsets.get(i, 0) & 0xFFFFFFFF
s_len = min(inst.length, 65535)
c2spd = min(inst.c2spd, 65535)
ps = 0
ls = min(inst.loop_begin, 65535)
le = min(inst.loop_end, 65535)
loop_mode = 1 if (inst.flags & 1) else 0
flags_byte = (ptr_hi << 4) | (loop_mode & 0x3) # hhhh 00pp
flags_byte = loop_mode & 0x3 # 0b 0000 00pp
base = taud_idx * 64
struct.pack_into('<H', inst_bin, base + 0, ptr_lo)
struct.pack_into('<H', inst_bin, base + 2, s_len)
struct.pack_into('<H', inst_bin, base + 4, c2spd)
struct.pack_into('<H', inst_bin, base + 6, ps)
struct.pack_into('<H', inst_bin, base + 8, ls)
struct.pack_into('<H', inst_bin, base + 10, le)
inst_bin[base + 12] = flags_byte
inst_bin[base + 15] = 0xFF # instrument global volume (S3M has none → full)
# Volume envelope: hold at instrument volume (clamped to 0x3F)
# Volume envelope: hold at instrument volume (clamped to 0x3F).
env_vol = min(inst.volume, 63)
inst_bin[base + 16] = env_vol # volume
inst_bin[base + 17] = 0 # offset minifloat = 0 → hold
# Vol env-flags: enable use-envelope bit (b=1) so engine reads the single point.
vol_env_flags = 0x0020 # b=bit 5
base = taud_idx * 192
struct.pack_into('<I', inst_bin, base + 0, ptr) # u32 sample pointer
struct.pack_into('<H', inst_bin, base + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd) # rate at TAUD_C4
struct.pack_into('<H', inst_bin, base + 8, ps)
struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
struct.pack_into('<H', inst_bin, base + 17, 0) # pan env-flags
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env-flags
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0
inst_bin[base + 171] = 0xFF # instrument global volume
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
inst_bin[base + 182] = 0xFF # filter cutoff = off
inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut
vprint(f" instrument[{base // 64}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
vprint(f" instrument[{base // 192}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
if inst.c2spd > 65535:
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
@@ -509,7 +539,8 @@ def _default_channel_pan(ch_setting: int) -> int:
def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
linear_slides: bool, inst_vols: dict = None) -> bytes:
linear_slides: bool, inst_vols: dict = None,
amiga_mode: bool = False) -> bytes:
"""Build a 512-byte Taud pattern for one S3M channel.
Volume column: explicit S3M cell vol → SEL_SET; when a note triggers
@@ -541,7 +572,7 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
and last_note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
op, arg, vol_override, pan_override = encode_effect(
row.effect, row.effect_arg, ch_idx, r)
row.effect, row.effect_arg, ch_idx, r, amiga_mode=amiga_mode)
# ── Volume column ──
note_triggers = (row.note not in (S3M_NOTE_EMPTY, S3M_NOTE_OFF))
@@ -550,11 +581,15 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
if vol_override is not None and vol_override[0] != SEL_SET:
vprint(f" ch{ch_idx} row{r}: dropped vol slide "
f"(cell already carries explicit volume)")
elif note_triggers and last_inst > 0:
# Note trigger with no explicit vol: use instrument default volume
# so prior channel-vol state doesn't bleed through.
elif note_triggers and row.inst > 0:
# Note trigger with a fresh instrument: use that instrument's
# default volume.
vol_sel = SEL_SET
vol_value = inst_vols.get(last_inst, 0x3F)
elif note_triggers and last_vol is not None:
# Note trigger without instrument: keep the channel's current
# volume rather than resetting to the instrument default.
vol_sel, vol_value = SEL_SET, last_vol
elif retrigger and last_vol is not None:
# Instrument-only row: re-emit the last known volume so the sample
# restarts at the correct level without an explicit note trigger.
@@ -624,6 +659,61 @@ def build_cue_sheet(order_list: list, num_pats_s3m: int, num_channels: int,
return bytes(sheet)
def relocate_late_note_delays(patterns: list, order_list: list,
num_channels: int, initial_speed: int) -> None:
"""Move SDx-delayed notes to the next row when x ≥ tick speed.
ST3 triggers a Note Delay during the current row; if x reaches the tick
speed, the trigger never lands. When the next row in the same channel is
empty, relocate the note (with delay = x speed) so it actually plays.
"""
visited = set()
for order in order_list:
if order >= S3M_ORDER_END:
break
if order >= len(patterns) or order in visited:
continue
visited.add(order)
grid = patterns[order]
speed = initial_speed
for r in range(PATTERN_ROWS):
for ch in range(min(num_channels, len(grid))):
row = grid[ch][r]
if row.effect == EFF_A and row.effect_arg > 0:
speed = row.effect_arg
break
if r + 1 >= PATTERN_ROWS or speed <= 0:
continue
for ch in range(min(num_channels, len(grid))):
row = grid[ch][r]
if row.effect != EFF_S or row.note == S3M_NOTE_EMPTY:
continue
if ((row.effect_arg >> 4) & 0xF) != 0xD:
continue
x = row.effect_arg & 0xF
if x < speed:
continue
nxt = grid[ch][r + 1]
if (nxt.note != S3M_NOTE_EMPTY or nxt.inst or nxt.effect
or nxt.effect_arg or nxt.vol != -1):
continue
new_delay = x - speed
nxt.note = row.note
nxt.inst = row.inst
nxt.vol = row.vol
if new_delay > 0:
nxt.effect = EFF_S
nxt.effect_arg = 0xD0 | (new_delay & 0xF)
row.note = S3M_NOTE_EMPTY
row.inst = 0
row.vol = -1
row.effect = 0
row.effect_arg = 0
vprint(f" fix: pat{order} ch{ch} row{r}: SD{x:X} ≥ speed{speed}, "
f"moved note to row{r+1}"
+ (f" with SD{new_delay:X}" if new_delay > 0 else ""))
def find_initial_bpm_speed(patterns: list, order_list: list,
default_speed: int, default_tempo: int) -> tuple:
"""Scan first pattern in order for Axx/Txx in row 0 of any channel."""
@@ -668,6 +758,10 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
resolve_st3_recalls(patterns, h.order_list, 32)
warn_st3_quirks(patterns, h.order_list, 32)
init_speed, _ = find_initial_bpm_speed(patterns, h.order_list,
h.initial_speed, h.initial_tempo)
relocate_late_note_delays(patterns, h.order_list, 32, init_speed)
# Build sample+instrument bin
vprint(" building sample/instrument bin…")
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
@@ -713,7 +807,8 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
for pi in range(P):
grid = patterns[pi]
for vi, ch in enumerate(active_channels):
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, inst_vols)
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides,
inst_vols, amiga_mode=not h.linear_slides)
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
# Deduplicate identical patterns

View File

@@ -30,8 +30,8 @@ TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
TAUD_SONG_ENTRY = 16 # offset(4)+voices(1)+pats(2)+bpm(1)+tick(1)+basenote(2)+basefreq(4)+flags(1)
SAMPLEBIN_SIZE = 770048
INSTBIN_SIZE = 16384 # 256 instruments × 64 bytes
SAMPLEBIN_SIZE = 737280
INSTBIN_SIZE = 49152 # 256 instruments × 192 bytes
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
@@ -44,7 +44,7 @@ NUM_VOICES = 20
NOTE_NOP = 0xFFFF
NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE
TAUD_C3 = 0x4000
TAUD_C4 = 0x5000 # The audio engine's Middle C
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00

View File

@@ -1973,20 +1973,20 @@ A universal, simple cue designed to work as both playlist to cue up external fil
--------------------------------------------------------------------------------
**Sound Adapter**
**Audio Adapter**
Endianness: little
TSVM Sound Adapter is consisted of 4 playheads, each playhead is capable of playing one PCM or Tracker track.
TSVM Audio Adapter is consisted of 4 playheads, each playhead is capable of playing one PCM or Tracker track.
Synchronisation between playheads are not guaranteed. Do not play music in multiple tracks.
Memory Space
0..770047 RW: Sample bin (752k)
770048..786431 RW: Instrument bin (256 instruments, 64 bytes each; instrument 0 does nothing; 16k)
0..737279 RW: Sample bin (720k)
737280..786431 RW: Instrument bin (256 instruments, 192 bytes each; instrument 0 does nothing; 48k)
786432..851967 RW: Play data 1 (currently exposed bank; 64k)
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
917504..983039 RW: TAD Input Buffer (64k)
@@ -1995,39 +1995,112 @@ Memory Space
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
Instrument bin: Registry for 256 instruments, formatted as:
Uint16 Sample Pointer
Uint32 Sample Pointer
Uint16 Sample length
Uint16 Sampling rate at C3 (note number 0x4000)
Uint16 Sampling rate at C4 (note number 0x5000)
Uint16 Play Start (usually 0 but not always)
Uint16 Loop Start (can be smaller than Play Start)
Uint16 Loop End
Bit8 Sample Flags
0b hhhh 00pp
h: sample pointer high bit
0b 0000 0spp
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
Bit8 Volume envelope sustain/loops
s: loop is sustain (key-off escapes the loop)
- IT: look for sample's SusLoop flag
Bit16 Volume envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index
0b ut eee sss
0b 0ut sssss pcb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
p: (IT) fadeout is zero; (XM) fadeout is cut
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Bit8 Panning envelope sustain/loops
Bit16 Panning envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag
0b ut eee sss
0b 0ut sssss pcb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
p: use default pan (see offset 176 "Default pan value" below)
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
Bit16x12 Volume envelopes
Bit16 Pitch/Filter envelope sustain/loops and flags
* Sustain is implemented by enabling 't' flag
0b 0ut sssss mcb eeeee
s: sustain/loop start index
e: sustain/loop end index
b: use envelope
c: envelope carry
m: mode (0: on pitch, 1: on filter)
t: the loop must sustain (key-off escapes the loop)
u: set to enable the sustain/loop
Bit16x25 Volume envelopes
Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Bit16x12 Panning envelopes
Bit16x25 Panning envelopes
Byte 1: Pan (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Bit16x25 Pitch/Filter envelopes
Byte 1: Value (00..FF)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
Uint8 Instrument Global Volume (0..255)
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
Uint8 Volume Fadeout low bits (IT: 1..256; XM: 0..255)
Bit8 Fadeout and vibrato
0b 0000 ffff
f: Volume Fadeout high bits
Uint8 Volume swing (0..255 full range)
Uint8 Vibrato speed
* ImpulseTracker has samplewise vibrato speed (0..64), and they must be taken into account because Taud has no samplewise config
* FastTracker2 has instrumentwise config (0..255)
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
Uint8 Vibrato sweep
* FastTracker2 instrument config
Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
* ImpulseTracker has samplewise default pan and instrumentwise default pan, and they must be taken into account because Taud has no samplewise config
Uint16 Pitch-pan centre (4096-TET note value)
Sint8 Pitch-pan separation (-128..127 full range)
Uint8 Pan swing (0..255 full range)
Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
Uint16 Sample detune (in 4096-TET unit) (XM finetune scale need to be rescaled accordingly)
Bit8 Instrument Flag
0b 000 www nn
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
ww: Vibrato waveform (IT: sample config, FT2: instrument config). 00: sine, 01: ramp-down saw, 10: square, 11: random, 100: ramp-up saw (FT2 only)
Uint8 Vibrato Depth (0..255 full range)
* ImpulseTracker has range of 0..32 ON THE SAMPLE SETTINGS; multiply by (255/32) then round to int
* FastTracker2 has range of 0..16; multiply by (255/16) then round to int
Uint8 Vibrato Rate (0..255 full range)
* ImpulseTracker sample config. The spec follows ImpulseTracker precisely
Byte[4] Reserved
TODO:
[x] implement Instrument Flag, Vibrato Depth, Vibrato Rate, other samplewise/instrumentwise changes to it2taud and audio engine
[x] implement new note action on the audio engine (IT uses "background channels", maybe we can do the same but make "background channels" mixer-private)
[x] (same context as above) implement S7x command
[x] on playback, panning changes randomly on Taud made by s3m2taud.py and mod2taud.py, but not by it2taud.py (maybe something's off with the instrument exports?)
[x] NNA not disabled for S3M and MOD
[ ] implement S6x command
[ ] `S B000` and `S B100` not working as intended -- on first playback it jumps to the next cue same row, on subsequent playbacks the commands are completely ignored
[ ] implement Wxx command (global volume slide)
[ ] implement sample loop sustain
[ ] cue and pattern compression of the Taud format (taud_common.py, taud.mjs)
[ ] figure out how IT (8 bits) and FT2 (12 bits) handles volume fadeout numbers, and come up with a compatible Taud spec, then implement
[ ] implement bitcrusher (eff sym '8')
[ ] Figure out why pitch slides are not working right on 2nd_pm.s3m only
Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2045,7 +2118,7 @@ note 0x0000: key-off
inst 0: no instrument change
Sound Adapter MMIO
Audio Adapter MMIO
0..1 RW: Play head #0 position
PCM mode: number of buffers uploaded and received by the adapter (writing does nothing)
@@ -2232,7 +2305,7 @@ Endianness: Little
Byte[14]Tracker/Converter signature
## Song Table
* Rows of 16 bytes:
* Rows of 32 bytes:
Uint32 Song offset
Uint8 Number of voices
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
@@ -2241,6 +2314,11 @@ Endianness: Little
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
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 Song global volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Uint8 Song mixing volume
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
Byte[14] Reserved
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
@@ -2255,7 +2333,7 @@ Endianness: Little
For your reference, tracker default tuning at A4 is 439.526 Hz (8363*2^(3/4) / 32)
## Pattern Bin and Cue Sheet
Pattern Bin/Cue Sheet images
RAM image of Pattern Bin/Cue Sheet
## Project Data
@@ -2323,8 +2401,8 @@ prefixes:
Note: custom notations will use internal index 65535 down to 65520 (index 0 = 65535, index 15 = 65520)
Note Tuning:
1. "Base Note at C3" will be derived using "Current Tuning Base Note" and "Frequency at the Base Note" from the song table. If the values are A3,440Hz, it will be converted to C3,261.6255653Hz
2. Frequency at C4 will be (Base Note at C3) × (Interval Size)
1. "Base Note at C4" will be derived using "Current Tuning Base Note" and "Frequency at the Base Note" from the song table. If the values are A4,440Hz, it will be converted to C4,261.6255653Hz
2. Frequency at C5 will be (Base Note at C4) × (Interval Size)
3. 4096 notes will be equidistance-distributed between (Frequency at C3) and (Frequency at C4), with logarithmic pitch progression; this builds the frequency-offset table
4. Frequency-Offset Table from the previous step will be applied against the "Base Note at C3" to construct the notes within the notation. Value at index zero of the Frequency Table must be 0
5. The progress will continue outside the "root interval" (C3..C4) to build a complete note-to-frequency table

View File

@@ -110,10 +110,10 @@ class AudioJSR223Delegate(private val vm: VM) {
}
}
/** Upload 64 bytes defining instrument `slot` (0-255). */
/** Upload up to 192 bytes defining instrument `slot` (0-255). */
fun uploadInstrument(slot: Int, bytes: IntArray) {
getFirstSnd()?.instruments?.get(slot and 0xFF)?.let { inst ->
for (i in 0 until minOf(64, bytes.size)) inst.setByte(i, bytes[i] and 0xFF)
for (i in 0 until minOf(192, bytes.size)) inst.setByte(i, bytes[i] and 0xFF)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ package net.torvald.tsvm
import com.badlogic.gdx.ApplicationAdapter
import com.badlogic.gdx.Gdx
import com.badlogic.gdx.Input
import com.badlogic.gdx.graphics.*
import com.badlogic.gdx.graphics.g2d.SpriteBatch
import com.badlogic.gdx.graphics.g2d.TextureRegion
@@ -205,6 +206,8 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
private var updateAkku = 0.0
private var updateRate = 1f / 60f
private var crtShaderSignalMode = 0
override fun render() {
gdxClearAndSetBlend(.094f, .094f, .094f, 0f)
setCameraPosition(0f, 0f)
@@ -250,6 +253,19 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
vm.update(delta)
if (vm.resetDown) rebootRequested = true
if ((Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) || Gdx.input.isKeyPressed(Input.Keys.SHIFT_RIGHT)) &&
(Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) || Gdx.input.isKeyPressed(Input.Keys.CONTROL_RIGHT))) {
if (Gdx.input.isKeyPressed(Input.Keys.S)) {
crtShaderSignalMode = -1 // RGB
}
else if (Gdx.input.isKeyPressed(Input.Keys.D)) {
crtShaderSignalMode = 0 // S-video
}
else if (Gdx.input.isKeyPressed(Input.Keys.F)) {
crtShaderSignalMode = 1 // Composite
}
}
}
fun poke(addr: Long, value: Byte) = vm.poke(addr, value)
@@ -292,6 +308,7 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
batch.shader.setUniformf("resolution", viewportWidth.toFloat(), viewportHeight.toFloat())
batch.shader.setUniformf("interlacer", (framecount % 2).toFloat())
batch.shader.setUniformf("time", (framecount % 640).toFloat())
batch.shader.setUniformi("signalMode", crtShaderSignalMode)
batch.setBlendFunctionSeparate(GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA, GL20.GL_SRC_ALPHA, GL20.GL_ONE)
batch.draw(gpuFBO.colorBufferTexture, 0f, 0f)
}