xm2taud (wip), separate sustain and loop def

This commit is contained in:
minjaesong
2026-05-06 05:31:55 +09:00
parent 1e482e32a8
commit 60b07a325a
12 changed files with 1954 additions and 378 deletions

View File

@@ -4,3 +4,5 @@ for f in *.mod; python3 mod2taud.py $f assets/disk0/(basename $f .mod).taud; end
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/(basename $f .s3m).taud; end for f in *.s3m; python3 s3m2taud.py $f assets/disk0/(basename $f .s3m).taud; end
for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end
for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end
for f in *.mon; python3 mon2taud.py $f assets/disk0/(basename $f .mon).taud; end
for f in *.MON; python3 mon2taud.py $f assets/disk0/(basename $f .MON).taud; end

View File

@@ -956,13 +956,14 @@ Effects in this section modifies the behaviour of the mixer. Primary intention o
**Plain.** Sets mixer-wide behaviour flags. Available flags are: **Plain.** Sets mixer-wide behaviour flags. Available flags are:
0b 0000 0mfp 0b 0000 Fmfp
- p unset: Linear panning mode (tracker-accurate). Centre panning gets 3 dB boost. Default setting. - 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. - 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. **Coarse and fine E/F arguments are stored as 4096-TET pitch units** and subtracted/added directly from the stored pitch. - Ff = 0: 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. - Ff = 1: 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.
- Ff = 2: Linear frequency mode. Pitch shift will behave against frequency number.
- m unset: IT fadeout-zero policy. An instrument with stored volume fadeout = 0 does **not** fade out on key-off; the voice plays through until the volume envelope ends it (or never, if there is no envelope). - m unset: IT fadeout-zero policy. An instrument with stored volume fadeout = 0 does **not** fade out on key-off; the voice plays through until the volume envelope ends it (or never, if there is no envelope).
- m set: FT2 fadeout-zero policy. An instrument with stored volume fadeout = 0 is **cut** on the first tick after key-off (or NNA Note-Fade). Nonzero fadeouts behave identically in both modes — the per-tick decrement is always `fadeout / 65536` in unity-volume units. - m set: FT2 fadeout-zero policy. An instrument with stored volume fadeout = 0 is **cut** on the first tick after key-off (or NNA Note-Fade). Nonzero fadeouts behave identically in both modes — the per-tick decrement is always `fadeout / 65536` in unity-volume units.

View File

@@ -275,7 +275,7 @@ function noteToStr(note) {
if (note === 0x0000) return sym.keyoff if (note === 0x0000) return sym.keyoff
if (pitchTablePresets[PITCH_PRESET_IDX].table.length === 0) return note.hex04() if (pitchTablePresets[PITCH_PRESET_IDX].table.length === 0) return note.hex04()
const [s, o] = pitchSymLut[note & 0xFFF] const [s, o] = pitchSymLut[note & 0xFFF]
return s + ((note >> 12) - 1 + o) return s + ((note >> 12) - 1 + o).toString(16) // octave 10 -> 'a'
} }
/** /**
@@ -1109,7 +1109,7 @@ function drawVoiceDetail(isVerticalLayout = false, ptn = null, activeRow = -1, c
let cumLines = [] let cumLines = []
if (cumState !== null && lowerH > 0) { if (cumState !== null && lowerH > 0) {
const _apo = Math.abs(cumState.pitchOff) const _apo = Math.abs(cumState.pitchOff)
const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : '=' const _psgn = cumState.pitchOff > 0 ? '+' : cumState.pitchOff < 0 ? '-' : ' '
const _absN = (cumState.lastNote !== 0xFFFF && cumState.pitchOff !== 0) const _absN = (cumState.lastNote !== 0xFFFF && cumState.pitchOff !== 0)
? noteToStr(Math.max(0, Math.min(0xFFFE, cumState.lastNote + cumState.pitchOff))) + ' ' ? noteToStr(Math.max(0, Math.min(0xFFFE, cumState.lastNote + cumState.pitchOff))) + ' '
: '' : ''
@@ -1319,7 +1319,7 @@ if (fullPathObj === undefined) {
const logofile = files.open("A:/tvdos/bin/tauthdr.r8") const logofile = files.open("A:/tvdos/bin/tauthdr.r8")
const logoBytes = logofile.bread(); logofile.close() const logoBytes = logofile.bread(); logofile.close()
const logoTexture = new gl.Texture(90, 14, logoBytes) const logoTexture = new gl.Texture(92, 14, logoBytes)
const buttonfile = files.open("A:/tvdos/bin/tautbtn.r8") const buttonfile = files.open("A:/tvdos/bin/tautbtn.r8")
const buttonBytes = buttonfile.bread(); buttonfile.close() const buttonBytes = buttonfile.bread(); buttonfile.close()
const buttonTexture = new gl.Texture(2, 28, buttonBytes) const buttonTexture = new gl.Texture(2, 28, buttonBytes)
@@ -1689,24 +1689,35 @@ function simulateRowState(ptnDat, uptoRow) {
// Note column // Note column
const isGRow = (effop === OP_G) const isGRow = (effop === OP_G)
const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD) const isNoteDelay = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0xD)
// Track whether this row reloads the channel's default volume. Engine:
// triggerNote() resets channelVolume to 0x3F on fresh triggers, and an
// instrument byte on a tone-porta row also reloads default vol (matches
// schism csf_instrument_change inst_column branch).
let reloadDefaultVol = false
if (note !== 0xFFFF && note !== 0xFFFE) { if (note !== 0xFFFF && note !== 0xFFFE) {
if (note === 0x0000) { if (note === 0x0000) {
// key-off; sample stays referenced // key-off; sample stays referenced
} else if (isGRow) { } else if (isGRow) {
portaTarget = note portaTarget = note
if (inst !== 0) reloadDefaultVol = true
} else if (isNoteDelay) { } else if (isNoteDelay) {
// Delayed trigger: latched but doesn't fire on this row's first tick. // Delayed trigger: latched but doesn't fire on this row's first tick.
// For "state at end of row" treat as if it triggered. // For "state at end of row" treat as if it triggered.
lastNote = note lastNote = note
pitchOff = 0 pitchOff = 0
portaTarget = -1 portaTarget = -1
reloadDefaultVol = true
} else { } else {
lastNote = note lastNote = note
pitchOff = 0 pitchOff = 0
portaTarget = -1 portaTarget = -1
reloadDefaultVol = true
} }
} }
if (inst !== 0) lastInst = inst if (inst !== 0) lastInst = inst
// Default vol reset must happen before the volume column so a SET selector
// can still override on the same row (engine order: triggerNote → applyVolColumn).
if (reloadDefaultVol) volAbs = 0x3F
// Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET). // Pre-scan effect column for S$80xx (8-bit pan SET wins over volcol/pancol SET).
const rowHasS80 = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0x8) const rowHasS80 = (effop === OP_S) && (((effarg >>> 12) & 0xF) === 0x8)
@@ -2089,16 +2100,20 @@ function drawProjectContents(wo) {
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255) for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
let mixerflag = initialTrackerMixerflags let mixerflag = initialTrackerMixerflags
let flagstrbuf = '' let flagStrSelected = []
let flagstr = [ let flagstr = [
['Linear pan','Equal-energy pan'], ['Linear pan','EquNrg pan'],
['Linear tone','Amiga tone'], ['Linear pitch','Amiga pitch', 'Linear freq', ''], // TODO MONOTONE uses linear-freq pitch
['IT fade','FT2 fade'],
] ]
for (let i = 0; i < flagstr.length; i++) { for (let i = 0; i < flagstr.length; i++) {
let s = flagstr[i][(mixerflag >>> i) & 1 != 0] if (i != 1 && 1 != 3) {
if (i > 0) flagstrbuf += ', '; let s = flagstr[i][(mixerflag >>> i) & 1 != 0]
flagstrbuf += s flagStrSelected.push(s)
}
} }
let toneMode = (((mixerflag >>> 1) & 1)) | (((mixerflag >>> 3) & 1) << 1)
flagStrSelected.splice(1, 0, flagstr[1][toneMode])
let projMeta = { let projMeta = {
@@ -2106,7 +2121,7 @@ function drawProjectContents(wo) {
Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`, Patterns: `${song.numPats}/4095 ($${song.numPats.hex03()})`,
Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`, Cues: `${song.lastActiveCue}/1024 ($${song.lastActiveCue.hex03()})`,
Notation: pitchTablePresets[PITCH_PRESET_IDX].name, Notation: pitchTablePresets[PITCH_PRESET_IDX].name,
Flags: `${flagstrbuf} ($${mixerflag.hex02()})`, Flags: `${flagStrSelected.join(', ')} ($${mixerflag.hex02()})`,
GlobalVol: initialGlobalVolume, GlobalVol: initialGlobalVolume,
MixingVol: initialMixingVolume MixingVol: initialMixingVolume
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

View File

@@ -54,6 +54,7 @@ from taud_common import (
J_SEMI_TABLE, J_SEMI_TABLE,
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
normalise_sample, encode_song_entry, normalise_sample, encode_song_entry,
CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len,
) )
@@ -467,8 +468,9 @@ def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list:
class ITInstrument: class ITInstrument:
__slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume',
'vol_envelope', 'pan_envelope', 'vol_env_sustain', 'pan_env_sustain', 'vol_envelope', 'pan_envelope', 'pf_envelope', 'pf_is_filter',
'pf_envelope', 'pf_env_sustain', 'pf_is_filter', 'vol_env_loop', 'pan_env_loop', 'pf_env_loop',
'vol_env_sus', 'pan_env_sus', 'pf_env_sus',
'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna', 'ifc', 'ifr', 'fadeout', 'pps', 'ppc', 'rv', 'rp', 'nna',
'dct', 'dca') 'dct', 'dca')
# vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None # vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None
@@ -538,14 +540,14 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
# Parse IT envelopes (new-format only, ≥cmwt 0x200) # Parse IT envelopes (new-format only, ≥cmwt 0x200)
# Vol envelope at ptr+0x130; pan envelope at ptr+0x182; pf envelope at ptr+0x1D4 # Vol envelope at ptr+0x130; pan envelope at ptr+0x182; pf envelope at ptr+0x1D4
ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0) ticks_per_sec = max(h.initial_tempo * 2.0 / 5.0, 1.0)
inst.vol_envelope, inst.vol_env_sustain = _parse_it_envelope( inst.vol_envelope, inst.vol_env_loop, inst.vol_env_sus = _parse_it_envelope(
data, ptr + 0x130, kind='vol', ticks_per_sec=ticks_per_sec) data, ptr + 0x130, kind='vol', ticks_per_sec=ticks_per_sec)
inst.pan_envelope, inst.pan_env_sustain = _parse_it_envelope( inst.pan_envelope, inst.pan_env_loop, inst.pan_env_sus = _parse_it_envelope(
data, ptr + 0x182, kind='pan', ticks_per_sec=ticks_per_sec) data, ptr + 0x182, kind='pan', ticks_per_sec=ticks_per_sec)
# pf envelope: byte 0 bit 7 distinguishes filter (1) from pitch (0). # pf envelope: byte 0 bit 7 distinguishes filter (1) from pitch (0).
pf_flag_byte = data[ptr + 0x1D4] if ptr + 0x1D4 < len(data) else 0 pf_flag_byte = data[ptr + 0x1D4] if ptr + 0x1D4 < len(data) else 0
inst.pf_is_filter = bool(pf_flag_byte & 0x80) inst.pf_is_filter = bool(pf_flag_byte & 0x80)
inst.pf_envelope, inst.pf_env_sustain = _parse_it_envelope( inst.pf_envelope, inst.pf_env_loop, inst.pf_env_sus = _parse_it_envelope(
data, ptr + 0x1D4, kind=('filter' if inst.pf_is_filter else 'pitch'), data, ptr + 0x1D4, kind=('filter' if inst.pf_is_filter else 'pitch'),
ticks_per_sec=ticks_per_sec) ticks_per_sec=ticks_per_sec)
insts.append(inst) insts.append(inst)
@@ -555,31 +557,32 @@ def parse_instruments(data: bytes, h: ITHeader) -> list:
def _parse_it_envelope(data: bytes, env_ptr: int, kind: str, def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
ticks_per_sec: float) -> tuple: ticks_per_sec: float) -> tuple:
"""Parse one IT envelope block (vol / pan / pitch / filter) into up to 25 """Parse one IT envelope block (vol / pan / pitch / filter) into up to 25
Taud (value, minifloat_idx) points + a 16-bit sustain/flags word. Taud (value, minifloat_idx) points + LOOP word + SUSTAIN word.
Returns (points_list, sus_word). points_list has 25 entries (padded Returns (points_list, loop_word, sustain_word).
with hold-zeros) or None if the envelope is disabled. points_list has 25 entries (padded with hold) or None if the envelope is
disabled. loop_word and sustain_word are zero when the corresponding
region is not enabled.
kind: kind:
'vol' — IT 0..64 → Taud 0..63 (byte 1 = volume) 'vol' — IT 0..64 → Taud 0..63
'pan' — IT -32..+32 → Taud 0..255 (0x80 = centre) 'pan' — IT -32..+32 → Taud 0..255 (0x80 = centre)
'pitch' — IT -32..+32 → Taud 0..255 (0x80 = unity, 1 unit ≈ 1 semitone) 'pitch' — IT -32..+32 → Taud 0..255 (0x80 = unity)
'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff) 'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff)
sus_word layout (16 bits, 0b 0ut sssss pcb eeeee): Word layout (terranmon.txt:2049+ / 2114+):
bit 14 = u (enable sustain/loop) LOOP word: 0b 0000_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol)
bit 13 = t (sustain — breaks on key-off when set) SUSTAIN word: 0b 0000_0sss_ss00b_eeeee
bits 12..8 = sustain/loop start (5-bit index 0..24) bits 12..8 = start index, bits 4..0 = end index
bit 7 = p (vol: fadeout-zero; pan: use default pan; pf: filter mode) bit 7 = p (pan: use default pan) / m (pf: pitch=0/filter=1) / 0 (vol)
bit 6 = c (envelope carry) bit 6 = c (envelope carry — placed in the LOOP word)
bit 5 = b (use envelope at all) bit 5 = b (enable that region)
bits 4..0 = sustain/loop end (5-bit index 0..24)
""" """
if env_ptr + 82 > len(data): if env_ptr + 82 > len(data):
return None, 0 return None, 0, 0
flags = data[env_ptr] flags = data[env_ptr]
if not (flags & 0x01): if not (flags & 0x01):
return None, 0 # envelope not enabled return None, 0, 0 # envelope not enabled
num_nodes = max(1, min(data[env_ptr + 1], 25)) num_nodes = max(1, min(data[env_ptr + 1], 25))
it_lpb = data[env_ptr + 2] it_lpb = data[env_ptr + 2]
@@ -591,20 +594,6 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
carry = bool(flags & 0x08) carry = bool(flags & 0x08)
is_filter = bool(flags & 0x80) and kind in ('pitch', 'filter') is_filter = bool(flags & 0x80) and kind in ('pitch', 'filter')
# Priority: sus loop > env loop (Taud carries one loop region).
if has_sus_loop:
use_lb, use_le = it_slb, it_sle
has_loop = True
is_sustain = True
elif has_env_loop:
use_lb, use_le = it_lpb, it_lpe
has_loop = True
is_sustain = False
else:
use_lb = use_le = -1
has_loop = False
is_sustain = False
# Read IT nodes: (int8 value, uint16 tick_pos LE) # Read IT nodes: (int8 value, uint16 tick_pos LE)
nodes = [] nodes = []
for n in range(num_nodes): for n in range(num_nodes):
@@ -615,14 +604,13 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
tick = struct.unpack_from('<H', data, nptr + 1)[0] tick = struct.unpack_from('<H', data, nptr + 1)[0]
nodes.append((val, tick)) nodes.append((val, tick))
if not nodes: if not nodes:
return None, 0 return None, 0, 0
def _to_taud_val(it_val: int) -> int: def _to_taud_val(it_val: int) -> int:
if kind == 'vol': if kind == 'vol':
return min(63, max(0, round(it_val * 63 / 64))) return min(63, max(0, round(it_val * 63 / 64)))
if kind == 'pan': if kind == 'pan':
return min(255, max(0, round((it_val + 32) * 255 / 64))) return min(255, max(0, round((it_val + 32) * 255 / 64)))
# pitch / filter: -32..+32 → 0..255 (0x80 = unity)
return min(255, max(0, round((it_val + 32) * 255 / 64))) return min(255, max(0, round((it_val + 32) * 255 / 64)))
pad_value = (63 if kind == 'vol' else 0x80) pad_value = (63 if kind == 'vol' else 0x80)
@@ -639,27 +627,34 @@ def _parse_it_envelope(data: bytes, env_ptr: int, kind: str,
delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec) delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec)
mf_idx = _nearest_minifloat(delta_sec) mf_idx = _nearest_minifloat(delta_sec)
else: else:
mf_idx = 0 # last real node: hold mf_idx = 0
else: else:
# Pad: hold at last real node's value.
taud_val = points[-1][0] if points else pad_value taud_val = points[-1][0] if points else pad_value
mf_idx = 0 mf_idx = 0
points.append((taud_val, mf_idx)) points.append((taud_val, mf_idx))
# Build 16-bit sus word. # Build LOOP word (offsets 15/17/19) and SUSTAIN word (offsets 189/191/193).
sus_word = 0x0020 # b = 1 (use envelope) — set whenever the envelope is enabled # IT distinguishes envelope loop and sustain loop natively; map both
# directly. Bits: 5=b enable, 6=c carry, 7=p (pan default-pan flag) /
# m (pf filter mode); 12..8=start, 4..0=end. SUSTAIN word never carries
# c/p/m — those live in the LOOP word.
loop_word = 0
if has_env_loop and 0 <= it_lpb < 25 and 0 <= it_lpe < 25:
loop_word |= 0x0020 # b: enable LOOP
loop_word |= (it_lpb & 0x1F) << 8
loop_word |= (it_lpe & 0x1F)
if carry: if carry:
sus_word |= 0x0040 loop_word |= 0x0040 # c carry — kept in LOOP word
if is_filter: if is_filter:
sus_word |= 0x0080 loop_word |= 0x0080 # m filter-mode (pf only)
if has_loop and 0 <= use_lb < 25 and 0 <= use_le < 25:
sus_word |= 0x4000 # u
if is_sustain:
sus_word |= 0x2000 # t
sus_word |= (use_lb & 0x1F) << 8 # sssss
sus_word |= (use_le & 0x1F) # eeeee
return points, sus_word sus_word = 0
if has_sus_loop and 0 <= it_slb < 25 and 0 <= it_sle < 25:
sus_word |= 0x0020 # b: enable SUSTAIN
sus_word |= (it_slb & 0x1F) << 8
sus_word |= (it_sle & 0x1F)
return points, loop_word, sus_word
# ── IT pattern parser ───────────────────────────────────────────────────────── # ── IT pattern parser ─────────────────────────────────────────────────────────
@@ -1008,12 +1003,21 @@ def resolve_it_recalls(patterns_rows: list, order_list: list,
def split_patterns(patterns_rows: list): def split_patterns(patterns_rows: list):
""" """
Returns (chunks, chunk_map). Returns (chunks, chunk_map, chunk_lens).
chunks: flat list of 64-row grids (list of 64 × 64-channel ITRow arrays) chunks: flat list of 64-row grids (list of 64 × 64-channel ITRow arrays)
chunk_map: list per source pattern of [chunk_idx_0, chunk_idx_1, ...] chunk_map: list per source pattern of [chunk_idx_0, chunk_idx_1, ...]
chunk_lens: list parallel to chunks giving the real row count of each
chunk (64 for full chunks, < 64 for partial-tail chunks).
The cue builder emits a Taud LEN ($02xx) instruction for
any chunk whose length is < 64.
Patterns ≤ 64 rows produce one chunk of `rows` rows (LEN if rows < 64).
Patterns > 64 rows split into ⌊rows/64⌋ full 64-row chunks plus, if
`rows % 64 != 0`, a final chunk holding the remainder (which gets LEN).
""" """
chunks = [] chunks = []
chunk_map = [] chunk_map = []
chunk_lens = []
for pi, (grid, rows) in enumerate(patterns_rows): for pi, (grid, rows) in enumerate(patterns_rows):
if rows == 0: if rows == 0:
@@ -1028,7 +1032,10 @@ def split_patterns(patterns_rows: list):
for k in range(n_chunks): for k in range(n_chunks):
r0 = k * PATTERN_ROWS r0 = k * PATTERN_ROWS
r1 = min(r0 + PATTERN_ROWS, rows) r1 = min(r0 + PATTERN_ROWS, rows)
# Build a 64-row grid for this chunk chunk_len = r1 - r0
# Build a 64-row grid for this chunk (rows past chunk_len are
# silent padding; the engine will stop early via LEN when
# chunk_len < 64).
chunk_grid = [] chunk_grid = []
for ch in range(64): for ch in range(64):
ch_rows = [] ch_rows = []
@@ -1041,52 +1048,30 @@ def split_patterns(patterns_rows: list):
ch_rows.append(ITRow()) ch_rows.append(ITRow())
chunk_grid.append(ch_rows) chunk_grid.append(ch_rows)
# If this is not the last chunk, add a C $0000 on ch0 row (r1-r0-1)
# to immediately break to next order (skip padding rows).
# Only needed when the last real row of this chunk is < 63.
if k < n_chunks - 1:
last_real = r1 - r0 - 1
pad_row = chunk_grid[0][last_real]
if pad_row.effect == 0:
pad_row.effect = EFF_C
pad_row.effect_arg = 0
elif rows < PATTERN_ROWS and n_chunks == 1:
# Single chunk, short pattern → break at last real row
last_real = rows - 1
if last_real < PATTERN_ROWS - 1:
pad_row = chunk_grid[0][last_real]
if pad_row.effect == 0:
pad_row.effect = EFF_C
pad_row.effect_arg = 0
idx = len(chunks) idx = len(chunks)
chunks.append(chunk_grid) chunks.append(chunk_grid)
chunk_lens.append(chunk_len)
pat_chunks.append(idx) pat_chunks.append(idx)
chunk_map.append(pat_chunks) chunk_map.append(pat_chunks)
return chunks, chunk_map return chunks, chunk_map, chunk_lens
def _remap_bc_effects(chunks: list, chunk_map: list, def _remap_bc_effects(chunks: list, chunk_map: list,
order_list: list, it_ord_to_taud_cue: dict, order_list: list, it_ord_to_taud_cue: dict,
num_channels: int) -> None: num_channels: int) -> None:
"""Rewrite B/C effects using remapped order indices. """Rewrite B (position-jump) effects using remapped order indices.
B effects in all chunks are rewritten to point to the first chunk B effects are rewritten to point to the first chunk of the target IT
of the target IT order. C effects in non-final chunks of a split order. C effects (pattern break) need no special handling: each
pattern get a co-row B to skip remaining chunks. Taud cue carries its own LEN instruction, so a non-final chunk of a
split source pattern simply terminates after its real row count
when LEN < 64 — but full 64-row non-final chunks rely on the C
being emitted by the engine when the source pattern's row pointer
naturally hits a chunk boundary. Since splits at exact multiples of
64 have no LEN gap, no C-skip injection is required.
""" """
# For each chunk, record which (it_pat, chunk_k, n_chunks) it came from.
# We build this from chunk_map.
chunk_info = {} # chunk_idx → (it_pat_idx, k, n_chunks)
for pi, pat_chunks in enumerate(chunk_map):
n = len(pat_chunks)
for k, ci in enumerate(pat_chunks):
chunk_info[ci] = (pi, k, n)
for ci, chunk_grid in enumerate(chunks): for ci, chunk_grid in enumerate(chunks):
pi, k, n = chunk_info.get(ci, (0, 0, 1))
for ch in range(num_channels): for ch in range(num_channels):
if ch >= len(chunk_grid): continue if ch >= len(chunk_grid): continue
for row in chunk_grid[ch]: for row in chunk_grid[ch]:
@@ -1094,25 +1079,6 @@ def _remap_bc_effects(chunks: list, chunk_map: list,
it_tgt = row.effect_arg it_tgt = row.effect_arg
taud_cue = it_ord_to_taud_cue.get(it_tgt, it_tgt) taud_cue = it_ord_to_taud_cue.get(it_tgt, it_tgt)
row.effect_arg = taud_cue & 0xFF row.effect_arg = taud_cue & 0xFF
elif row.effect == EFF_C and k < n - 1:
# C in non-final chunk: need B to skip remaining chunks
# Find the cue index immediately after all chunks of this pat
# (the cue right after the last chunk of pi in the order list)
# We store the B in aux_effect; the Taud builder handles it.
skip_cue = _find_post_pat_cue(pi, order_list, chunk_map,
it_ord_to_taud_cue)
if skip_cue is not None:
row.aux_effect = (EFF_B, skip_cue & 0xFF)
def _find_post_pat_cue(pi: int, order_list: list, chunk_map: list,
it_ord_to_taud_cue: dict):
"""Return the Taud cue index that follows ALL chunks of pattern pi in the order list."""
for taud_cue, it_ord in it_ord_to_taud_cue.items():
# Find first Taud cue after the last chunk of pi
pass
# Simpler: walk the Taud cue list (we'll compute it in assemble_taud)
# Return None for now — assemble_taud will do a second pass.
return None
# ── Sample / instrument bin (same as s3m2taud) ──────────────────────────────── # ── Sample / instrument bin (same as s3m2taud) ────────────────────────────────
@@ -1165,7 +1131,8 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
s.sus_end = min(s.sus_end, n) s.sus_end = min(s.sus_end, n)
pos += n pos += n
# 192-byte instrument layout (terranmon.txt:1997-2070). # 256-byte instrument layout (terranmon.txt:2001+).
INST_STRIDE = 256
USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope USE_ENV_BIT = 0x0020 # b — set whenever the engine should evaluate the envelope
def _write_env(buf: bytearray, base: int, env_pts): def _write_env(buf: bytearray, base: int, env_pts):
@@ -1216,7 +1183,7 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
loop_mode = 0 # no loop loop_mode = 0 # no loop
flags_byte = (loop_mode & 0x3) | sustain_bit flags_byte = (loop_mode & 0x3) | sustain_bit
base = taud_idx * 192 base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr) 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 + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd) struct.pack_into('<H', inst_bin, base + 6, c2spd)
@@ -1226,12 +1193,19 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 14] = flags_byte inst_bin[base + 14] = flags_byte
idata = (instr_data_by_slot or {}).get(taud_idx) or {} idata = (instr_data_by_slot or {}).get(taud_idx) or {}
vol_env = idata.get('vol_env') vol_env = idata.get('vol_env')
pan_env = idata.get('pan_env') pan_env = idata.get('pan_env')
pf_env = idata.get('pf_env') pf_env = idata.get('pf_env')
vol_sus = idata.get('vol_sus', USE_ENV_BIT) # LOOP words live at offsets 15/17/19. SUSTAIN words at 189/191/193.
pan_sus = idata.get('pan_sus', 0) # When the source has neither loop nor sustain on the volume envelope
pf_sus = idata.get('pf_sus', 0) # the engine still needs the b flag so the single-point unit envelope
# is evaluated — synthesise USE_ENV_BIT into the LOOP word as a fallback.
vol_env_loop = idata.get('vol_env_loop', USE_ENV_BIT)
vol_env_sus = idata.get('vol_env_sus', 0)
pan_env_loop = idata.get('pan_env_loop', 0)
pan_env_sus = idata.get('pan_env_sus', 0)
pf_env_loop = idata.get('pf_env_loop', 0)
pf_env_sus = idata.get('pf_env_sus', 0)
# Sample-mode default IGV: fold sample default vol (Sv) and sample GV # Sample-mode default IGV: fold sample default vol (Sv) and sample GV
# into Taud's IGV. Instrument-mode supplies inst_gv pre-folded. # into Taud's IGV. Instrument-mode supplies inst_gv pre-folded.
if 'inst_gv' in idata: if 'inst_gv' in idata:
@@ -1247,9 +1221,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
# effects.c:1261). Clamp defensively to 4095. # effects.c:1261). Clamp defensively to 4095.
fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF) fadeout = min(0xFFF, idata.get('fadeout', 0) & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 15, vol_sus & 0xFFFF) # LOOP words at offsets 15/17/19.
struct.pack_into('<H', inst_bin, base + 17, pan_sus & 0xFFFF) struct.pack_into('<H', inst_bin, base + 15, vol_env_loop & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 19, pf_sus & 0xFFFF) struct.pack_into('<H', inst_bin, base + 17, pan_env_loop & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 19, pf_env_loop & 0xFFFF)
if vol_env: if vol_env:
_write_env(inst_bin, base + 21, vol_env) _write_env(inst_bin, base + 21, vol_env)
@@ -1258,8 +1233,10 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
# carried by IGV (byte 171), so the envelope must be a unit multiplier. # carried by IGV (byte 171), so the envelope must be a unit multiplier.
inst_bin[base + 21] = 63 inst_bin[base + 21] = 63
inst_bin[base + 22] = 0 inst_bin[base + 22] = 0
# Force engine to use this single point. # Force engine to use this single point — set the b bit on the LOOP
struct.pack_into('<H', inst_bin, base + 15, USE_ENV_BIT) # word so the envelope is evaluated even though no wrap region exists.
cur_loop = struct.unpack_from('<H', inst_bin, base + 15)[0]
struct.pack_into('<H', inst_bin, base + 15, cur_loop | USE_ENV_BIT)
if pan_env: if pan_env:
_write_env(inst_bin, base + 71, pan_env) _write_env(inst_bin, base + 71, pan_env)
@@ -1304,13 +1281,16 @@ def build_sample_inst_bin_it(samples_or_proxy: list,
inst_bin[base + 187] = idata.get('vib_depth', 0) & 0xFF inst_bin[base + 187] = idata.get('vib_depth', 0) & 0xFF
# Byte 188: vibrato rate (0..255 full range, IT samplewise Vir). # Byte 188: vibrato rate (0..255 full range, IT samplewise Vir).
inst_bin[base + 188] = idata.get('vib_rate', 0) & 0xFF inst_bin[base + 188] = idata.get('vib_rate', 0) & 0xFF
# Byte 189: duplicate-check / action (IT-only — bits 0-1 = DCT, bits 2-3 = DCA). # SUSTAIN words at offsets 189/191/193.
# DCT: 0=off, 1=note, 2=sample, 3=instrument. struct.pack_into('<H', inst_bin, base + 189, vol_env_sus & 0xFFFF)
# DCA: 0=note cut, 1=note off, 2=note fade. struct.pack_into('<H', inst_bin, base + 191, pan_env_sus & 0xFFFF)
struct.pack_into('<H', inst_bin, base + 193, pf_env_sus & 0xFFFF)
# Byte 195: duplicate-check / action (IT-only — bits 0-1 = DCT, bits 2-3 = DCA).
# Relocated 2026-05-06 from old offset 189 (now part of the vol sustain word).
dct = idata.get('dct', 0) & 0x03 dct = idata.get('dct', 0) & 0x03
dca = idata.get('dca', 0) & 0x03 dca = idata.get('dca', 0) & 0x03
inst_bin[base + 189] = (dca << 2) | dct inst_bin[base + 195] = (dca << 2) | dct
# Bytes 190-191: reserved (already zeroed). # Bytes 196..255: reserved (already zeroed).
vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}") vprint(f" instrument[{taud_idx}] '{s.name}' ptr:{ptr} c5spd:{s.c5_speed}")
@@ -1331,7 +1311,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
"""Build a 512-byte Taud pattern for one IT channel from a 64-row chunk grid.""" """Build a 512-byte Taud pattern for one IT channel from a 64-row chunk grid."""
out = bytearray(PATTERN_BYTES) out = bytearray(PATTERN_BYTES)
rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS
last_inst = 0
last_note_it = -1 last_note_it = -1
for r, cell in enumerate(rows[:PATTERN_ROWS]): for r, cell in enumerate(rows[:PATTERN_ROWS]):
@@ -1360,9 +1339,6 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
if cell.note >= 0: if cell.note >= 0:
note_taud = encode_note_it(cell.note) note_taud = encode_note_it(cell.note)
if cell.inst > 0:
last_inst = cell.inst
note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119) note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119)
# ── Volume column ──────────────────────────────────────────────────── # ── Volume column ────────────────────────────────────────────────────
@@ -1392,30 +1368,16 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int,
else: else:
pan_sel, pan_value = SEL_FINE, 0 pan_sel, pan_value = SEL_FINE, 0
# Handle aux_effect (B) stored on a C cell for chunk-skip
if cell.aux_effect is not None:
aux_cmd, aux_arg = cell.aux_effect
if aux_cmd == EFF_B and op == TOP_C:
# Encode as B effect; C row break handled by engine's simultaneous B+C
op = TOP_C
# We need to emit both; store the B's target in arg16 high byte
# Taud simultaneous B+C: B sets order, C sets row. Engine handles.
# Encoding: keep op=TOP_C (pattern break), store B target in
# a separate "B command on another channel". We can't encode two
# effects in one cell, so instead just emit the B effect here
# and let the order index point past the remaining chunks.
# This is a best-effort; the engine should honour the lowest-channel B.
op = TOP_B
arg16 = aux_arg & 0xFF
vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6) vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6)
pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6) pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6)
taud_inst = last_inst & 0xFF if (note_triggers or cell.inst > 0) else 0 # Preserve cell.inst==0 verbatim — IT semantics: a note row with no
# explicit instrument byte retriggers the channel's currently-loaded
# instrument. Filling in last_inst converts that into an explicit
# instrument-change, which can break NNA / envelope-reset behaviour.
base = r * 8 base = r * 8
struct.pack_into('<H', out, base + 0, note_taud) struct.pack_into('<H', out, base + 0, note_taud)
out[base + 2] = taud_inst out[base + 2] = cell.inst & 0xFF
out[base + 3] = vol_byte out[base + 3] = vol_byte
out[base + 4] = pan_byte out[base + 4] = pan_byte
out[base + 5] = op & 0xFF out[base + 5] = op & 0xFF
@@ -1563,7 +1525,7 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
# ── Split patterns into 64-row chunks ──────────────────────────────────── # ── Split patterns into 64-row chunks ────────────────────────────────────
vprint(" splitting patterns…") vprint(" splitting patterns…")
chunks, chunk_map = split_patterns(patterns_rows) chunks, chunk_map, chunk_lens = split_patterns(patterns_rows)
# ── Choose active channels ─────────────────────────────────────────────── # ── Choose active channels ───────────────────────────────────────────────
active_channels = _active_channels(h, patterns_rows) active_channels = _active_channels(h, patterns_rows)
@@ -1657,12 +1619,15 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
nna_taud = it_to_taud_nna[inst.nna & 0x03] nna_taud = it_to_taud_nna[inst.nna & 0x03]
instr_data_by_slot[taud_slot] = { instr_data_by_slot[taud_slot] = {
'vol_env': inst.vol_envelope, 'vol_env': inst.vol_envelope,
'vol_sus': inst.vol_env_sustain, 'vol_env_loop': inst.vol_env_loop,
'pan_env': inst.pan_envelope, 'vol_env_sus': inst.vol_env_sus,
'pan_sus': inst.pan_env_sustain, 'pan_env': inst.pan_envelope,
'pf_env': inst.pf_envelope, 'pan_env_loop': inst.pan_env_loop,
'pf_sus': inst.pf_env_sustain, 'pan_env_sus': inst.pan_env_sus,
'pf_env': inst.pf_envelope,
'pf_env_loop': inst.pf_env_loop,
'pf_env_sus': inst.pf_env_sus,
'inst_gv': inst_gv_255, 'inst_gv': inst_gv_255,
'fadeout': inst.fadeout, 'fadeout': inst.fadeout,
'vib_speed': vib_speed_taud, 'vib_speed': vib_speed_taud,
@@ -1739,17 +1704,35 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list,
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
last_active = -1 last_active = -1
len_cue_count = 0
for cue_idx, ci in enumerate(taud_cue_list): for cue_idx, ci in enumerate(taud_cue_list):
if cue_idx >= NUM_CUES: break if cue_idx >= NUM_CUES: break
base_pat = cue_idx * C base_pat = cue_idx * C
pats = [pat_remap[base_pat + vi] for vi in range(C)] pats = [pat_remap[base_pat + vi] for vi in range(C)]
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pats, 0) clen = chunk_lens[ci] if ci < len(chunk_lens) else PATTERN_ROWS
if clen < PATTERN_ROWS:
instr = cue_instruction_len(clen)
len_cue_count += 1
else:
instr = CUE_INST_NOP
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(pats, instr)
last_active = cue_idx last_active = cue_idx
if last_active >= 0: if last_active >= 0:
sheet[last_active * CUE_SIZE + 30] = 0x01 # Halt overlays whatever LEN was on this cue. If both apply
# (the song terminates on a partial-tail chunk), the LEN is
# mooted by halt — warn so the user is aware.
b30_existing = sheet[last_active * CUE_SIZE + 30]
if b30_existing == CUE_INST_LEN:
vprint(f" warning: last active cue {last_active} had LEN; "
f"replaced with HALT (partial tail at song terminus)")
sheet[last_active * CUE_SIZE + 30] = CUE_INST_HALT
sheet[last_active * CUE_SIZE + 31] = 0x00
else: else:
sheet[30] = 0x01 sheet[30] = CUE_INST_HALT
if len_cue_count:
vprint(f" emitted {len_cue_count} LEN cue instruction(s) "
f"for partial-length patterns")
# ── Header ─────────────────────────────────────────────────────────────── # ── Header ───────────────────────────────────────────────────────────────
sig = (SIGNATURE + b' ' * 14)[:14] sig = (SIGNATURE + b' ' * 14)[:14]

View File

@@ -500,7 +500,8 @@ def build_sample_inst_bin(samples: list) -> tuple:
s.loop_end = min(s.loop_end, n) s.loop_end = min(s.loop_end, n)
pos += n pos += n
# New 192-byte instrument layout (terranmon.txt:1997-2070). # New 256-byte instrument layout (terranmon.txt:2001+).
INST_STRIDE = 256
inst_bin = bytearray(INSTBIN_SIZE) inst_bin = bytearray(INSTBIN_SIZE)
for i, s in enumerate(samples): for i, s in enumerate(samples):
taud_idx = i + 1 # 1-based instrument number taud_idx = i + 1 # 1-based instrument number
@@ -519,9 +520,11 @@ def build_sample_inst_bin(samples: list) -> tuple:
# Envelope first point is full-scale; per-sample level is carried by # Envelope first point is full-scale; per-sample level is carried by
# IGV (byte 171) so the envelope must contribute a unit multiplier. # IGV (byte 171) so the envelope must contribute a unit multiplier.
env_vol = 63 env_vol = 63
vol_env_flags = 0x0020 # use-envelope bit # MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
# the unit envelope. Pan/PF stay disabled.
vol_env_loop = 0x0020 # b enable
base = taud_idx * 192 base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr) 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 + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd) struct.pack_into('<H', inst_bin, base + 6, c2spd)
@@ -529,7 +532,8 @@ def build_sample_inst_bin(samples: list) -> tuple:
struct.pack_into('<H', inst_bin, base + 10, ls) struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le) struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags) # LOOP words at 15/17/19; SUSTAIN words at 189/191/193 (left zero).
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop)
struct.pack_into('<H', inst_bin, base + 17, 0) struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0) struct.pack_into('<H', inst_bin, base + 19, 0)
inst_bin[base + 21] = env_vol inst_bin[base + 21] = env_vol

View File

@@ -476,9 +476,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst.loop_end = min(inst.loop_end, n) inst.loop_end = min(inst.loop_end, n)
pos += n pos += n
# Build instrument bin (256 × 192 bytes) # Build instrument bin (256 × 256 bytes)
# New layout (terranmon.txt:1997-2070): u32 sample ptr, ..., 25-point envelopes, # New layout (terranmon.txt:2001+): LOOP words at 15/17/19, SUSTAIN words at 189/191/193.
# plus a host of optional fields. S3M doesn't supply most of those — they default to 0. # S3M has no envelope sustain or loop, so SUSTAIN words stay zero.
INST_STRIDE = 256
inst_bin = bytearray(INSTBIN_SIZE) inst_bin = bytearray(INSTBIN_SIZE)
for i, inst in enumerate(instruments): for i, inst in enumerate(instruments):
taud_idx = i + 1 taud_idx = i + 1
@@ -498,10 +499,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
# Volume envelope first point is full-scale; per-sample level is carried # Volume envelope first point is full-scale; per-sample level is carried
# by IGV (byte 171) so the envelope contributes a unit multiplier. # by IGV (byte 171) so the envelope contributes a unit multiplier.
env_vol = 63 env_vol = 63
# Vol env-flags: enable use-envelope bit (b=1) so engine reads the single point. # Vol LOOP word: only b=1 (use envelope) — no actual loop / sustain.
vol_env_flags = 0x0020 # b=bit 5 vol_env_loop = 0x0020
base = taud_idx * 192 base = taud_idx * INST_STRIDE
struct.pack_into('<I', inst_bin, base + 0, ptr) # u32 sample pointer 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 + 4, s_len)
struct.pack_into('<H', inst_bin, base + 6, c2spd) # rate at TAUD_C4 struct.pack_into('<H', inst_bin, base + 6, c2spd) # rate at TAUD_C4
@@ -509,9 +510,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
struct.pack_into('<H', inst_bin, base + 10, ls) struct.pack_into('<H', inst_bin, base + 10, ls)
struct.pack_into('<H', inst_bin, base + 12, le) struct.pack_into('<H', inst_bin, base + 12, le)
inst_bin[base + 14] = flags_byte inst_bin[base + 14] = flags_byte
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags) # LOOP words at 15/17/19; SUSTAIN words at 189/191/193 (left zero).
struct.pack_into('<H', inst_bin, base + 17, 0) # pan env-flags struct.pack_into('<H', inst_bin, base + 15, vol_env_loop)
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env-flags struct.pack_into('<H', inst_bin, base + 17, 0)
struct.pack_into('<H', inst_bin, base + 19, 0)
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold). # Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
inst_bin[base + 21] = env_vol inst_bin[base + 21] = env_vol
inst_bin[base + 22] = 0 inst_bin[base + 22] = 0
@@ -524,7 +526,7 @@ def build_sample_inst_bin(instruments: list) -> tuple:
inst_bin[base + 183] = 0xFF # filter resonance = off inst_bin[base + 183] = 0xFF # filter resonance = off
inst_bin[base + 186] = 1 # NNA: note cut inst_bin[base + 186] = 1 # NNA: note cut
vprint(f" instrument[{base // 192}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'") vprint(f" instrument[{base // INST_STRIDE}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
if inst.c2spd > 65535: if inst.c2spd > 65535:
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')") vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")

View File

@@ -30,8 +30,9 @@ TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
TAUD_VERSION = 1 TAUD_VERSION = 1
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14) TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry) TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
SAMPLEBIN_SIZE = 737280 INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
INSTBIN_SIZE = 49152 # 256 instruments × 192 bytes SAMPLEBIN_SIZE = 720896 # was 737280; 16K reallocated to inst bin (terranmon.txt:1985-1997)
INSTBIN_SIZE = INST_RECORD_SIZE * 256 # 65536 = 64K
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
PATTERN_ROWS = 64 PATTERN_ROWS = 64
PATTERN_BYTES = PATTERN_ROWS * 8 # 512 PATTERN_BYTES = PATTERN_ROWS * 8 # 512
@@ -46,6 +47,18 @@ NOTE_KEYOFF = 0x0000
NOTE_CUT = 0xFFFE NOTE_CUT = 0xFFFE
TAUD_C4 = 0x5000 # The audio engine's Middle C TAUD_C4 = 0x5000 # The audio engine's Middle C
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
# Per terranmon.txt §"Cue Sheet":
# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
# 00000001 (HALT) end of song
# 00000000 (NOP) default 64-row cue
# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern
CUE_INST_NOP = 0x00
CUE_INST_HALT = 0x01
CUE_INST_LEN = 0x02
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23) # Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
TOP_NONE = 0x00 TOP_NONE = 0x00
TOP_A = 0x0A TOP_A = 0x0A
@@ -152,8 +165,13 @@ def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
return bytes(out) return bytes(out)
def encode_cue(patterns12: list, instruction: int) -> bytearray: def encode_cue(patterns12: list, instruction) -> bytearray:
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.""" """Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.
`instruction` is either an int (legacy single-byte value placed at byte 30,
byte 31 = 0) or a 2-tuple `(byte30, byte31)` for two-byte forms such as
LEN (CUE_INST_LEN with row count - 1).
"""
pats = list(patterns12) + [0xFFF] * NUM_VOICES pats = list(patterns12) + [0xFFF] * NUM_VOICES
pats = pats[:NUM_VOICES] pats = pats[:NUM_VOICES]
entry = bytearray(CUE_SIZE) entry = bytearray(CUE_SIZE)
@@ -162,10 +180,25 @@ def encode_cue(patterns12: list, instruction: int) -> bytearray:
entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles
entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles
entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles
entry[30] = instruction & 0xFF if isinstance(instruction, tuple):
b30, b31 = instruction
entry[30] = b30 & 0xFF
entry[31] = b31 & 0xFF
else:
entry[30] = instruction & 0xFF
return entry return entry
def cue_instruction_len(rows: int) -> tuple:
"""Build the 2-byte LEN cue instruction for `rows` (1..64).
Returns (byte30, byte31) where byte30 = 0x02 and byte31 = (rows - 1) & 0x3F.
"""
if not 1 <= rows <= 64:
raise ValueError(f"LEN row count must be 1..64, got {rows}")
return (CUE_INST_LEN, (rows - 1) & 0x3F)
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple: def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
"""Consolidate identical 512-byte Taud patterns into a single copy. """Consolidate identical 512-byte Taud patterns into a single copy.

View File

@@ -1985,16 +1985,57 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
Memory Space Memory Space
0..737279 RW: Sample bin (720k) 0..720895 RW: Sample bin (704k)
737280..786431 RW: Instrument bin (256 instruments, 192 bytes each; instrument 0 does nothing; 48k) 720896..786431 RW: Instrument bin (256 instruments, 256 bytes each; instrument 0 does nothing; 64k)
786432..851967 RW: Play data 1 (currently exposed bank; 64k) 786432..851967 RW: Play data 1 (currently exposed bank; 64k)
851968..917503 RW: Play data 2 (currently exposed bank; 64k) 851968..917503 RW: Play data 2 (currently exposed bank; 64k)
917504..983039 RW: TAD Input Buffer (64k) 917504..983039 RW: TAD Input Buffer (64k)
983040..1048575 RW: TAD Decode Output (64k) 983040..1048575 RW: TAD Decode Output (64k)
(Layout note 2026-05-06: sample bin shrunk by 16k and instrument bin widened
by the same amount so all downstream dispatch ranges keep their existing
anchors at 786432. Total memory space stays at exactly 1 MiB.)
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample 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: Instrument bin: Registry for 256 instruments, formatted as:
The instrument record is 256 bytes wide. Envelopes are described by FOUR
independent regions per envelope (vol / pan / pitch-filter):
1. The 25 envelope nodes (offsets 21 / 71 / 121).
2. The LOOP word (offsets 15 / 17 / 19) — defines an always-active
wrap region. When enabled (b=1) and the envelope position reaches
loop_end, it wraps back to loop_start. Active regardless of key
state. This is the IT/FT2 envelope loop.
3. The SUSTAIN word (offsets 189 / 191 / 193) — defines a wrap
region that is ONLY active while the key is on. When the key
goes off the sustain "releases" and the envelope position is
free to walk past sus_end. Concretely:
- FT2-style "sustain point": store sus_start == sus_end (single
index). Engine wraps that index → itself, so the envelope
holds at the point until key-off.
- IT-style "sustain loop": store sus_start <= sus_end. Engine
wraps sus_end → sus_start while key is on, so the envelope
loops within the sustain range until key-off.
4. (none — there is no separate "release loop"; once sustain releases
the envelope walks forward and is captured by the LOOP region if
the LOOP region exists and the position enters it.)
Priority during playback follows schismtracker player/sndmix.c:480-499:
if SUSTAIN.b == 1 and !key_off : wrap (sus_start, sus_end)
elif LOOP.b == 1 : wrap (loop_start, loop_end)
else : hold at last node
This means SUSTAIN takes precedence over LOOP while the key is on; once
the key is released, LOOP becomes the active wrap region. Setting both
to b=0 disables envelope wrapping entirely (envelope plays once and holds
at its last node).
The b flag is the SOLE enable bit for each region; the historical 't'
(sustain breaks on key-off) and 'u' (sustain/loop enable) flags are NOT
present in this encoding — sustain vs loop is now a structural
distinction (different word at a different offset), not a flag bit.
0 Uint32 Sample Pointer 0 Uint32 Sample Pointer
4 Uint16 Sample length 4 Uint16 Sample length
6 Uint16 Sampling rate at C4 (note number 0x5000) 6 Uint16 Sampling rate at C4 (note number 0x5000)
@@ -2006,41 +2047,35 @@ Instrument bin: Registry for 256 instruments, formatted as:
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes) pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
s: loop is sustain (key-off escapes the loop) s: loop is sustain (key-off escapes the loop)
- IT: look for sample's SusLoop flag - IT: look for sample's SusLoop flag
15 Bit16 Volume envelope sustain/loops and flags 15 Bit16 Volume envelope LOOP word
* Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index * Always-active wrap region for the volume envelope. See SUSTAIN word at offset 189 for the key-on-only wrap.
0b 0ut sssss 0cb eeeee 0b 000_sssss_0cb_eeeee
s: sustain/loop start index s (bits 12..8) : loop start index (0..24)
e: sustain/loop end index e (bits 4..0) : loop end index (0..24)
b (bit 5) : enable the LOOP (0 = no envelope loop)
b: use envelope c (bit 6) : envelope carry (cross-trigger envelope position carry)
c: envelope carry (bits 7, 13..15 reserved — set to 0)
17 Bit16 Panning envelope LOOP word
t: the loop must sustain (key-off escapes the loop) * Always-active wrap region for the pan envelope.
u: set to enable the sustain/loop 0b 000_sssss_pcb_eeeee
17 Bit16 Panning envelope sustain/loops and flags s (bits 12..8) : loop start index
* Sustain is implemented by enabling 't' flag e (bits 4..0) : loop end index
0b 0ut sssss pcb eeeee b (bit 5) : enable the LOOP
s: sustain/loop start index c (bit 6) : envelope carry
e: sustain/loop end index p (bit 7) : use default pan (see offset 177 "Default pan value" below).
Independent of LOOP enable; the engine reads this bit
b: use envelope from the LOOP word as the canonical home for envelope-
c: envelope carry level meta flags.
p: use default pan (see offset 177 "Default pan value" below) (bits 13..15 reserved)
19 Bit16 Pitch/Filter envelope LOOP word
t: the loop must sustain (key-off escapes the loop) * Always-active wrap region for the pitch/filter envelope.
u: set to enable the sustain/loop 0b 000_sssss_mcb_eeeee
19 Bit16 Pitch/Filter envelope sustain/loops and flags s (bits 12..8) : loop start index
* Sustain is implemented by enabling 't' flag e (bits 4..0) : loop end index
0b 0ut sssss mcb eeeee b (bit 5) : enable the LOOP
s: sustain/loop start index c (bit 6) : envelope carry
e: sustain/loop end index m (bit 7) : mode — 0 = pitch envelope, 1 = filter envelope
(bits 13..15 reserved)
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
21 Bit16x25 Volume envelopes 21 Bit16x25 Volume envelopes
Byte 1: Volume (00..3F) Byte 1: Volume (00..3F)
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely. Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
@@ -2090,7 +2125,29 @@ Instrument bin: Registry for 256 instruments, formatted as:
* FastTracker2 has range of 0..16; multiply by (255/16) then round to int * FastTracker2 has range of 0..16; multiply by (255/16) then round to int
188 Uint8 Vibrato Rate (0..255 full range) 188 Uint8 Vibrato Rate (0..255 full range)
* ImpulseTracker sample config. The spec follows ImpulseTracker precisely * ImpulseTracker sample config. The spec follows ImpulseTracker precisely
189 Byte[3] Reserved 189 Bit16 Volume envelope SUSTAIN word
* Wrap region active ONLY while key is on. Released on key-off.
* FT2 single-point sustain: store sus_start == sus_end (the engine
wraps that index → itself, so the envelope holds there).
* IT sustain loop: store sus_start <= sus_end (engine wraps the range
while key is on; same shape as the LOOP word).
0b 000_sssss_00b_eeeee
s (bits 12..8) : sustain start index (0..24)
e (bits 4..0) : sustain end index (0..24)
b (bit 5) : enable the SUSTAIN (0 = no sustain wrap)
(bits 6..7, 13..15 reserved — the 'c' carry bit lives in the LOOP word)
191 Bit16 Panning envelope SUSTAIN word
* Same encoding as offset 189, applied to the pan envelope.
0b 000_sssss_00b_eeeee
193 Bit16 Pitch/Filter envelope SUSTAIN word
* Same encoding as offset 189, applied to the pitch/filter envelope.
0b 000_sssss_00b_eeeee
195 Bit8 Duplicate Check / Action (IT-only; FT2 leaves this 0)
0b 0000 dcdt
dt (bits 0..1) : Duplicate Check Type. 0=off, 1=note, 2=sample, 3=instrument.
dc (bits 2..3) : Duplicate Check Action. 0=note cut, 1=note off, 2=note fade.
* Relocated from offset 189 (which is now the volume sustain word) on 2026-05-06.
196..255 Reserved (60 bytes free for future per-instrument fields)
@@ -2115,6 +2172,10 @@ TODO:
[ ] low-number voleffs are too quiet (needs elaboration and test cases) [ ] low-number voleffs are too quiet (needs elaboration and test cases)
[x] scale Oxxxx when samples get resampled [x] scale Oxxxx when samples get resampled
[x] implement bitcrusher and overdrive (eff sym '8' and '9') [x] implement bitcrusher and overdrive (eff sym '8' and '9')
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
[ ] implement extended tone mode (MONOTONE compat)
[ ] pattern loops stops working after processed once (test with slumberjack.xm)
[ ] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*)
Play Data: play data are series of tracker-like instructions, visualised as: Play Data: play data are series of tracker-like instructions, visualised as:
@@ -2239,10 +2300,12 @@ Play Head Flags
Byte 11..20: 0b miV1 miV2, 0b miV3 miV4, 0b miV5 miV6, ... 0b miV19 miV20 Byte 11..20: 0b miV1 miV2, 0b miV3 miV4, 0b miV5 miV6, ... 0b miV19 miV20
Byte 21..30: 0b hiV1 hiV2, 0b hiV3 hiV4, 0b hiV5 hiV6, ... 0b hiV19 hiV20 Byte 21..30: 0b hiV1 hiV2, 0b hiV3 hiV4, 0b hiV5 hiV6, ... 0b hiV19 hiV20
Byte 31..32: instruction Byte 31..32: instruction
1000xxxx yyyyyyyy - Go back 0bxxxxyyyyyyyy patterns 1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns
1001xxxx yyyyyyyy - Skip forward 0bxxxxyyyyyyyy patterns 1001xxxx yyyyyyyy (FWD000) - Skip forward 0bxxxxyyyyyyyy patterns
1111xxxx yyyyyyyy - Go to absolute pattern number 0bxxxxyyyyyyyy 1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy
00000001 - Halt 00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns)
00000001 00000000 - Halt (HALT )
00000001 00111111 - Fadeout (FADOUT) - Gradually decrease global volume such that at row 63 it reaches zero
00000000 - No operation 00000000 - No operation
65536..131071 RW: PCM Sample buffer 65536..131071 RW: PCM Sample buffer
@@ -2329,9 +2392,9 @@ 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 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 Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
Uint8 Flags for Global Behaviour (effect symbol '1') Uint8 Flags for Global Behaviour (effect symbol '1')
0b 0000 0mfp 0b 0000 Fmfp
p: panning law (0=linear, 1=equal-power) p: panning law (0=linear, 1=equal-power)
f: tone mode (0=linear pitch slides, 1=Amiga period slides) Ff: tone mode (0=linear pitch slides, 1=Amiga period slides, 2=linear-frequency slides, 3=reserved)
m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout; m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout;
1=FT2 — stored fadeout 0 means cut on key-off) 1=FT2 — stored fadeout 0 means cut on key-off)
Uint8 Song global volume Uint8 Song global volume

View File

@@ -135,7 +135,11 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
const val AMIGA_BASE_PERIOD = 428.0 const val AMIGA_BASE_PERIOD = 428.0
} }
internal val sampleBin = UnsafeHelper.allocate(737280L, this) // Memory map (terranmon.txt:1985-1997, updated 2026-05-06):
// 0..720895 sample bin (704K, was 737280)
// 720896..786431 instrument bin (256 inst × 256 bytes = 64K)
// 786432.. play data 1 / 2 / TAD blocks (anchors unchanged)
internal val sampleBin = UnsafeHelper.allocate(720896L, this)
internal val instruments = Array(256) { TaudInst(it) } internal val instruments = Array(256) { TaudInst(it) }
internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } } internal val playdata = Array(4096) { Array(64) { TaudPlayData(0xFFFF, 0, 0, 0, 32, 0, 0, 0) } }
internal val playheads: Array<Playhead> internal val playheads: Array<Playhead>
@@ -307,8 +311,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
override fun peek(addr: Long): Byte { override fun peek(addr: Long): Byte {
return when (val adi = addr.toInt()) { return when (val adi = addr.toInt()) {
in 0..737279 -> sampleBin[addr] in 0..720895 -> sampleBin[addr]
in 737280..786431 -> (adi - 737280).let { instruments[it / 192].getByte(it % 192) } in 720896..786431 -> (adi - 720896).let { instruments[it / 256].getByte(it % 256) }
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) } in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) } in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].getByte(off % 8) }
in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes) in 917504..983039 -> tadInputBin[addr - 917504] // TAD input buffer (65536 bytes)
@@ -321,8 +325,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val adi = addr.toInt() val adi = addr.toInt()
val bi = byte.toUint() val bi = byte.toUint()
when (adi) { when (adi) {
in 0..737279 -> { sampleBin[addr] = byte } in 0..720895 -> { sampleBin[addr] = byte }
in 737280..786431 -> (adi - 737280).let { instruments[it / 192].setByte(it % 192, bi) } in 720896..786431 -> (adi - 720896).let { instruments[it / 256].setByte(it % 256, bi) }
in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) } in 786432..851967 -> { val off = adi - 786432; playdata[playheads[0].patBank1 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) } in 851968..917503 -> { val off = adi - 851968; playdata[playheads[0].patBank2 * 128 + off / 512][(off % 512) / 8].setByte(off % 8, bi) }
in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer in 917504..983039 -> tadInputBin[addr - 917504] = byte // TAD input buffer
@@ -1205,33 +1209,60 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
return amigaPeriodToNoteVal(newPeriod) return amigaPeriodToNoteVal(newPeriod)
} }
/**
* Resolve the active wrap region for an envelope based on the LOOP and
* SUSTAIN words and key state.
*
* Encoding (terranmon.txt:2049+, 2114+):
* LOOP word (offset 15/17/19): 0b 0000_0sss_ssXcb_eeeee
* SUSTAIN word (offset 189/191/193): 0b 0000_0sss_ss00b_eeeee
* In both, bit 5 = b (enable). bits 12..8 = start, bits 4..0 = end.
*
* Priority (matches schismtracker player/sndmix.c:480-499):
* if SUSTAIN.b and !keyOff : wrap (sus_start, sus_end)
* elif LOOP.b : wrap (loop_start, loop_end)
* else : no wrap (envelope walks forward and holds)
*
* Returns -1 in `wrapEnd` when no wrap is active.
*/
private inline fun resolveEnvWrap(loopWord: Int, sustainWord: Int, keyOff: Boolean,
outRange: IntArray) {
val susB = (sustainWord ushr 5) and 1 != 0
val loopB = (loopWord ushr 5) and 1 != 0
if (susB && !keyOff) {
outRange[0] = (sustainWord ushr 8) and 0x1F
outRange[1] = sustainWord and 0x1F
} else if (loopB) {
outRange[0] = (loopWord ushr 8) and 0x1F
outRange[1] = loopWord and 0x1F
} else {
outRange[0] = -1
outRange[1] = -1
}
}
// Reusable per-envelope wrap-range scratch (avoid per-tick allocation).
private val volWrap = IntArray(2)
private val panWrap = IntArray(2)
private val pfWrap = IntArray(2)
private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) { private fun advanceEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
// 16-bit envelope-flag layout (terranmon.txt:2007-2030):
// 0b 0ut sssss pcb eeeee
// bit 14 = u (enable sustain/loop)
// bit 13 = t (sustain — 1=breaks on key-off, 0=loops forever)
// bits 12..8 = sustain/loop start index (0..24)
// bit 7 = p (channel-specific flag — fadeout zero / use default pan)
// bit 6 = c (envelope carry)
// bit 5 = b (use envelope at all)
// bits 4..0 = sustain/loop end index (0..24)
val maxIdx = 24 val maxIdx = 24
// Volume envelope // Volume envelope
val vSus = inst.volEnvSustain val vEnvActive = (((inst.volEnvLoop ushr 5) and 1) or ((inst.volEnvSustainWord ushr 5) and 1)) != 0
val vUseEnv = (vSus ushr 5) and 1 != 0 if (vEnvActive && voice.volEnvOn) {
if (vUseEnv && voice.volEnvOn) { resolveEnvWrap(inst.volEnvLoop, inst.volEnvSustainWord, voice.keyOff, volWrap)
val vEnabled = (vSus ushr 14) and 1 != 0 val wStart = volWrap[0]
val vIsSustain = (vSus ushr 13) and 1 != 0 val wEnd = volWrap[1]
val vSusOn = vEnabled && (!vIsSustain || !voice.keyOff) val wrapping = wStart >= 0
val vSusStart = (vSus ushr 8) and 0x1F
val vSusEnd = vSus and 0x1F
if (vSusOn && voice.envIndex == vSusEnd && vSusStart == vSusEnd) { if (wrapping && voice.envIndex == wEnd && wStart == wEnd) {
// Hold at the wrap point (FT2 single-point sustain).
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (vSusOn && voice.envIndex == vSusEnd) { } else if (wrapping && voice.envIndex == wEnd) {
voice.envTimeSec = 0.0 voice.envTimeSec = 0.0
voice.envIndex = vSusStart voice.envIndex = wStart
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
} else if (voice.envIndex >= maxIdx) { } else if (voice.envIndex >= maxIdx) {
voice.envVolume = (inst.volEnvelopes[maxIdx].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[maxIdx].value / 63.0).coerceIn(0.0, 1.0)
@@ -1243,7 +1274,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envTimeSec += tickSec voice.envTimeSec += tickSec
if (voice.envTimeSec >= vOffset) { if (voice.envTimeSec >= vOffset) {
voice.envTimeSec -= vOffset voice.envTimeSec -= vOffset
val nextIdx = if (vSusOn && voice.envIndex == vSusEnd) vSusStart val nextIdx = if (wrapping && voice.envIndex == wEnd) wStart
else (voice.envIndex + 1).coerceAtMost(maxIdx) else (voice.envIndex + 1).coerceAtMost(maxIdx)
voice.envIndex = nextIdx voice.envIndex = nextIdx
voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0) voice.envVolume = (inst.volEnvelopes[voice.envIndex].value / 63.0).coerceIn(0.0, 1.0)
@@ -1258,20 +1289,18 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Pan envelope (only when active for this instrument) // Pan envelope (only when active for this instrument)
if (!voice.hasPanEnv || !voice.panEnvOn) return if (!voice.hasPanEnv || !voice.panEnvOn) return
val pSus = inst.panEnvSustain val pEnvActive = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0
val pUseEnv = (pSus ushr 5) and 1 != 0 if (!pEnvActive) return
if (!pUseEnv) return resolveEnvWrap(inst.panEnvLoop, inst.panEnvSustainWord, voice.keyOff, panWrap)
val pEnabled = (pSus ushr 14) and 1 != 0 val pStart = panWrap[0]
val pIsSustain = (pSus ushr 13) and 1 != 0 val pEnd = panWrap[1]
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff) val pWrapping = pStart >= 0
val pSusStart = (pSus ushr 8) and 0x1F
val pSusEnd = pSus and 0x1F
if (pSusOn && voice.envPanIndex == pSusEnd && pSusStart == pSusEnd) { if (pWrapping && voice.envPanIndex == pEnd && pStart == pEnd) {
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (pSusOn && voice.envPanIndex == pSusEnd) { } else if (pWrapping && voice.envPanIndex == pEnd) {
voice.envPanTimeSec = 0.0 voice.envPanTimeSec = 0.0
voice.envPanIndex = pSusStart voice.envPanIndex = pStart
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
} else if (voice.envPanIndex >= maxIdx) { } else if (voice.envPanIndex >= maxIdx) {
voice.envPan = inst.panEnvelopes[maxIdx].value / 255.0 voice.envPan = inst.panEnvelopes[maxIdx].value / 255.0
@@ -1283,7 +1312,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPanTimeSec += tickSec voice.envPanTimeSec += tickSec
if (voice.envPanTimeSec >= pOffset) { if (voice.envPanTimeSec >= pOffset) {
voice.envPanTimeSec -= pOffset voice.envPanTimeSec -= pOffset
val nextIdx = if (pSusOn && voice.envPanIndex == pSusEnd) pSusStart val nextIdx = if (pWrapping && voice.envPanIndex == pEnd) pStart
else (voice.envPanIndex + 1).coerceAtMost(maxIdx) else (voice.envPanIndex + 1).coerceAtMost(maxIdx)
voice.envPanIndex = nextIdx voice.envPanIndex = nextIdx
voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0 voice.envPan = inst.panEnvelopes[voice.envPanIndex].value / 255.0
@@ -1303,14 +1332,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) { private fun advancePfEnvelope(voice: Voice, inst: TaudInst, tickSec: Double) {
if (!voice.hasPfEnv || !voice.pfEnvOn) return if (!voice.hasPfEnv || !voice.pfEnvOn) return
val maxIdx = 24 val maxIdx = 24
val pSus = inst.pfEnvSustain val pEnvActive = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0
val pUseEnv = (pSus ushr 5) and 1 != 0 if (!pEnvActive) return
if (!pUseEnv) return resolveEnvWrap(inst.pfEnvLoop, inst.pfEnvSustainWord, voice.keyOff, pfWrap)
val pEnabled = (pSus ushr 14) and 1 != 0 val pSusStart = pfWrap[0]
val pIsSustain = (pSus ushr 13) and 1 != 0 val pSusEnd = pfWrap[1]
val pSusOn = pEnabled && (!pIsSustain || !voice.keyOff) val pSusOn = pSusStart >= 0
val pSusStart = (pSus ushr 8) and 0x1F
val pSusEnd = pSus and 0x1F
if (pSusOn && voice.envPfIndex == pSusEnd && pSusStart == pSusEnd) { if (pSusOn && voice.envPfIndex == pSusEnd && pSusStart == pSusEnd) {
voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0 voice.envPfValue = inst.pfEnvelopes[voice.envPfIndex].value / 255.0
@@ -1523,7 +1550,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val sampleLen = inst.sampleLength.coerceAtLeast(1) val sampleLen = inst.sampleLength.coerceAtLeast(1)
val loopStart = inst.sampleLoopStart.toDouble() val loopStart = inst.sampleLoopStart.toDouble()
val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0) val loopEnd = inst.sampleLoopEnd.toDouble().coerceAtLeast(1.0)
val binMax = 737279 // sampleBin is 737280 bytes (0..737279) val binMax = 720895 // sampleBin is 720896 bytes (0..720895)
val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1) val i0 = voice.samplePos.toInt().coerceIn(0, sampleLen - 1)
val i1 = (i0 + 1).coerceAtMost(sampleLen - 1) val i1 = (i0 + 1).coerceAtMost(sampleLen - 1)
@@ -1578,11 +1605,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.envPanIndex = 0 voice.envPanIndex = 0
voice.envPanTimeSec = 0.0 voice.envPanTimeSec = 0.0
voice.envPan = inst.panEnvelopes[0].value / 255.0 voice.envPan = inst.panEnvelopes[0].value / 255.0
// Pan envelope is active when the `b` (use envelope) flag is set in panEnvSustain. // Pan envelope is active when EITHER the LOOP word's b bit OR the SUSTAIN word's b bit is set.
voice.hasPanEnv = (inst.panEnvSustain ushr 5) and 1 != 0 voice.hasPanEnv = (((inst.panEnvLoop ushr 5) and 1) or ((inst.panEnvSustainWord ushr 5) and 1)) != 0
// Pitch/filter envelope state. // Pitch/filter envelope state.
voice.hasPfEnv = (inst.pfEnvSustain ushr 5) and 1 != 0 voice.hasPfEnv = (((inst.pfEnvLoop ushr 5) and 1) or ((inst.pfEnvSustainWord ushr 5) and 1)) != 0
voice.envPfIsFilter = (inst.pfEnvSustain ushr 7) and 1 != 0 // The pf 'm' mode bit (pitch=0, filter=1) lives in the LOOP word at bit 7.
voice.envPfIsFilter = (inst.pfEnvLoop ushr 7) and 1 != 0
voice.envPfIndex = 0 voice.envPfIndex = 0
voice.envPfTimeSec = 0.0 voice.envPfTimeSec = 0.0
voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5 voice.envPfValue = if (voice.hasPfEnv) inst.pfEnvelopes[0].value / 255.0 else 0.5
@@ -1597,8 +1625,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
voice.randomPanBias = if (inst.panSwing != 0) voice.randomPanBias = if (inst.panSwing != 0)
(Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0 (Math.random() * (2 * inst.panSwing + 1)).toInt() - inst.panSwing else 0
// Default pan: applied unless the pattern row has already overridden channelPan. // Default pan: applied unless the pattern row has already overridden channelPan.
// We treat the pan envelope "p" flag (panEnvSustain bit 7) as "use default pan". // The pan envelope's 'p' flag ("use default pan") lives in the pan LOOP word at bit 7.
if ((inst.panEnvSustain ushr 7) and 1 != 0) { if ((inst.panEnvLoop ushr 7) and 1 != 0) {
voice.channelPan = inst.defaultPan voice.channelPan = inst.defaultPan
voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63) voice.rowPan = (voice.channelPan ushr 2).coerceIn(0, 63)
} }
@@ -1874,12 +1902,26 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// call this an "instrument-only retrigger"; in MOD/S3M/IT the sample // call this an "instrument-only retrigger"; in MOD/S3M/IT the sample
// keeps playing, but the channel's instrument reference advances. // keeps playing, but the channel's instrument reference advances.
0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment } 0xFFFF -> { if (row.instrment != 0) voice.instrumentId = row.instrment }
0x0000 -> { voice.keyOff = true; voice.active = false } // key-off; breaks sustain loop // Key-off: release sustain; envelope walks past the sustain point and the fadeout
0xFFFE -> voice.active = false // note cut // begins (foreground-voice fade path at line ~2380). The voice deactivates when
// fadeoutVolume reaches 0, or immediately if FT2-mode fadeStep == 0. Setting
// voice.active = false here would defeat both — instruments with sustain points
// and non-zero fadeout (FT2 sustain-then-fade idiom) would be cut on the spot.
0x0000 -> { voice.keyOff = true }
0xFFFE -> voice.active = false // note cut (immediate)
else -> { else -> {
if (toneG && voice.active) { if (toneG && voice.active) {
// Tone porta: target the note, do not retrigger sample. // Tone porta: target the note, do not retrigger sample.
voice.tonePortaTarget = row.note voice.tonePortaTarget = row.note
// Instrument byte on a porta row reloads the channel's default
// volume even though the sample isn't retriggered. Mirrors schism
// csf_instrument_change (effects.c:1302) which writes
// chan->volume = psmp->volume whenever inst_column is set.
if (row.instrment != 0) {
voice.instrumentId = row.instrment
voice.channelVolume = 0x3F
voice.rowVolume = 0x3F
}
} else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) { } else if ((row.effect == EffectOp.OP_S) && ((row.effectArg ushr 12) and 0xF) == 0xD) {
// Note delay: defer trigger to the requested tick. NNA fires when the // Note delay: defer trigger to the requested tick. NNA fires when the
// deferred trigger actually executes, not now. // deferred trigger actually executes, not now.
@@ -2364,14 +2406,19 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
// Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick. // Volume fadeout: after key-off OR Note-Fade NNA, decrement per tick.
// The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh. // The 12-bit fadeStep is split across volumeFadeoutLow + low nibble of fadeoutHigh.
// Divisor selects per-tracker semantics: // Divisor selects per-tracker semantics:
// FT2 mode (fadeoutCutOnZero=true): fadeStep / 65536 per tick — matches FT2 .XM (16-bit accumulator, decrement = stored). // FT2 mode (fadeoutCutOnZero=true): fadeStep / 32768 per tick — matches ft2-clone
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism (sndmix.c:331-339 + effects.c:1261: // (ft2_replayer.c:387-390, 1469-1481): the FT2 XM
// accumulator 65536, decrement = (stored<<5)<<1 = stored·64). // file format docs claim the accumulator is 16-bit
// (65536), but the actual replayer initialises
// fadeoutVol to 32768 and decrements by stored.
// IT mode (fadeoutCutOnZero=false): fadeStep / 1024 per tick — matches Schism
// (sndmix.c:331-339 + effects.c:1261: accumulator
// 65536, decrement = (stored<<5)<<1 = stored·64).
// Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade). // Stored 0: FT2 mode cuts on key-off; IT mode leaves voice playing (no fade).
if (voice.keyOff || voice.noteFading) { if (voice.keyOff || voice.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) { if (fadeStep > 0) {
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0 val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0
voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0) voice.fadeoutVolume = (voice.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
if (voice.fadeoutVolume <= 0.0) voice.active = false if (voice.fadeoutVolume <= 0.0) voice.active = false
} else if (ts.fadeoutCutOnZero) { } else if (ts.fadeoutCutOnZero) {
@@ -2427,7 +2474,9 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
if (bg.keyOff || bg.noteFading) { if (bg.keyOff || bg.noteFading) {
val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8) val fadeStep = inst.volumeFadeoutLow or ((inst.fadeoutHigh and 0x0F) shl 8)
if (fadeStep > 0) { if (fadeStep > 0) {
val divisor = if (ts.fadeoutCutOnZero) 65536.0 else 1024.0 // Divisor must mirror the foreground-voice fade path above
// (FT2 mode: 32768 to match ft2_replayer.c:387-390+1469-1481).
val divisor = if (ts.fadeoutCutOnZero) 32768.0 else 1024.0
bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0) bg.fadeoutVolume = (bg.fadeoutVolume - fadeStep / divisor).coerceAtLeast(0.0)
} else if (ts.fadeoutCutOnZero) { } else if (ts.fadeoutCutOnZero) {
bg.active = false bg.active = false
@@ -2625,7 +2674,13 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
} }
else -> { else -> {
ts.rowIndex++ ts.rowIndex++
if (ts.rowIndex >= 64) { // LEN cue instruction shortens the effective row count so the
// engine wraps to the next cue early. Patterns fed by the
// converter are still 64 rows long; rows past `rowLimit` are
// silent padding that we skip here.
val currentInst = cueSheet[ts.cuePos].instruction
val rowLimit = if (currentInst is PlayInstPatLen) currentInst.rows else 64
if (ts.rowIndex >= rowLimit) {
ts.rowIndex = 0 ts.rowIndex = 0
advanceTrackerCue(ts, playhead) advanceTrackerCue(ts, playhead)
resetPatternLoopState(ts) resetPatternLoopState(ts)
@@ -2637,14 +2692,38 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal data class PlayCue( internal data class PlayCue(
val patterns: IntArray = IntArray(20) { 0xFFF }, val patterns: IntArray = IntArray(20) { 0xFFF },
var instruction: PlayInstruction = PlayInstNop var instruction: PlayInstruction = PlayInstNop,
var instByte30: Int = 0,
var instByte31: Int = 0,
) { ) {
// Cue layout (32 bytes, 20 voices, 12-bit pattern numbers): // Cue layout (32 bytes, 20 voices, 12-bit pattern numbers):
// bytes 0-9: packed low nybbles (byte i => voice i*2 in hi, voice i*2+1 in lo) // bytes 0-9: packed low nybbles (byte i => voice i*2 in hi, voice i*2+1 in lo)
// bytes 10-19: packed mid nybbles (same packing) // bytes 10-19: packed mid nybbles (same packing)
// bytes 20-29: packed high nybbles (same packing) // bytes 20-29: packed high nybbles (same packing)
// byte 30: instruction // byte 30: instruction (low byte)
// byte 31: unused // byte 31: instruction arg byte (used by 2-byte forms: LEN, BAK, FWD, JMP)
// Decoding rules per terranmon.txt §"Cue Sheet":
// 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
// 00000001 (HALT) end of song
// 00000000 (NOP) default 64-row cue
// 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
// 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
// 1111xxxx yyyyyyyy (JMP) go to absolute pattern (currently unused)
private fun recomputeInstruction() {
val b30 = instByte30
val b31 = instByte31
instruction = when {
b30 == 0x02 -> PlayInstPatLen((b31 and 0x3F) + 1)
b30 == 0x01 -> PlayInstHalt
b30 == 0x00 -> PlayInstNop
// BAK: 1000xxxx yyyyyyyy — 12-bit arg combining b30 low nybble + b31.
(b30 and 0xF0) == 0x80 -> PlayInstGoBack(((b30 and 0xF) shl 8) or (b31 and 0xFF))
// FWD: 1001xxxx yyyyyyyy — 12-bit arg.
(b30 and 0xF0) == 0x90 -> PlayInstSkip(((b30 and 0xF) shl 8) or (b31 and 0xFF))
// JMP: 1111xxxx yyyyyyyy — reserved (decoder TBD).
else -> PlayInstNop
}
}
fun write(index: Int, byte: Int) = when (index) { fun write(index: Int, byte: Int) = when (index) {
in 0..9 -> { in 0..9 -> {
val b = index * 2 val b = index * 2
@@ -2661,13 +2740,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
patterns[b] = (patterns[b] and 0x0FF) or (((byte ushr 4) and 0xF) shl 8) patterns[b] = (patterns[b] and 0x0FF) or (((byte ushr 4) and 0xF) shl 8)
patterns[b + 1] = (patterns[b + 1] and 0x0FF) or ((byte and 0xF) shl 8) patterns[b + 1] = (patterns[b + 1] and 0x0FF) or ((byte and 0xF) shl 8)
} }
30 -> { instruction = when { 30 -> { instByte30 = byte and 0xFF; recomputeInstruction() }
byte >= 128 -> PlayInstGoBack(byte and 127) 31 -> { instByte31 = byte and 0xFF; recomputeInstruction() }
byte in 16..31 -> PlayInstSkip(byte and 15)
byte == 1 -> PlayInstHalt
else -> PlayInstNop
} }
31 -> {}
else -> throw InternalError("Bad offset $index") else -> throw InternalError("Bad offset $index")
} }
fun read(index: Int): Byte = when (index) { fun read(index: Int): Byte = when (index) {
@@ -2683,13 +2757,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
val b = (index - 20) * 2 val b = (index - 20) * 2
((((patterns[b] ushr 8) and 0xF) shl 4) or ((patterns[b + 1] ushr 8) and 0xF)).toByte() ((((patterns[b] ushr 8) and 0xF) shl 4) or ((patterns[b + 1] ushr 8) and 0xF)).toByte()
} }
30 -> when (instruction) { 30 -> instByte30.toByte()
is PlayInstGoBack -> (0b10000000 or instruction.arg).toByte() 31 -> instByte31.toByte()
is PlayInstSkip -> (0b00010000 or instruction.arg).toByte()
is PlayInstHalt -> 1
else -> 0
}
31 -> 0
else -> throw InternalError("Bad offset $index") else -> throw InternalError("Bad offset $index")
} }
} }
@@ -2697,6 +2766,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
internal open class PlayInstruction(val arg: Int) internal open class PlayInstruction(val arg: Int)
internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg) internal class PlayInstGoBack(arg: Int) : PlayInstruction(arg)
internal class PlayInstSkip(arg: Int) : PlayInstruction(arg) internal class PlayInstSkip(arg: Int) : PlayInstruction(arg)
internal class PlayInstPatLen(val rows: Int) : PlayInstruction(rows)
internal object PlayInstHalt : PlayInstruction(0) internal object PlayInstHalt : PlayInstruction(0)
internal object PlayInstNop : PlayInstruction(0) internal object PlayInstNop : PlayInstruction(0)
@@ -2768,7 +2838,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
var envPfIndex = 0 var envPfIndex = 0
var envPfTimeSec = 0.0 var envPfTimeSec = 0.0
var envPfValue = 0.5 // 0.0..1.0; 0.5 = unity (no pitch shift / unmodulated cutoff) var envPfValue = 0.5 // 0.0..1.0; 0.5 = unity (no pitch shift / unmodulated cutoff)
var envPfIsFilter = false // mirror of inst.pfEnvSustain bit 7 latched at trigger var envPfIsFilter = false // mirror of inst.pfEnvLoop bit 7 latched at trigger
// Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow. // Volume fadeout — engaged after key-off, decays to 0 at rate inst.volumeFadeoutLow.
var fadeoutVolume = 1.0 var fadeoutVolume = 1.0
@@ -3143,77 +3213,92 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat) data class TaudInstEnvPoint(var value: Int, var offset: ThreeFiveMiniUfloat)
/** /**
* 192-byte instrument record (terranmon.txt:1997-2070). * 256-byte instrument record (terranmon.txt:2001+).
*
* Envelopes have FOUR independent regions per envelope (vol/pan/pf):
* - 25 envelope nodes (offsets 21 / 71 / 121).
* - LOOP word (offsets 15 / 17 / 19) — always-active wrap region.
* - SUSTAIN word (offsets 189 / 191 / 193) — wrap region active ONLY
* while key is on; released on key-off.
*
* Priority during playback (matches schismtracker player/sndmix.c:480-499):
* if SUSTAIN.b == 1 and !key_off : wrap (sus_start, sus_end)
* elif LOOP.b == 1 : wrap (loop_start, loop_end)
* else : hold at last node
*
* Layout: * Layout:
* 0..3 u32 sample pointer * 0..3 u32 sample pointer
* 4..5 u16 sample length * 4..5 u16 sample length
* 6..7 u16 sampling rate at Middle C (0x5000) // NOTE: Taud treats middle C as C4, but some trackers show you C4 even if they are internally C5. Best practice: copy the value as-is. * 6..7 u16 sampling rate at Middle C (0x5000)
* 8..9 u16 play start * 8..9 u16 play start
* 10..11 u16 loop start * 10..11 u16 loop start
* 12..13 u16 loop end * 12..13 u16 loop end
* 14 u8 sample flags (low 2 bits = loop mode 0..3) * 14 u8 sample flags (low 2 bits = loop mode 0..3)
* 15..16 u16 volume envelope flags (0b 0ut sssss pcb eeeee) * 15..16 u16 volume envelope LOOP word (0b 0000_0sss_ss0cb_eeeee)
* 17..18 u16 panning envelope flags * 17..18 u16 panning envelope LOOP word (0b 0000_0sss_ssp_cb_eeeee, p=use-default-pan)
* 19..20 u16 pitch/filter envelope flags * 19..20 u16 pitch/filter envelope LOOP word (0b 0000_0sss_ssm_cb_eeeee, m=mode)
* 21..70 Bit16×25 volume envelope points (value 0x00-0x3F + minifloat dt) * 21..70 Bit16×25 volume envelope points
* 71..120 Bit16×25 panning envelope points (value 0x00-0xFF, 0x80=centre) * 71..120 Bit16×25 panning envelope points
* 121..170 Bit16×25 pitch/filter envelope points * 121..170 Bit16×25 pitch/filter envelope points
* 171 u8 instrument global volume * 171 u8 instrument global volume
* 172 u8 volume fadeout low bits * 172 u8 volume fadeout low bits
* 173 u8 fadeout high bits (low nibble; 0b 0000 ffff) * 173 u8 fadeout high bits (low nibble; 0b 0000 ffff)
* 174 u8 volume swing * 174 u8 volume swing
* 175 u8 vibrato speed (FT2 instrumentwise; IT Vis rescaled to 0..255) * 175 u8 vibrato speed
* 176 u8 vibrato sweep (FT2-only ramp ticks; 0 for IT) * 176 u8 vibrato sweep
* 177 u8 default pan * 177 u8 default pan
* 178..179 u16 pitch-pan centre (4096-TET) * 178..179 u16 pitch-pan centre
* 180 s8 pitch-pan separation * 180 s8 pitch-pan separation
* 181 u8 pan swing * 181 u8 pan swing
* 182 u8 default cutoff * 182 u8 default cutoff
* 183 u8 default resonance * 183 u8 default resonance
* 184..185 u16 sample detune (4096-TET, signed stored as u16) * 184..185 u16 sample detune (signed)
* 186 u8 instrument flag (0b 000 www nn — NNA bits 0-1, vib waveform bits 2-4) * 186 u8 instrument flag (NNA bits 0-1, vib waveform bits 2-4)
* NNA: 00=note off, 01=note cut, 10=continue, 11=note fade * 187 u8 vibrato depth
* waveform: 0=sine, 1=ramp-down, 2=square, 3=random, 4=ramp-up (FT2) * 188 u8 vibrato rate
* 187 u8 vibrato depth (0..255 full range) * 189..190 u16 volume envelope SUSTAIN word (0b 0000_0sss_ss00b_eeeee)
* 188 u8 vibrato rate (0..255 full range — IT samplewise Vir) * 191..192 u16 panning envelope SUSTAIN word
* 189 u8 duplicate-check / action (IT-only — 0b 0000 aadd) * 193..194 u16 pitch/filter envelope SUSTAIN word
* dd = DCT (Duplicate Check Type) 0=off, 1=note, 2=sample, 3=instrument * 195 u8 duplicate-check / action (relocated from old offset 189)
* aa = DCA (Duplicate Check Action) 0=note cut, 1=note off, 2=note fade * bits 0-1 = DCT, bits 2-3 = DCA
* 190..191 byte[2] reserved * 196..255 reserved (60 bytes)
*/ */
data class TaudInst( data class TaudInst(
var index: Int, var index: Int,
var samplePtr: Int, // 32-bit sample bin offset var samplePtr: Int,
var sampleLength: Int, var sampleLength: Int,
var samplingRate: Int, // rate at MIDDLE_C var samplingRate: Int,
var samplePlayStart: Int, var samplePlayStart: Int,
var sampleLoopStart: Int, var sampleLoopStart: Int,
var sampleLoopEnd: Int, var sampleLoopEnd: Int,
var loopMode: Int, // byte 14, low 3 bits (bits 0-1: loop kind, bit 2: sustain) var loopMode: Int,
var volEnvSustain: Int, // bytes 15-16 (16-bit, see flag layout) var volEnvLoop: Int, // bytes 15-16 (LOOP word)
var panEnvSustain: Int, // bytes 17-18 var panEnvLoop: Int, // bytes 17-18
var pfEnvSustain: Int, // bytes 19-20 (pitch/filter) var pfEnvLoop: Int, // bytes 19-20
var instGlobalVolume: Int, // byte 171 var instGlobalVolume: Int,
var volEnvelopes: Array<TaudInstEnvPoint>, // 25 points var volEnvelopes: Array<TaudInstEnvPoint>,
var panEnvelopes: Array<TaudInstEnvPoint>, // 25 points var panEnvelopes: Array<TaudInstEnvPoint>,
var pfEnvelopes: Array<TaudInstEnvPoint>, // 25 points (pitch/filter) var pfEnvelopes: Array<TaudInstEnvPoint>,
var volumeFadeoutLow: Int, // byte 172 var volumeFadeoutLow: Int,
var fadeoutHigh: Int, // byte 173 (low nibble — 0b 0000 ffff) var fadeoutHigh: Int,
var volumeSwing: Int, // byte 174 var volumeSwing: Int,
var vibratoSpeed: Int, // byte 175 var vibratoSpeed: Int,
var vibratoSweep: Int, // byte 176 (FT2 ramp ticks) var vibratoSweep: Int,
var defaultPan: Int, // byte 177 var defaultPan: Int,
var pitchPanCentre: Int, // bytes 178-179 var pitchPanCentre: Int,
var pitchPanSeparation: Int, // byte 180 (signed) var pitchPanSeparation: Int,
var panSwing: Int, // byte 181 var panSwing: Int,
var defaultCutoff: Int, // byte 182 var defaultCutoff: Int,
var defaultResonance: Int, // byte 183 var defaultResonance: Int,
var sampleDetune: Int, // bytes 184-185 (signed 4096-TET stored as u16) var sampleDetune: Int,
var instrumentFlag: Int, // byte 186 (NNA + vibrato waveform) var instrumentFlag: Int,
var vibratoDepth: Int, // byte 187 (0..255 full range) var vibratoDepth: Int,
var vibratoRate: Int, // byte 188 (IT samplewise Vir) var vibratoRate: Int,
var dupCheckFlag: Int // byte 189 (DCT bits 0-1, DCA bits 2-3) var volEnvSustainWord: Int, // bytes 189-190 (SUSTAIN word)
var panEnvSustainWord: Int, // bytes 191-192
var pfEnvSustainWord: Int, // bytes 193-194
var dupCheckFlag: Int // byte 195 (relocated from 189)
) { ) {
constructor(index: Int) : this( constructor(index: Int) : this(
index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, index, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF,
@@ -3221,7 +3306,7 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) }, Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) }, Array(25) { TaudInstEnvPoint(0x80, ThreeFiveMiniUfloat(0)) },
0, 0, 0, 0, 0, 0x80, 0x5000, 0, 0, 0xFF, 0, 0, 0, 0, 0, 0, 0x80, 0x5000, 0, 0, 0xFF, 0,
0, 0, 0, 0, 0 0, 0, 0, 0, 0, 0, 0, 0
) )
/** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop: /** Sample-flag byte 14 bit 2 — when set, the sample loop is a sustain loop:
@@ -3240,8 +3325,8 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
/** Duplicate Check Action — 0=note cut, 1=note off, 2=note fade. */ /** Duplicate Check Action — 0=note cut, 1=note off, 2=note fade. */
val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03 val duplicateCheckAction: Int get() = (dupCheckFlag ushr 2) and 0x03
// Reserved padding at offsets 190..191 (2 bytes per instrument). // Reserved padding at offsets 196..255 (60 bytes per instrument).
private val reserved = ByteArray(2) private val reserved = ByteArray(60)
// Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region. // Funk repeat (S$Fx00) bit-mask — non-destructive XOR overlay across the loop region.
// Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact. // Lazily allocated; a 1-bit flips the byte, a 0-bit leaves it intact.
@@ -3294,12 +3379,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
13 -> sampleLoopEnd.ushr(8).toByte() 13 -> sampleLoopEnd.ushr(8).toByte()
14 -> (loopMode and 7).toByte() 14 -> (loopMode and 7).toByte()
15 -> volEnvSustain.toByte() 15 -> volEnvLoop.toByte()
16 -> volEnvSustain.ushr(8).toByte() 16 -> volEnvLoop.ushr(8).toByte()
17 -> panEnvSustain.toByte() 17 -> panEnvLoop.toByte()
18 -> panEnvSustain.ushr(8).toByte() 18 -> panEnvLoop.ushr(8).toByte()
19 -> pfEnvSustain.toByte() 19 -> pfEnvLoop.toByte()
20 -> pfEnvSustain.ushr(8).toByte() 20 -> pfEnvLoop.ushr(8).toByte()
in 21..70 -> envPointGet(volEnvelopes, 21, offset) in 21..70 -> envPointGet(volEnvelopes, 21, offset)
in 71..120 -> envPointGet(panEnvelopes, 71, offset) in 71..120 -> envPointGet(panEnvelopes, 71, offset)
@@ -3323,8 +3408,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
186 -> instrumentFlag.toByte() 186 -> instrumentFlag.toByte()
187 -> vibratoDepth.toByte() 187 -> vibratoDepth.toByte()
188 -> vibratoRate.toByte() 188 -> vibratoRate.toByte()
189 -> dupCheckFlag.toByte() 189 -> volEnvSustainWord.toByte()
in 190..191 -> reserved[offset - 190] 190 -> volEnvSustainWord.ushr(8).toByte()
191 -> panEnvSustainWord.toByte()
192 -> panEnvSustainWord.ushr(8).toByte()
193 -> pfEnvSustainWord.toByte()
194 -> pfEnvSustainWord.ushr(8).toByte()
195 -> dupCheckFlag.toByte()
in 196..255 -> reserved[offset - 196]
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
@@ -3350,12 +3441,12 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) } 13 -> { sampleLoopEnd = (sampleLoopEnd and 0x00ff) or (byte shl 8) }
14 -> { loopMode = byte and 7 } 14 -> { loopMode = byte and 7 }
15 -> { volEnvSustain = (volEnvSustain and 0xff00) or byte } 15 -> { volEnvLoop = (volEnvLoop and 0xff00) or byte }
16 -> { volEnvSustain = (volEnvSustain and 0x00ff) or (byte shl 8) } 16 -> { volEnvLoop = (volEnvLoop and 0x00ff) or (byte shl 8) }
17 -> { panEnvSustain = (panEnvSustain and 0xff00) or byte } 17 -> { panEnvLoop = (panEnvLoop and 0xff00) or byte }
18 -> { panEnvSustain = (panEnvSustain and 0x00ff) or (byte shl 8) } 18 -> { panEnvLoop = (panEnvLoop and 0x00ff) or (byte shl 8) }
19 -> { pfEnvSustain = (pfEnvSustain and 0xff00) or byte } 19 -> { pfEnvLoop = (pfEnvLoop and 0xff00) or byte }
20 -> { pfEnvSustain = (pfEnvSustain and 0x00ff) or (byte shl 8) } 20 -> { pfEnvLoop = (pfEnvLoop and 0x00ff) or (byte shl 8) }
in 21..70 -> envPointSet(volEnvelopes, 21, offset, byte) in 21..70 -> envPointSet(volEnvelopes, 21, offset, byte)
in 71..120 -> envPointSet(panEnvelopes, 71, offset, byte) in 71..120 -> envPointSet(panEnvelopes, 71, offset, byte)
@@ -3363,14 +3454,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
171 -> { instGlobalVolume = byte and 0xFF } 171 -> { instGlobalVolume = byte and 0xFF }
172 -> { volumeFadeoutLow = byte and 0xFF } 172 -> { volumeFadeoutLow = byte and 0xFF }
173 -> { fadeoutHigh = byte and 0x0F } // low nibble only (0b 0000 ffff) 173 -> { fadeoutHigh = byte and 0x0F }
174 -> { volumeSwing = byte and 0xFF } 174 -> { volumeSwing = byte and 0xFF }
175 -> { vibratoSpeed = byte and 0xFF } 175 -> { vibratoSpeed = byte and 0xFF }
176 -> { vibratoSweep = byte and 0xFF } 176 -> { vibratoSweep = byte and 0xFF }
177 -> { defaultPan = byte and 0xFF } 177 -> { defaultPan = byte and 0xFF }
178 -> { pitchPanCentre = (pitchPanCentre and 0xff00) or byte } 178 -> { pitchPanCentre = (pitchPanCentre and 0xff00) or byte }
179 -> { pitchPanCentre = (pitchPanCentre and 0x00ff) or (byte shl 8) } 179 -> { pitchPanCentre = (pitchPanCentre and 0x00ff) or (byte shl 8) }
180 -> { pitchPanSeparation = byte.toByte().toInt() } // signed 180 -> { pitchPanSeparation = byte.toByte().toInt() }
181 -> { panSwing = byte and 0xFF } 181 -> { panSwing = byte and 0xFF }
182 -> { defaultCutoff = byte and 0xFF } 182 -> { defaultCutoff = byte and 0xFF }
183 -> { defaultResonance = byte and 0xFF } 183 -> { defaultResonance = byte and 0xFF }
@@ -3379,8 +3470,14 @@ class AudioAdapter(val vm: VM) : PeriBase(VM.PERITYPE_SOUND) {
186 -> { instrumentFlag = byte and 0xFF } 186 -> { instrumentFlag = byte and 0xFF }
187 -> { vibratoDepth = byte and 0xFF } 187 -> { vibratoDepth = byte and 0xFF }
188 -> { vibratoRate = byte and 0xFF } 188 -> { vibratoRate = byte and 0xFF }
189 -> { dupCheckFlag = byte and 0x0F } // DCT (bits 0-1) + DCA (bits 2-3) 189 -> { volEnvSustainWord = (volEnvSustainWord and 0xff00) or byte }
in 190..191 -> { reserved[offset - 190] = byte.toByte() } 190 -> { volEnvSustainWord = (volEnvSustainWord and 0x00ff) or (byte shl 8) }
191 -> { panEnvSustainWord = (panEnvSustainWord and 0xff00) or byte }
192 -> { panEnvSustainWord = (panEnvSustainWord and 0x00ff) or (byte shl 8) }
193 -> { pfEnvSustainWord = (pfEnvSustainWord and 0xff00) or byte }
194 -> { pfEnvSustainWord = (pfEnvSustainWord and 0x00ff) or (byte shl 8) }
195 -> { dupCheckFlag = byte and 0x0F }
in 196..255 -> { reserved[offset - 196] = byte.toByte() }
else -> throw InternalError("Bad offset $offset") else -> throw InternalError("Bad offset $offset")
} }
} }

1376
xm2taud.py Normal file

File diff suppressed because it is too large Load Diff