Impl Taud L/K xy00; IT Mxx Nxx Pxx

This commit is contained in:
minjaesong
2026-05-08 14:27:31 +09:00
parent e49140902b
commit d706f27e18
9 changed files with 296 additions and 45 deletions

View File

@@ -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). - **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). - **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 ## 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 ## 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 ## 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. **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. **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.
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)`
**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. **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 | | `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 | | `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. | | `4 $xy` | `H $xxyy` | Vibrato; nibble-repeat each byte. |
| `5 $xy` | `L $xy00` | Combined portamento + 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 (see compatibility note) | | `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 | | `7 $xy` | `R $xxyy` | Tremolo; nibble-repeat |
| `8 $xx` | `S $80xx` or panning column `0.$xx` | Fine pan | | `8 $xx` | `S $80xx` or panning column `0.$xx` | Fine pan |
| `9 $xx` | `O $xx00` | Sample offset | | `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. **Shared memory across effects.** In ST3, a single memory slot backs D, E, F, I, J, K, L, Q, R, and S. A `$00` argument on any of these recalls whichever effect last wrote a non-zero argument. Taud narrows this to four cohorts (EF / G / HU / R) plus private slots. The converter must **eagerly resolve ST3 recalls** — walking the pattern in playback order, tracking the shared memory value, and emitting explicit Taud arguments wherever an ST3 recall crosses a cohort boundary. Otherwise a Taud player will either recall the wrong value or recall $0000.
**M / N / P (channel volume and panning).** S3M files produced by IT-aware tools embed M (set channel volume), N (channel volume slide), and P (channel panning slide) using the IT semantics described in §M / §N / §P. These are emitted verbatim into Taud (with M's argument byte clamped to $3F). N and P each have private memory; M is literal-zero. ST3 itself never wrote M / N / P, so legacy S3M files contain none.
**Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. Decode on import; encode on export. Out-of-range BCD bytes (decimal 64 or higher) clamp to row 0. **Cxx BCD encoding.** ST3 stores pattern-break row numbers as BCD on disk (`$10` means decimal 10). Taud uses binary. 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. **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.

View File

@@ -111,12 +111,12 @@ G:"Portamento ",
H:"Vibrato ", H:"Vibrato ",
I:"Tremor ", I:"Tremor ",
J:"Arpeggio ", J:"Arpeggio ",
K:"UNIMPLEMENTED", // Volume slide+Vibrato. Use H0000 and VolEff instead K:"Vibrafade ",
L:"UNIMPLEMENTED", // Volume slide+Portamento. Use G0000 and VolEff instead L:"Portafade ",
M:"UNIMPLEMENTED", // IT: Set channel volume. Use VolEff instead M:"Channel vol ",
N:"UNIMPLEMENTED", // IT: Channel volume slide. Use VolEff instead N:"Chan.volslide",
O:"Sample offset", O:"Sample offset",
P:"UNIMPLEMENTED", // IT: panning slide. Use PanEff instead P:"Chan.panslide",
Q:"Retrigger ", Q:"Retrigger ",
R:"Tremolo ", R:"Tremolo ",
S:"Special ", S:"Special ",
@@ -130,12 +130,12 @@ S6:"Fine delay ",
S7:"Note action ", S7:"Note action ",
S8:"Channel pan ", // Taud: 8-bit channel panning S8:"Channel pan ", // Taud: 8-bit channel panning
S9:"UNIMPLEMENTED", // IT: Sound control 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 ", SB:"Pattern loop ",
SC:"Note cut ", SC:"Note cut ",
SD:"Note delay ", SD:"Note delay ",
SE:"Pattern delay", SE:"Pattern delay",
SF:"Funk it ", SF:"Funk repeat ",
T:"Tempo ", T:"Tempo ",
U:"Fine vibrato ", U:"Fine vibrato ",
V:"Global volume", V:"Global volume",

View File

@@ -46,7 +46,7 @@ from taud_common import (
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4, 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_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, 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_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, 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) return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None)
if cmd == EFF_K: 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: 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: 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: 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: if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None) return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P: 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: if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None) return (TOP_Q, (arg & 0xFF) << 8, None, None)

View File

@@ -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) return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == 0x5: if cmd == 0x5:
# Tone porta + vol slide → Taud L (engine splits internally). # Tone porta + vol slide → Taud L verbatim. PT's 500 recall is already
return (TOP_G, 0x0000, d_arg_to_col(arg), None) # 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: if cmd == 0x6:
# Vibrato + vol slide → Taud K. # Vibrato + vol slide → Taud K verbatim (same rationale as 0x5).
return (TOP_H, 0x0000, d_arg_to_col(arg), None) return (TOP_K, (arg & 0xFF) << 8, None, None)
if cmd == 0x7: if cmd == 0x7:
hi = (arg >> 4) & 0xF hi = (arg >> 4) & 0xF

View File

@@ -37,7 +37,7 @@ from taud_common import (
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4, 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_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, 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_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, 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) None, None)
if cmd == EFF_K: if cmd == EFF_K:
# K = vibrato continuation + vol slide; engine treats K as no-op. # K = vibrato continuation + vol slide; emitted verbatim. ST3's shared
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide. # memory cohort is already resolved upstream by resolve_st3_recalls.
return (TOP_H, 0x0000, d_arg_to_col(arg), None) return (TOP_K, (arg & 0xFF) << 8, None, None)
if cmd == EFF_L: if cmd == EFF_L:
# L = tone-porta continuation + vol slide; split similarly. # L = tone-porta continuation + vol slide; emitted verbatim.
return (TOP_G, 0x0000, d_arg_to_col(arg), None) return (TOP_L, (arg & 0xFF) << 8, None, None)
if cmd == EFF_M: 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: 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: if cmd == EFF_O:
return (TOP_O, (arg & 0xFF) << 8, None, None) return (TOP_O, (arg & 0xFF) << 8, None, None)
if cmd == EFF_P: 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: if cmd == EFF_Q:
return (TOP_Q, (arg & 0xFF) << 8, None, None) return (TOP_Q, (arg & 0xFF) << 8, None, None)

View File

@@ -76,7 +76,10 @@ TOP_I = 0x12
TOP_J = 0x13 TOP_J = 0x13
TOP_K = 0x14 TOP_K = 0x14
TOP_L = 0x15 TOP_L = 0x15
TOP_M = 0x16
TOP_N = 0x17
TOP_O = 0x18 TOP_O = 0x18
TOP_P = 0x19
TOP_Q = 0x1A TOP_Q = 0x1A
TOP_R = 0x1B TOP_R = 0x1B
TOP_S = 0x1C TOP_S = 0x1C

View File

@@ -2349,7 +2349,8 @@ TODO:
[ ] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored [ ] 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. [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) [ ] 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: Play Data: play data are series of tracker-like instructions, visualised as:

View File

@@ -1171,7 +1171,10 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val OP_J = 0x13 const val OP_J = 0x13
const val OP_K = 0x14 const val OP_K = 0x14
const val OP_L = 0x15 const val OP_L = 0x15
const val OP_M = 0x16
const val OP_N = 0x17
const val OP_O = 0x18 const val OP_O = 0x18
const val OP_P = 0x19
const val OP_Q = 0x1A const val OP_Q = 0x1A
const val OP_R = 0x1B const val OP_R = 0x1B
const val OP_S = 0x1C const val OP_S = 0x1C
@@ -2006,7 +2009,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.rowEffectArg = row.effectArg voice.rowEffectArg = row.effectArg
// ── Note ── // ── 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) { when (row.note) {
// No note but an instrument byte is present: latch the instrument so // No note but an instrument byte is present: latch the instrument so
// the *next* note-only trigger picks up the right sample. Trackers // 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.arpOff1 = (arg ushr 8) and 0xFF
voice.arpOff2 = arg 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 -> { EffectOp.OP_O -> {
val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it } val arg = resolveArg(rawArg, voice.mem.o).also { if (rawArg != 0) voice.mem.o = it }
val inst = instruments[voice.instrumentId] val inst = instruments[voice.instrumentId]
@@ -2947,6 +3028,15 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var q: Int = 0 var q: Int = 0
var tslide: Int = 0 var tslide: Int = 0
var w: 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 { class Voice {

View File

@@ -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) return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
if cmd == 0x05: if cmd == 0x05:
# Tone porta + vol slide → Taud L (G + d_arg vol slide override). # Tone porta + vol slide → Taud L verbatim. The XM source byte goes
return (TOP_G, 0x0000, d_arg_to_col(arg), None) # 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: if cmd == 0x06:
# Vibrato + vol slide → Taud K (H + d_arg vol slide override). # Vibrato + vol slide → Taud K verbatim (same rationale as 0x05).
return (TOP_H, 0x0000, d_arg_to_col(arg), None) return (TOP_K, (arg & 0xFF) << 8, None, None)
if cmd == 0x07: if cmd == 0x07:
hi = (arg >> 4) & 0xF hi = (arg >> 4) & 0xF