From d706f27e18a6e1b0f844700ab1e553b43d90fe8b Mon Sep 17 00:00:00 2001 From: minjaesong Date: Fri, 8 May 2026 14:27:31 +0900 Subject: [PATCH] Impl Taud L/K xy00; IT Mxx Nxx Pxx --- TAUD_NOTE_EFFECTS.md | 160 ++++++++++++++++-- assets/disk0/tvdos/bin/taut.js | 14 +- it2taud.py | 18 +- mod2taud.py | 13 +- s3m2taud.py | 21 ++- taud_common.py | 3 + terranmon.txt | 3 +- .../torvald/tsvm/peripheral/AudioAdapter.kt | 94 +++++++++- xm2taud.py | 15 +- 9 files changed, 296 insertions(+), 45 deletions(-) diff --git a/TAUD_NOTE_EFFECTS.md b/TAUD_NOTE_EFFECTS.md index ab2c86b..b9d47b5 100644 --- a/TAUD_NOTE_EFFECTS.md +++ b/TAUD_NOTE_EFFECTS.md @@ -69,7 +69,9 @@ Most effects recall their last non-zero argument when re-issued with $0000. Unli - **H and U share one slot** (vibrato speed and depth are jointly recalled; the last-written values persist across both commands). - **R has its own slot** (tremolo). -Every other memory-carrying effect (D, I, J, K, L, O, 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. ## 7. Opcode and argument format @@ -404,21 +406,155 @@ The `tick_within_row mod 3` counter resets every row start (so every row begins ## K $xy00 — Dual: vibrato continuation and volume slide $xy -**Plain.** **Unimplemented**. On ST3, continues a 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. +**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. -**Compatibility.** ST3 `Kxy` maps directly. Implementations must convert K to an explicit pair of commands: `H $0000` (continue with stored speed/depth) combined with volume-column command `1.$xy` (volume slide), and emit both. +**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. -**Implementation.** Execute the per-tick vibrato update as if an H command were active with argument $0000 (recall), then execute a D $0y00 or $x000 slide using the bytes of the K argument: high nibble as up-slide, low nibble as down-slide. If both nibbles are non-zero, down-slide takes precedence (matching ST3). K has no memory of its own; it uses H/U's stored speed and depth. +**Implementation.** On row parse: + +``` +on row parse (K): + raw = (arg >> 8) & 0xFF # the xy nibble pair lives in the high byte + if raw == 0: raw = memory_K + else: memory_K = raw + voice.vibratoActive = true # H/U speed and depth come from memory_HU + hi_nib = (raw >> 4) & 0xF + lo_nib = raw & 0xF + # Slide direction: high nibble = up, low nibble = down. Both non-zero ⇒ down wins (ST3 quirk). + if hi_nib != 0 and lo_nib == 0: + slide_per_tick = +hi_nib + elif lo_nib != 0: + slide_per_tick = -lo_nib + else: + slide_per_tick = 0 + +on every tick (including tick 0): + apply vibrato update with memory_HU.speed / memory_HU.depth (see §H) + +on tick > 0: + channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F) + row_volume = channel_volume +``` + +K has its own memory slot (private). The slide always uses the per-tick form — `K $FF00` does **not** trigger a fine slide; the argument's `$F` nibbles are interpreted as `$F`-magnitude per-tick slides (down wins), matching ST3's K and IT's K semantics. --- ## L $xy00 — Dual: tone portamento continuation and volume slide $xy -**Plain.** **Unimplemented**. On ST3, continues a 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. +**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. -**Compatibility.** ST3 `Lxy` maps directly. Like K, L must be equivalently implemented as `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. -**Implementation.** Execute the per-tick G update (recalling G's stored speed), then the D slide as in K. L has no memory of its own. +**Implementation.** Identical machinery to K with `G` swapped for the LFO update: + +``` +on row parse (L): + raw = (arg >> 8) & 0xFF + if raw == 0: raw = memory_L + else: memory_L = raw + # Tone portamento target is set by the row's note (see §G); G's stored speed (memory_G) drives the slide. + hi_nib = (raw >> 4) & 0xF + lo_nib = raw & 0xF + if hi_nib != 0 and lo_nib == 0: + slide_per_tick = +hi_nib + elif lo_nib != 0: + slide_per_tick = -lo_nib + else: + slide_per_tick = 0 + +on tick > 0: + apply tone-portamento step using memory_G.speed (see §G) + channel_volume = clamp(channel_volume + slide_per_tick, 0, $3F) + row_volume = channel_volume +``` + +L has its own memory slot (private), separate from K's and from D's. + +--- + +## M $xx00 — Set channel volume to $xx + +**Plain.** Sets the channel's persistent base volume to `$xx`, in the same 6-bit `$00..$3F` range as a note's default volume. Unlike a volume-column SET (which only writes the *row* volume on a re-triggering row), M overwrites the channel's stored base volume so the change persists across subsequent rows that don't carry an explicit vol-column SET. + +**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. + +**Implementation.** + +``` +on row parse (M): + new_vol = (arg >> 8) & 0xFF + if new_vol > 0x3F: new_vol = 0x3F + channel_volume = new_vol + row_volume = new_vol +``` + +The change takes effect on tick 0 of the row. There is no slide form; for that, use N. The low byte of M's argument is reserved. + +--- + +## N $xy00 — Channel volume slide + +**Plain.** Slides the channel's persistent base volume by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is identical to D (see §D), but the slide acts on `channel_volume` rather than the per-row note volume — so the change persists into following rows that don't reissue N. Range and clipping match D: `$00..$3F`. + +**Compatibility.** IT `Nxy` maps directly to Taud `N $xy00` (high byte = source argument byte, verbatim). ST3 has no native N. N's encoding sub-forms mirror D exactly: + +- `N $0y00` — coarse slide down by `$y` per non-first tick. +- `N $x000` — coarse slide up by `$x` per non-first tick. +- `N $Fy00` — fine slide down by `$y` on tick 0 only (with the same `$FF` "fine up by $F" quirk as D). +- `N $xF00` — fine slide up by `$x` on tick 0 only. + +**Memory.** N has its own private slot, separate from D's. `N $0000` recalls the last N argument and re-applies it in its original sub-form (coarse vs fine, up vs down). + +**Implementation.** Identical to D, with `channel_volume` substituted for the per-row volume target. After every step the result is clamped to `$00..$3F` and `row_volume` is forced to track `channel_volume` so subsequent ticks' mixing reflects the slid value: + +``` +on row parse (N): + raw = (arg >> 8) & 0xFF + if raw == 0: raw = memory_N + else: memory_N = raw + decode raw exactly as D does (FF / F0 / Fy / xF / 0y / x0 → fine-up-F / coarse / fine forms) + schedule per-tick (or apply once) on channel_volume; row_volume = channel_volume after each step +``` + +--- + +## P $xy00 — Channel panning slide + +**Plain.** Slides the channel's persistent pan by `$xy` per non-first tick (or once on tick 0 for fine forms). Encoding is layered on D's structural skeleton, but the *direction* of each nibble follows the IT panning convention: the low nibble of the high byte slides **right**, the high nibble of the high byte slides **left**. Pan ranges over the full 8-bit space (`$00`..`$FF`, $80 centre); P writes the persistent `channel_pan` so the change persists across rows. + +**Compatibility.** IT `Pxy` maps directly to Taud `P $xy00` (high byte = source argument byte, verbatim). ST3 has no native P. The four sub-forms are: + +- `P $0y00` — slide right by `$y` per non-first tick. +- `P $x000` — slide left by `$x` per non-first tick. +- `P $Fy00` — fine slide right by `$y` on tick 0 only. +- `P $xF00` — fine slide left by `$x` on tick 0 only. + +The `$FF` corner case (`P $FF00`) follows the D / N quirk: it is interpreted as "fine slide left by `$F`" (the high-nibble form wins when both nibbles are `$F`). + +**Memory.** P has its own private slot, separate from D / N. `P $0000` recalls the last P argument and re-applies it in its original sub-form. + +**Implementation.** + +``` +on row parse (P): + raw = (arg >> 8) & 0xFF + if raw == 0: raw = memory_P + else: memory_P = raw + hi_nib = (raw >> 4) & 0xF + lo_nib = raw & 0xF + if raw == 0xFF or (hi_nib == 0xF and lo_nib == 0): apply fine-left-by-F on tick 0 + elif hi_nib == 0xF and lo_nib != 0: apply fine-right-by-lo_nib on tick 0 + elif lo_nib == 0xF and hi_nib != 0: apply fine-left-by-hi_nib on tick 0 + elif hi_nib == 0 and lo_nib != 0: per-tick: channel_pan += lo_nib (right) + elif lo_nib == 0 and hi_nib != 0: per-tick: channel_pan -= hi_nib (left) + +on every per-tick or fine step: + channel_pan = clamp(channel_pan ± step, 0, 0xFF) + row_pan = channel_pan >> 2 # 6-bit pan value used by the mixer +``` + +The mixer reads `channel_pan` (8-bit) directly through the same path as `S $80xx`. P slides interact additively with panbrello (Y) and the panning column's slide selectors, but P has the highest precedence on `channel_pan` because it writes the persistent value rather than a per-row delta. --- @@ -839,9 +975,7 @@ The background pool is reaped when a ghost's `fadeoutVolume` drops to zero or it **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.** 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)` +**Compatibility.** IT `Xxx` maps directly. ST3 `S8x` uses a 4-bit value. 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. **Implementation.** Write `channel_pan = arg & $FF`. The pan value is applied at the mixer: `left_gain = (($FF − pan) × $100) >> 8`, `right_gain = (pan × $100) >> 8`, with both applied before the global volume stage. @@ -1068,8 +1202,8 @@ This table maps each PT effect to its Taud equivalent. Arguments follow PT's two | `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) | +| `5 $xy` | `L $xy00` | Combined portamento + volume slide; argument byte verbatim (PT `500` recall is resolved to the previous 5xy by the converter, then emitted as L $xy00) | +| `6 $xy` | `K $xy00` | Combined vibrato + volume slide; argument byte verbatim (PT `600` recall is resolved to the previous 6xy by the converter, then emitted as K $xy00) | | `7 $xy` | `R $xxyy` | Tremolo; nibble-repeat | | `8 $xx` | `S $80xx` or panning column `0.$xx` | Fine pan | | `9 $xx` | `O $xx00` | Sample offset | @@ -1104,6 +1238,8 @@ These quirks of ST3 are worth preserving or flagging when importing S3M files in **Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter must **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000. +**M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none. + **Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Decode on import; encode on export. Out-of-range BCD bytes (decimal 64 or higher) clamp to row 0. **Tempo range.** ST3 accepts tempos $20..$FF (BPM 32..255); Taud accepts bytes $00..$FF (BPM 24..279). Imported ST3 tempos must be shifted down by $18; Taud tempos below $08 and above $E7 cannot be represented in ST3 and should clamp on export. diff --git a/assets/disk0/tvdos/bin/taut.js b/assets/disk0/tvdos/bin/taut.js index 63c2345..e16e5f0 100644 --- a/assets/disk0/tvdos/bin/taut.js +++ b/assets/disk0/tvdos/bin/taut.js @@ -111,12 +111,12 @@ G:"Portamento ", H:"Vibrato ", I:"Tremor ", J:"Arpeggio ", -K:"UNIMPLEMENTED", // Volume slide+Vibrato. Use H0000 and VolEff instead -L:"UNIMPLEMENTED", // Volume slide+Portamento. Use G0000 and VolEff instead -M:"UNIMPLEMENTED", // IT: Set channel volume. Use VolEff instead -N:"UNIMPLEMENTED", // IT: Channel volume slide. Use VolEff instead +K:"Vibrafade ", +L:"Portafade ", +M:"Channel vol ", +N:"Chan.volslide", O:"Sample offset", -P:"UNIMPLEMENTED", // IT: panning slide. Use PanEff instead +P:"Chan.panslide", Q:"Retrigger ", R:"Tremolo ", S:"Special ", @@ -130,12 +130,12 @@ S6:"Fine delay ", S7:"Note action ", S8:"Channel pan ", // Taud: 8-bit channel panning S9:"UNIMPLEMENTED", // IT: Sound control -SA:"UNIMPLEMENTED", // SC3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit) +SA:"UNIMPLEMENTED", // ST3: Stereo control. IT: Sample offset high twobyte (not applicable because Taud has 64k limit) SB:"Pattern loop ", SC:"Note cut ", SD:"Note delay ", SE:"Pattern delay", -SF:"Funk it ", +SF:"Funk repeat ", T:"Tempo ", U:"Fine vibrato ", V:"Global volume", diff --git a/it2taud.py b/it2taud.py index e9c797f..8023f6a 100644 --- a/it2taud.py +++ b/it2taud.py @@ -46,7 +46,7 @@ from taud_common import ( PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, 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_W, TOP_Y, + TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T, @@ -809,22 +809,28 @@ def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0, return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None) if cmd == EFF_K: - return (TOP_H, 0x0000, d_arg_to_col(arg), None) + # K = vibrato continuation + vol slide; emitted verbatim. IT's D/K/L + # shared cohort is already resolved upstream by resolve_it_recalls. + return (TOP_K, (arg & 0xFF) << 8, None, None) if cmd == EFF_L: - return (TOP_G, 0x0000, d_arg_to_col(arg), None) + # L = tone-porta continuation + vol slide; emitted verbatim. + return (TOP_L, (arg & 0xFF) << 8, None, None) if cmd == EFF_M: - return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) + # M = set channel volume; literal byte (no recall). Clamp IT $40 → $3F. + return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None) if cmd == EFF_N: - return (TOP_NONE, 0, d_arg_to_col(arg), None) + # N = channel volume slide; D-style encoding. + return (TOP_N, (arg & 0xFF) << 8, None, None) if cmd == EFF_O: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == EFF_P: - return (TOP_NONE, 0, None, d_arg_to_col(arg)) + # P = channel panning slide; D-style encoding (low nib = right, high nib = left). + return (TOP_P, (arg & 0xFF) << 8, None, None) if cmd == EFF_Q: return (TOP_Q, (arg & 0xFF) << 8, None, None) diff --git a/mod2taud.py b/mod2taud.py index dcd53ce..dce004e 100644 --- a/mod2taud.py +++ b/mod2taud.py @@ -266,12 +266,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple: return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None) if cmd == 0x5: - # Tone porta + vol slide → Taud L (engine splits internally). - return (TOP_G, 0x0000, d_arg_to_col(arg), None) + # Tone porta + vol slide → Taud L verbatim. PT's 500 recall is already + # collapsed by resolve_pt_recalls; if the source had no prior 5xy the + # resolved arg is 0, which Taud's L $0000 then recalls from L's own + # private memory. Emitting a real L (rather than the previous + # G+vol-col split) preserves the slide on rows that also carry a + # vol-column SET (e.g., a Cxx fold) — see TAUD_NOTE_EFFECTS.md §L. + return (TOP_L, (arg & 0xFF) << 8, None, None) if cmd == 0x6: - # Vibrato + vol slide → Taud K. - return (TOP_H, 0x0000, d_arg_to_col(arg), None) + # Vibrato + vol slide → Taud K verbatim (same rationale as 0x5). + return (TOP_K, (arg & 0xFF) << 8, None, None) if cmd == 0x7: hi = (arg >> 4) & 0xF diff --git a/s3m2taud.py b/s3m2taud.py index 2e778a5..40cea1b 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -37,7 +37,7 @@ from taud_common import ( PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, 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_W, TOP_Y, + TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T, @@ -305,25 +305,28 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0, None, None) if cmd == EFF_K: - # K = vibrato continuation + vol slide; engine treats K as no-op. - # Split into: H $0000 (recall vibrato from HU memory) + vol-col slide. - return (TOP_H, 0x0000, d_arg_to_col(arg), None) + # K = vibrato continuation + vol slide; emitted verbatim. ST3's shared + # memory cohort is already resolved upstream by resolve_st3_recalls. + return (TOP_K, (arg & 0xFF) << 8, None, None) if cmd == EFF_L: - # L = tone-porta continuation + vol slide; split similarly. - return (TOP_G, 0x0000, d_arg_to_col(arg), None) + # L = tone-porta continuation + vol slide; emitted verbatim. + return (TOP_L, (arg & 0xFF) << 8, None, None) if cmd == EFF_M: - return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None) + # M = set channel volume; literal byte (no recall). Clamp ST3/IT $40 → $3F. + return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None) if cmd == EFF_N: - return (TOP_NONE, 0, d_arg_to_col(arg), None) + # N = channel volume slide; D-style encoding. + return (TOP_N, (arg & 0xFF) << 8, None, None) if cmd == EFF_O: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == EFF_P: - return (TOP_NONE, 0, None, d_arg_to_col(arg)) + # P = channel panning slide; D-style encoding (low nib = right, high nib = left). + return (TOP_P, (arg & 0xFF) << 8, None, None) if cmd == EFF_Q: return (TOP_Q, (arg & 0xFF) << 8, None, None) diff --git a/taud_common.py b/taud_common.py index 364a811..01bfae0 100644 --- a/taud_common.py +++ b/taud_common.py @@ -76,7 +76,10 @@ TOP_I = 0x12 TOP_J = 0x13 TOP_K = 0x14 TOP_L = 0x15 +TOP_M = 0x16 +TOP_N = 0x17 TOP_O = 0x18 +TOP_P = 0x19 TOP_Q = 0x1A TOP_R = 0x1B TOP_S = 0x1C diff --git a/terranmon.txt b/terranmon.txt index 6300747..0d67060 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2349,7 +2349,8 @@ TODO: [ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored [x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure. [ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy) - [ ] FT2/MOD double effects (5xx, 6xx) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py) + [x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility. + Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects Play Data: play data are series of tracker-like instructions, visualised as: diff --git a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt index 0d355ca..546c0ce 100644 --- a/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt +++ b/tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt @@ -1171,7 +1171,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { const val OP_J = 0x13 const val OP_K = 0x14 const val OP_L = 0x15 + const val OP_M = 0x16 + const val OP_N = 0x17 const val OP_O = 0x18 + const val OP_P = 0x19 const val OP_Q = 0x1A const val OP_R = 0x1B const val OP_S = 0x1C @@ -2006,7 +2009,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.rowEffectArg = row.effectArg // ── Note ── - val toneG = (row.effect == EffectOp.OP_G) + // OP_L (combined porta + vol slide) also takes a tone-porta target without retriggering, + // mirroring G's behaviour — the L command continues the porta started by an earlier G. + val toneG = (row.effect == EffectOp.OP_G || row.effect == EffectOp.OP_L) when (row.note) { // No note but an instrument byte is present: latch the instrument so // the *next* note-only trigger picks up the right sample. Trackers @@ -2211,7 +2216,83 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { voice.arpOff1 = (arg ushr 8) and 0xFF voice.arpOff2 = arg and 0xFF } - EffectOp.OP_K, EffectOp.OP_L -> {} // engine no-op by design (converter splits them) + EffectOp.OP_K -> { + // K $xy00 — vibrato continuation + per-tick volume slide. xy lives in the high + // byte; $00 recalls K's private memory (TAUD_NOTE_EFFECTS.md §K). Vibrato uses + // the H/U memory cohort (no retrigger from K alone). Slide direction: high nibble + // = up, low nibble = down; both non-zero ⇒ down wins (ST3 quirk). + val raw = (rawArg ushr 8) and 0xFF + val arg = if (raw != 0) raw.also { voice.mem.k = it } else voice.mem.k + val hi = (arg ushr 4) and 0xF + val lo = arg and 0xF + voice.vibratoActive = true + voice.vibratoFineShift = 6 + when { + lo != 0 -> { voice.volColSlideDown = lo } // down wins + hi != 0 -> { voice.volColSlideUp = hi } + } + } + EffectOp.OP_L -> { + // L $xy00 — tone-portamento continuation + per-tick volume slide. xy lives in the + // high byte; $00 recalls L's private memory (TAUD_NOTE_EFFECTS.md §L). The porta + // target was set in the row's note-handling block (toneG includes OP_L); the + // porta speed is recalled from G's memory so a prior G's rate carries forward. + val raw = (rawArg ushr 8) and 0xFF + val arg = if (raw != 0) raw.also { voice.mem.l = it } else voice.mem.l + val hi = (arg ushr 4) and 0xF + val lo = arg and 0xF + voice.tonePortaSpeed = voice.mem.g + when { + lo != 0 -> { voice.volColSlideDown = lo } + hi != 0 -> { voice.volColSlideUp = hi } + } + } + EffectOp.OP_M -> { + // M $xx00 — set channel volume to the high byte (literal, no recall). IT $40 is + // clamped to Taud's $3F cap. See TAUD_NOTE_EFFECTS.md §M. + val newVol = ((rawArg ushr 8) and 0xFF).coerceAtMost(0x3F) + voice.channelVolume = newVol + voice.rowVolume = newVol + } + EffectOp.OP_N -> { + // N $xy00 — channel-volume slide. Same nibble decoding as D but writes the + // persistent channelVolume so the change carries past this row. + val arg = resolveArg(rawArg, voice.mem.n).also { if (rawArg != 0) voice.mem.n = it } + val hi = (arg ushr 8) and 0xFF + val lo = hi and 0x0F + val hin = (hi ushr 4) and 0x0F + when { + hi == 0xFF || hi == 0xF0 -> { voice.channelVolume = (voice.channelVolume + 0xF).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume } + hin == 0xF && lo != 0 -> { voice.channelVolume = (voice.channelVolume - lo).coerceAtLeast(0); voice.rowVolume = voice.channelVolume } + lo == 0xF && hin != 0 -> { voice.channelVolume = (voice.channelVolume + hin).coerceAtMost(0x3F); voice.rowVolume = voice.channelVolume } + hin == 0 && lo != 0 -> { voice.volColSlideDown = lo } // coarse down per non-first tick + lo == 0 && hin != 0 -> { voice.volColSlideUp = hin } // coarse up per non-first tick + } + } + EffectOp.OP_P -> { + // P $xy00 — channel-panning slide. D-style nibble layout, but the IT panning + // direction convention applies: low nibble = right, high nibble = left. + val arg = resolveArg(rawArg, voice.mem.p).also { if (rawArg != 0) voice.mem.p = it } + val hi = (arg ushr 8) and 0xFF + val lo = hi and 0x0F // low nibble of high byte → right + val hin = (hi ushr 4) and 0x0F // high nibble of high byte → left + when { + hi == 0xFF || hi == 0xF0 -> { // FF / F0 quirk: fine left by F (high-nib form wins) + voice.channelPan = (voice.channelPan - 0xF).coerceAtLeast(0) + voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) + } + hin == 0xF && lo != 0 -> { // fine right by lo on tick 0 + voice.channelPan = (voice.channelPan + lo).coerceAtMost(0xFF) + voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) + } + lo == 0xF && hin != 0 -> { // fine left by hin on tick 0 + voice.channelPan = (voice.channelPan - hin).coerceAtLeast(0) + voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) + } + hin == 0 && lo != 0 -> { voice.panColSlideRight = lo } // coarse right per non-first tick + lo == 0 && hin != 0 -> { voice.panColSlideLeft = hin } // coarse left per non-first tick + } + } EffectOp.OP_O -> { val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it } val inst = instruments[voice.instrumentId] @@ -2947,6 +3028,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) { var q: Int = 0 var tslide: Int = 0 var w: Int = 0 + // K, L, N, P: each its own private slot. K and L store the high-byte + // (xy nibble pair) of the most recent non-zero argument; N and P + // store the same high-byte and let the per-tick form recover via + // identical decoding to D. (M has no recall — literal-zero — so no + // slot is needed.) + var k: Int = 0 + var l: Int = 0 + var n: Int = 0 + var p: Int = 0 } class Voice { diff --git a/xm2taud.py b/xm2taud.py index 5d31399..a808d2f 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -490,12 +490,19 @@ def encode_effect_xm(cmd: int, arg: int, ch: int = 0, row: int = 0, return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None) if cmd == 0x05: - # Tone porta + vol slide → Taud L (G + d_arg vol slide override). - return (TOP_G, 0x0000, d_arg_to_col(arg), None) + # Tone porta + vol slide → Taud L verbatim. The XM source byte goes + # straight into L's high byte; the engine handles the combined + # porta-continuation + vol-slide semantics natively (see + # TAUD_NOTE_EFFECTS.md §L). XM's 500 (arg = 0) recall is honoured by + # Taud's L $0000 recall against L's own private memory, so a 500 row + # plays the previously emitted slide rate. This avoids the volume- + # column collision that the H+vol-col split form caused on rows + # already carrying a vol-column SET. + return (TOP_L, (arg & 0xFF) << 8, None, None) if cmd == 0x06: - # Vibrato + vol slide → Taud K (H + d_arg vol slide override). - return (TOP_H, 0x0000, d_arg_to_col(arg), None) + # Vibrato + vol slide → Taud K verbatim (same rationale as 0x05). + return (TOP_K, (arg & 0xFF) << 8, None, None) if cmd == 0x07: hi = (arg >> 4) & 0xF