#!/usr/bin/env python3 """it2taud.py — Convert ImpulseTracker (.it) to TSVM Taud (.taud) Usage: python3 it2taud.py input.it output.taud [-v] [--no-decompress] Limits: - Up to 20 IT channels (excess dropped; hard error if chunk count × channel count > 4095). - IT patterns with >64 rows are split into ⌈rows/64⌉ consecutive Taud patterns. Pattern-loop (SBx) crossing a chunk boundary is warned; B/C effects are remapped to new cue indices. - IT2.14/IT2.15 compressed samples are decoded unless --no-decompress. - IT instrument volume/pan/pitch-or-filter envelopes (up to 25 nodes, sustain & env loops) are converted directly to the new Taud 192-byte instrument format. NNA actions are ignored. Each IT instrument resolves to its C-5 canonical sample. - Pitch and filter envelopes are emitted natively (engine-evaluated); auto-vibrato, fadeout, PPS/PPC, default pan, volume/pan swing, and initial filter cutoff/resonance are forwarded to the engine via the new instrument fields. - AdLib / OPL instruments are skipped. Effect support: A-Z dispatch per TAUD_NOTE_EFFECTS.md. IT-specific: Cxx is binary (not BCD like ST3). V scales by ×2 (IT 0-128 → Taud 0-255). X is the full 8-bit IT pan. Y panbrello nibble-repeats. Z (MIDI macro) dropped. S6x fine-pattern-delay forwarded directly to Taud S$6x. SAx high-offset dropped. S7x NNA / past-note / envelope toggles forwarded directly (IT sub-codes match Taud one-to-one). Vol-column pitch-slide / tone-porta / vibrato sub- commands forwarded to main effect slot when empty; dropped otherwise. Per-effect private memory cohorts resolved eagerly (D/K/L share; E/F optionally linked with G per flag bit 5). """ import argparse import copy import struct import sys from taud_common import ( set_verbose, vprint, TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY, SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE, SAMPLE_LEN_LIMIT, PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES, NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4, TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I, TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y, SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T, EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z, J_SEMI_TABLE, d_arg_to_col, resample_linear, rescale_offset_effects_per_slot, encode_cue, deduplicate_patterns, normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, build_project_data, detect_subsongs, ) # ── IT constants ───────────────────────────────────────────────────────────── IT_MAGIC = b'IMPM' IT_SMP_MAGIC = b'IMPS' IT_INST_MAGIC = b'IMPI' IT_NOTE_OFF = 255 IT_NOTE_CUT = 254 IT_NOTE_FADE = 246 # treated as key-off IT_ORD_END = 255 IT_ORD_SKIP = 254 IT_FLAG_STEREO = 0x01 IT_FLAG_USE_INST = 0x04 IT_FLAG_LINEAR = 0x08 IT_FLAG_OLD_EFFECTS = 0x10 IT_FLAG_LINK_GEF = 0x20 # link G memory with E/F # Sample flags (Flg byte at IMPS+0x12) IT_SMP_ASSOC = 0x01 IT_SMP_16BIT = 0x02 IT_SMP_STEREO = 0x04 IT_SMP_COMPRESSED = 0x08 IT_SMP_LOOP = 0x10 IT_SMP_SUS_LOOP = 0x20 IT_SMP_PINGPONG = 0x40 IT_SMP_PINGPONG_SUS = 0x80 # Vol-column byte ranges (inclusive lower, inclusive upper) VC_VOL_LO, VC_VOL_HI = 0, 64 VC_FVUP_LO, VC_FVUP_HI = 65, 74 # fine vol up A (value = vc-64, 1..10) VC_FVDN_LO, VC_FVDN_HI = 75, 84 # fine vol down B (value = vc-74, 1..10) VC_VUP_LO, VC_VUP_HI = 85, 94 # vol slide up C (value = vc-84, 1..10) VC_VDN_LO, VC_VDN_HI = 95, 104 # vol slide dn D (value = vc-94, 1..10) VC_PDN_LO, VC_PDN_HI = 105, 114 # pitch dn E (value = vc-104, 1..10) VC_PUP_LO, VC_PUP_HI = 115, 124 # pitch up F (value = vc-114, 1..10) VC_PAN_LO, VC_PAN_HI = 128, 192 # set pan 0..64 (value = vc-128) VC_TPORTA_LO, VC_TPORTA_HI = 193, 202 # tone porta G VC_VIB_LO, VC_VIB_HI = 203, 212 # vibrato H (depth 1..10) VC_TPORTA_TABLE = (0, 1, 4, 8, 16, 32, 64, 96, 128, 255) # IT effects that recall last non-zero arg (per-effect-private, with cohort exceptions). # V (Set Global Volume) recalls in IT compat mode — the first V $00 resolves to the # header's global_vol, not literal 0. Without this, songs starting with V $00 silence. IT_MEM_EFFECTS = frozenset({ EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J, EFF_K, EFF_L, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T, EFF_U, EFF_X, EFF_Y, # EFF_V excluded: V00 means literal 0 in IT, not recall. # EFF_W excluded: Taud engine handles W recall natively (same private-slot semantics). }) # ── Taud constants (it-specific) ────────────────────────────────────────────── SIGNATURE = b'it2taud/TSVM ' # 14 bytes # ── IT header parser ────────────────────────────────────────────────────────── class ITHeader: __slots__ = ('title', 'ord_count', 'ins_count', 'smp_count', 'pat_count', 'cwt', 'cmwt', 'flags', 'special', 'global_vol', 'mix_vol', 'initial_speed', 'initial_tempo', 'pan_sep', 'linear_slides', 'use_instruments', 'link_gef', 'old_effects', 'chnl_pan', 'chnl_vol', 'order_list', 'ins_ptrs', 'smp_ptrs', 'pat_ptrs') def parse_it_header(data: bytes) -> ITHeader: if len(data) < 0xC0: sys.exit("error: file too short to be IT") if data[0:4] != IT_MAGIC: sys.exit("error: not an IT file (bad magic)") h = ITHeader() h.title = data[0x04:0x1E].rstrip(b'\x00').decode('latin-1', errors='replace') h.ord_count = struct.unpack_from(' int: """Wrap to signed int8 range (C int8 overflow behaviour).""" v &= 0xFF return v if v < 128 else v - 256 def _wrap16(v: int) -> int: """Wrap to signed int16 range.""" v &= 0xFFFF return v if v < 32768 else v - 65536 def _sign_extend(val: int, bits: int) -> int: sign_bit = 1 << (bits - 1) return (val & (sign_bit - 1)) - (val & sign_bit) def _it214_decompress_block(payload: bytes, num_samples: int, is_16bit: bool, is_it215: bool) -> list: """Decode one compressed block payload. Returns list of signed int output samples. Algorithm from libxmp / schism source: 8-bit: init_width=9, short escape reads 3 bits, mid border=(1<>= n bit_cnt -= n return val if is_16bit: init_width = 17 range_count = 16 # escape range size in mid form border_sub = 8 # = range_count / 2; centres escape range on signed midpoint escape_bits = 4 # bits to read in short-form escape else: init_width = 9 range_count = 8 border_sub = 4 escape_bits = 3 width = init_width d1 = d2 = 0 out = [] n = 0 mask = (1 << (init_width - 1)) - 1 # 0xFF (8-bit) or 0xFFFF (16-bit) while n < num_samples: v = read_bits(width) is_data = False if width < 7: # Mode A (short): single escape code at v == 1<<(width-1). if v == (1 << (width - 1)): new_w = read_bits(escape_bits) + 1 width = new_w if new_w < width else new_w + 1 # skip-self continue # Else: data, sign-extend from `width` bits. delta = _sign_extend(v, width) is_data = True elif width < init_width: # Mode B (mid): `range_count` escape codes occupy values (border, border+range_count]. # The encoder simply does NOT emit data values that would collide with this slot — # it widens first. So values *above* the escape range are sign-extended verbatim, # not collapsed. Reference: schismtracker fmt/compression.c:103-127 and # MilkyTracker XModule.cpp:629-640. # border = (mask >> (init_width-width)) - border_sub, where border_sub # = range_count / 2. # 8-bit: width=7 → border=63-4=59, width=8 → border=127-4=123 # 16-bit: width=7..16 with border_sub=8. border = (mask >> (init_width - width)) - border_sub if border < v <= border + range_count: new_w = v - border width = new_w if new_w < width else new_w + 1 # skip-self continue delta = _sign_extend(v, width) is_data = True else: # Mode C (full): top bit (bit init_width-1) signals width change. top_bit = 1 << (init_width - 1) if v & top_bit: width = (v & (top_bit - 1)) + 1 continue # Else: data is (init_width-1) bits wide, sign-extend from there. delta = _sign_extend(v, init_width - 1) is_data = True if is_data: if is_16bit: d1 = _wrap16(d1 + delta) if is_it215: d2 = _wrap16(d2 + d1) out.append(d2) else: out.append(d1) else: d1 = _wrap8(d1 + delta) if is_it215: d2 = _wrap8(d2 + d1) out.append(d2) else: out.append(d1) n += 1 return out def _it214_decompress_channel(blob: bytes, pos: int, num_samples: int, is_16bit: bool, is_it215: bool) -> tuple: """Decode one channel of IT2.14/IT2.15 compressed data. Returns (raw PCM bytes, next position after consumed blocks).""" block_size = 0x4000 if is_16bit else 0x8000 out_samples = [] while len(out_samples) < num_samples: if pos + 2 > len(blob): break block_len = struct.unpack_from(' bytes: """Decode IT2.14/IT2.15 compressed sample data. Returns raw PCM bytes (signed). For stereo samples, returns the left channel block followed by the right channel block (matching IT's on-disk SF_SS layout).""" left, pos = _it214_decompress_channel(blob, smp_offset, num_samples, is_16bit, is_it215) if not is_stereo: return left right, _ = _it214_decompress_channel(blob, pos, num_samples, is_16bit, is_it215) return left + right # ── IT sample parser ────────────────────────────────────────────────────────── class ITSample: __slots__ = ('name', 'filename', 'gv', 'vol', 'flags', 'cvt', 'dfp', 'c5_speed', 'length', 'loop_beg', 'loop_end', 'sus_beg', 'sus_end', 'smp_point', 'has_loop', 'is_16bit', 'is_stereo', 'is_compressed', 'is_signed', 'sample_data', 'av_speed', 'av_depth', 'av_sweep', 'av_wave') def parse_samples(data: bytes, h: ITHeader, decompress: bool) -> list: samples = [] # IT2.15 compression is signaled PER-SAMPLE via cvt bit 2 (0x04), not globally # via the file's cwt. Reference: OpenMPT ITTools.cpp, libxmp it_load.c. for i, ptr in enumerate(h.smp_ptrs): if ptr == 0 or ptr + 0x50 > len(data): vprint(f" warning: sample {i+1} pointer {ptr:#x} out of range, skipping") samples.append(None) continue if data[ptr:ptr+4] != IT_SMP_MAGIC: vprint(f" warning: sample {i+1} at {ptr:#x} has bad magic, skipping") samples.append(None) continue s = ITSample() s.filename = data[ptr+0x04:ptr+0x10].rstrip(b'\x00').decode('latin-1', errors='replace') s.gv = data[ptr+0x11] s.flags = data[ptr+0x12] s.vol = data[ptr+0x13] s.name = data[ptr+0x14:ptr+0x2E].rstrip(b'\x00').decode('latin-1', errors='replace') s.cvt = data[ptr+0x2E] s.dfp = data[ptr+0x2F] s.length = struct.unpack_from(' 0 if has_data: if s.is_compressed: if not decompress: vprint(f" warning: '{s.name}' is IT2.14 compressed, --no-decompress → silent") else: try: is_it215 = bool(s.cvt & 0x04) raw = it214_decompress(data, s.smp_point, s.length, s.is_16bit, is_it215, s.is_stereo) s.sample_data = normalise_sample(raw, True, s.is_16bit, s.is_stereo, s.name) s.length = len(s.sample_data) s.loop_beg = min(s.loop_beg, s.length) s.loop_end = min(s.loop_end, s.length) s.sus_beg = min(s.sus_beg, s.length) s.sus_end = min(s.sus_end, s.length) except Exception as e: vprint(f" warning: '{s.name}' decompression failed ({e}), silent") else: byte_len = s.length * (2 if s.is_16bit else 1) * (2 if s.is_stereo else 1) if s.smp_point + byte_len > len(data): vprint(f" warning: '{s.name}' sample data out of range, zeroing") s.sample_data = bytes(min(s.length, 256)) else: raw = data[s.smp_point : s.smp_point + byte_len] s.sample_data = normalise_sample(raw, s.is_signed, s.is_16bit, s.is_stereo, s.name) s.length = len(s.sample_data) s.loop_beg = min(s.loop_beg, s.length) s.loop_end = min(s.loop_end, s.length) s.sus_beg = min(s.sus_beg, s.length) s.sus_end = min(s.sus_end, s.length) samples.append(s) return samples # ── IT instrument parser ────────────────────────────────────────────────────── class ITInstrument: __slots__ = ('name', 'dfp', 'gv', 'canonical_sample', 'canonical_volume', 'vol_envelope', 'pan_envelope', 'pf_envelope', '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', 'dct', 'dca') # vol_envelope / pan_envelope / pf_envelope: list of 25 (value, minifloat_idx) tuples, or None # *_env_sustain: int (16-bit, 0b 0ut sssss pcb eeeee), 0 = no envelope # pf_is_filter: bool — pf envelope mode (False = pitch, True = filter) # ifc / ifr : initial filter cutoff / resonance (0..127, 0 if not set) # fadeout : 0..1024 (IT FadeOut field; doubled to 0..2048 when written to Taud's 12-bit field) # pps / ppc : pitch-pan separation (signed -32..+32) and centre note (0..119) # rv / rp : random volume swing (0..100) / random pan swing (0..64) # nna : new note action (IT 0=cut, 1=continue, 2=note off, 3=note fade) def parse_instruments(data: bytes, h: ITHeader) -> list: insts = [] for i, ptr in enumerate(h.ins_ptrs): if ptr == 0 or ptr + 0x48 > len(data): insts.append(None); continue if data[ptr:ptr+4] != IT_INST_MAGIC: insts.append(None); continue inst = ITInstrument() inst.name = data[ptr+0x20:ptr+0x3A].rstrip(b'\x00').decode('latin-1', errors='replace') # NNA at IMPI+0x11 (new format). 0=cut, 1=continue, 2=note off, 3=note fade. inst.nna = data[ptr + 0x11] & 0x03 # DCT (Duplicate Check Type) and DCA (Duplicate Check Action), per Schism iti.c:80-94. # DCT: 0=off, 1=note, 2=sample, 3=instrument. # DCA: 0=note cut, 1=note off, 2=note fade. inst.dct = data[ptr + 0x12] & 0x03 inst.dca = data[ptr + 0x13] & 0x03 inst.fadeout = struct.unpack_from(' tuple: """Parse one IT envelope block (vol / pan / pitch / filter) into up to 25 Taud (value, minifloat_idx) points + LOOP word + SUSTAIN word. Returns (points_list, loop_word, sustain_word). 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: 'vol' — IT 0..64 → Taud 0..63 'pan' — IT -32..+32 → Taud 0..255 (0x80 = centre) 'pitch' — IT -32..+32 → Taud 0..255 (0x80 = unity) 'filter' — IT -32..+32 → Taud 0..255 (0x80 = unity cutoff) Word layout (terranmon.txt:2049+ / 2114+): LOOP word: 0b 00P0_0sss_ssXcb_eeeee (X = 'p'/'m' for pan/pf, 0 for vol) SUSTAIN word: 0b 0000_0sss_ss00b_eeeee bits 12..8 = start index, bits 4..0 = end index bit 13 = P (envelope present; gates pan/pf evaluation in the engine) bit 7 = p (pan: use default pan) / m (pf: pitch=0/filter=1) / 0 (vol) bit 6 = c (envelope carry — placed in the LOOP word) bit 5 = b (enable that region) """ if env_ptr + 82 > len(data): return None, 0, 0 flags = data[env_ptr] if not (flags & 0x01): return None, 0, 0 # envelope not enabled num_nodes = max(1, min(data[env_ptr + 1], 25)) it_lpb = data[env_ptr + 2] it_lpe = data[env_ptr + 3] it_slb = data[env_ptr + 4] it_sle = data[env_ptr + 5] has_env_loop = bool(flags & 0x02) has_sus_loop = bool(flags & 0x04) carry = bool(flags & 0x08) is_filter = bool(flags & 0x80) and kind in ('pitch', 'filter') # Read IT nodes: (int8 value, uint16 tick_pos LE) nodes = [] for n in range(num_nodes): nptr = env_ptr + 6 + n * 3 if nptr + 2 >= len(data): break val = struct.unpack_from('b', data, nptr)[0] tick = struct.unpack_from(' int: if kind == 'vol': return min(63, max(0, round(it_val * 63 / 64))) 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))) pad_value = (63 if kind == 'vol' else 0x80) # Build Taud envelope points with delta-time minifloats. We keep all # IT nodes verbatim (up to 25), so loop indices stay valid. points = [] for k in range(25): if k < len(nodes): val, tick = nodes[k] taud_val = _to_taud_val(val) if k < len(nodes) - 1: _, next_tick = nodes[k + 1] delta_sec = max(0.0, (next_tick - tick) / ticks_per_sec) mf_idx = nearest_minifloat(delta_sec) else: mf_idx = 0 else: taud_val = points[-1][0] if points else pad_value mf_idx = 0 points.append((taud_val, mf_idx)) # Build LOOP word (offsets 15/17/19) and SUSTAIN word (offsets 189/191/193). # 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. # P (bit 13) marks the envelope as present in source, regardless of LOOP/ # SUSTAIN enable. We reach this point only when the IT envelope flag bit 0 # is set (handled at function top), so P is unconditionally set here. loop_word = 0x2000 # P: envelope present 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: loop_word |= 0x0040 # c carry — kept in LOOP word if is_filter: loop_word |= 0x0080 # m filter-mode (pf only) 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 ───────────────────────────────────────────────────────── class ITRow: __slots__ = ('note', 'inst', 'vol', 'effect', 'effect_arg', 'volcol', 'pan_set', 'aux_effect') def __init__(self): self.note = -1 # -1=empty, 0-119=pitch, IT_NOTE_* self.inst = 0 # 1-based self.vol = -1 # -1=not set self.effect = 0 self.effect_arg = 0 self.volcol = -1 # raw IT vol-col byte, -1 = not set self.pan_set = None # 0..63 from vol-col, or None self.aux_effect = None # (cmd,arg) from vol-col, or None def _parse_one_pattern(data: bytes, ptr: int) -> tuple: """Returns (grid: list[64_channels][rows], row_count: int).""" if ptr == 0 or ptr + 8 > len(data): return [[ITRow() for _ in range(PATTERN_ROWS)] for _ in range(64)], PATTERN_ROWS data_len = struct.unpack_from('= end: break last_mask[ch] = data[pos]; pos += 1 mask = last_mask[ch] cell = grid[ch][row] if mask & 0x01: last_note[ch] = data[pos]; pos += 1 if mask & 0x02: last_inst[ch] = data[pos]; pos += 1 if mask & 0x04: last_volcol[ch] = data[pos]; pos += 1 if mask & 0x08: last_cmd[ch] = data[pos]; pos += 1 last_arg[ch] = data[pos]; pos += 1 if mask & 0x11: cell.note = last_note[ch] if mask & 0x22: cell.inst = last_inst[ch] if mask & 0x44: cell.volcol = last_volcol[ch] if mask & 0x88: cell.effect = last_cmd[ch] cell.effect_arg = last_arg[ch] return grid, row_count def parse_patterns(data: bytes, h: ITHeader) -> list: """Returns list of (grid, row_count) tuples.""" patterns = [] for ptr in h.pat_ptrs: grid, rows = _parse_one_pattern(data, ptr) patterns.append((grid, rows)) return patterns # ── Note encoding (IT linear 0-119 → Taud pitch units) ─────────────────────── def encode_note_it(it_note: int) -> int: if it_note == IT_NOTE_OFF or it_note == IT_NOTE_FADE: return NOTE_KEYOFF if it_note == IT_NOTE_CUT: return NOTE_CUT if 0 <= it_note <= 119: # IT middle C is C-5 (note 60); Taud reference is C-4 (TAUD_C4 = 0x5000). # IT C-5 anchors to Taud C-4, so offset = it_note - 60. semis = it_note - 60 val = round(TAUD_C4 + semis * 4096 / 12) return max(0x20, min(0xFFFF, val)) return NOTE_NOP # ── Vol-column decoder ──────────────────────────────────────────────────────── def decode_volcol(vc: int, recall_volslide: int = 0): """Return (vol_sel, vol_value, pan_set, aux_effect) or None for each field. IT vol-col x=0 means "recall last value" for the relevant memory cohort (Schism player/effects.c:2097-2137 — Ax/Bx/Cx/Dx share `mem_vc_volslide`, a per-channel slot separate from the main column's D memory; Ex/Fx share `mem_pitchslide` with the main effect column; Gx shares `mem_portanote` with main G; Hx uses the channel's vibrato state). For pitch/porta/ vibrato we emit Taud E/F/G/H with arg=0 so Taud's own private (E/F-cohort, G, H/U-cohort) memory recalls naturally. For volume slides Taud has no recall in the volume column, so the converter passes `recall_volslide` (the per-channel A/B/C/D shared memory tracked by build_pattern_it) and substitutes it when x=0. """ if vc < 0: # not set return SEL_FINE, 0, None, None if vc <= VC_VOL_HI: return SEL_SET, min(vc, 0x3F), None, None if VC_FVUP_LO <= vc <= VC_FVUP_HI: mag = vc - VC_FVUP_LO # 0..9 — Schism fmt/it.c:234 if mag == 0: mag = recall_volslide if mag == 0: return SEL_FINE, 0, None, None return SEL_FINE, (mag & 0x1F) | 0x20, None, None # fine up if VC_FVDN_LO <= vc <= VC_FVDN_HI: mag = vc - VC_FVDN_LO if mag == 0: mag = recall_volslide if mag == 0: return SEL_FINE, 0, None, None return SEL_FINE, mag & 0x1F, None, None # fine down if VC_VUP_LO <= vc <= VC_VUP_HI: mag = vc - VC_VUP_LO if mag == 0: mag = recall_volslide if mag == 0: return SEL_FINE, 0, None, None return SEL_UP, mag, None, None if VC_VDN_LO <= vc <= VC_VDN_HI: mag = vc - VC_VDN_LO if mag == 0: mag = recall_volslide if mag == 0: return SEL_FINE, 0, None, None return SEL_DOWN, mag, None, None if VC_PDN_LO <= vc <= VC_PDN_HI: # IT vol-col Ex slides pitch down by 4×e raw IT period units (Schism # player/effects.c:294-298). e=0 recalls mem_pitchslide; emit # E $0000 so Taud's E/F-cohort memory supplies the value. e = vc - VC_PDN_LO units = e * 4 return SEL_FINE, 0, None, (EFF_E, units & 0xFF) if VC_PUP_LO <= vc <= VC_PUP_HI: f = vc - VC_PUP_LO units = f * 4 return SEL_FINE, 0, None, (EFF_F, units & 0xFF) if VC_PAN_LO <= vc <= VC_PAN_HI: pan64 = vc - VC_PAN_LO # 0..64 pan6 = min(0x3F, round(pan64 * 63 / 64)) return SEL_FINE, 0, pan6, None if VC_TPORTA_LO <= vc <= VC_TPORTA_HI: # IT Gg tone-porta speed: VC_TPORTA_TABLE[0]=0 → g=0 recalls # mem_portanote. Emit G $0000; Taud's private G memory recalls. g = vc - VC_TPORTA_LO spd = VC_TPORTA_TABLE[g] return SEL_FINE, 0, None, (EFF_G, spd & 0xFF) if VC_VIB_LO <= vc <= VC_VIB_HI: # IT Hh sets vibrato depth (low nybble only) and runs vibrato with # the channel's current vibrato_speed (Schism player/effects.c:391-398 # via fx_vibrato). h=0 keeps the existing depth; emit H $0000 so # Taud's H/U cohort memory supplies both speed and depth. h = vc - VC_VIB_LO return SEL_FINE, 0, None, (EFF_H, h & 0x0F) return SEL_FINE, 0, None, None # ── Effect translator ───────────────────────────────────────────────────────── def encode_effect_it(cmd: int, arg: int, ch: int = 0, row: int = 0, amiga_mode: bool = False) -> tuple: """Return (taud_op, taud_arg16, vol_override, pan_override). Differs from s3m2taud.encode_effect in: - Cxx: binary row number, not BCD - V: IT global vol 0-128 scaled ×2 - X: IT full 8-bit pan → 6-bit - S6x: fine pattern delay forwarded; S7x forwarded; SAx/SFx dropped amiga_mode mirrors the inverse of the IT ``linear_slides`` flag. When set, E/F coarse pitch-slide arguments are emitted as raw IT period units (the engine applies them directly in period space); when clear they are quantised to 4096-TET units via ``round(× 64/3)``. Fine/extra-fine slides and tone portamento (G) are always linear regardless of mode. """ if cmd == 0: return (TOP_NONE, 0, None, None) if cmd == EFF_A: if arg == 0: return (TOP_NONE, 0, None, None) return (TOP_A, (arg & 0xFF) << 8, None, None) if cmd == EFF_B: return (TOP_B, arg & 0xFF, None, None) if cmd == EFF_C: # IT stores binary (not BCD like ST3) row_num = arg & 0xFF if row_num >= PATTERN_ROWS: row_num = 0 return (TOP_C, row_num & 0xFF, None, None) if cmd == EFF_D: return (TOP_D, (arg & 0xFF) << 8, None, None) if cmd in (EFF_E, EFF_F): # Coarse: 1/16 semitone = 64/3 Taud units in linear mode; raw IT period # units in Amiga mode (engine consumes them in period space). # Fine/extra-fine (Exx with hi ∈ {E,F}): 1/64 semitone = 16/3 Taud units # in linear mode; raw IT period units in Amiga mode (engine consumes # them in period space, applied once per row at tick 0). op = TOP_E if cmd == EFF_E else TOP_F hi = (arg >> 4) & 0xF lo = arg & 0xF if hi in (0xE, 0xF) and lo > 0: if amiga_mode: return (op, 0xF000 | (lo & 0xFFF), None, None) return (op, 0xF000 | (round(lo * 16 / 3) & 0xFFF), None, None) if amiga_mode: return (op, arg & 0xFFFF, None, None) return (op, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd == EFF_G: return (TOP_G, round(arg * 64 / 3) & 0xFFFF, None, None) if cmd in (EFF_H, EFF_I, EFF_R, EFF_U): op = {EFF_H: TOP_H, EFF_I: TOP_I, EFF_R: TOP_R, EFF_U: TOP_U}[cmd] hi = (arg >> 4) & 0xF lo = arg & 0xF return (op, ((hi * 0x11) << 8) | (lo * 0x11), None, None) if cmd == EFF_J: hi_semi = (arg >> 4) & 0xF lo_semi = arg & 0xF return (TOP_J, (J_SEMI_TABLE[hi_semi] << 8) | J_SEMI_TABLE[lo_semi], None, None) if cmd == EFF_K: # K = vibrato continuation + vol slide; emitted verbatim. IT's D/K/L # shared cohort is already resolved upstream by resolve_it_recalls. return (TOP_K, (arg & 0xFF) << 8, None, None) if cmd == EFF_L: # L = tone-porta continuation + vol slide; emitted verbatim. return (TOP_L, (arg & 0xFF) << 8, None, None) if cmd == EFF_M: # M = set channel volume; literal byte (no recall). Clamp IT $40 → $3F. return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None) if cmd == EFF_N: # N = channel volume slide; D-style encoding. return (TOP_N, (arg & 0xFF) << 8, None, None) if cmd == EFF_O: return (TOP_O, (arg & 0xFF) << 8, None, None) if cmd == EFF_P: # P = channel panning slide; D-style encoding (low nib = right, high nib = left). return (TOP_P, (arg & 0xFF) << 8, None, None) if cmd == EFF_Q: return (TOP_Q, (arg & 0xFF) << 8, None, None) if cmd == EFF_S: sub = (arg >> 4) & 0xF val = arg & 0xF if sub in (0x1, 0x2, 0x3, 0x4, 0xB, 0xC, 0xD, 0xE): return (TOP_S, (sub << 12) | (val << 8), None, None) if sub == 0x5: return (TOP_S, 0x5000 | (val << 8), None, None) if sub == 0x8: # IT S8x: 4-bit → nibble-repeat into 8-bit SEL_SET pan pan8 = (val << 4) | val return (TOP_S, 0x8000 | pan8, None, None) if sub == 0x6: # IT S6x = fine pattern delay (extends row by x ticks) — maps directly. return (TOP_S, 0x6000 | (val << 8), None, None) if sub == 0x7: # NNA / past-note / envelope on-off — IT S7x maps directly to Taud S $7x00 # (same sub-code table). No payload to translate. return (TOP_S, 0x7000 | (val << 8), None, None) if sub == 0x9: return (TOP_NONE, 0, None, None) # sound control — drop silently if sub == 0xA: vprint(f" dropped SA{val:X} (high offset) at ch{ch} row{row}") return (TOP_NONE, 0, None, None) if sub == 0xF: vprint(f" dropped SF{val:X} (MIDI macro) at ch{ch} row{row}") return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None) if cmd == EFF_T: if arg >= 0x20: return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None) return (TOP_T, arg & 0xFF, None, None) if cmd == EFF_V: # IT global vol is 0-128; Taud uses 0-255 → ×2 taud_v = min(arg * 2, 0xFF) return (TOP_V, (taud_v & 0xFF) << 8, None, None) if cmd == EFF_W: # W$xy: same nibble-pair layout as D, passed in the high byte. return (TOP_W, (arg & 0xFF) << 8, None, None) if cmd == EFF_X: return (TOP_S, 0x8000 | (arg & 0xFF), None, None) if cmd == EFF_Y: hi = (arg >> 4) & 0xF lo = arg & 0xF return (TOP_Y, ((hi * 0x11) << 8) | (lo * 0x11), None, None) if cmd == EFF_Z: vprint(f" dropped Z{arg:02X} (MIDI macro) at ch{ch} row{row}") return (TOP_NONE, 0, None, None) return (TOP_NONE, 0, None, None) # ── IT recall resolution ────────────────────────────────────────────────────── def resolve_it_recalls(patterns_rows: list, order_list: list, num_channels: int, link_gef: bool, old_effects: bool = False) -> None: """Walk in order, resolve zero-arg recalls per-effect-per-channel. IT effect memory groups: - D / K / L: shared vol-slide cohort - E / F (/ G when link_gef): shared pitch-slide cohort - G: own slot (or part of EF cohort when link_gef) - All others: private slots old_effects=True (IT_FLAG_OLD_EFFECTS): E00/F00 are ST3-style stops — they do NOT recall and are suppressed to TOP_NONE. All other effects still recall normally even in old_effects mode. V and W are excluded from IT_MEM_EFFECTS and are not resolved here: V00 in IT means literal 0 (not recall); W recall is handled natively by the Taud engine's private W memory slot. """ # last_mem[ch][eff_key] = last_non_zero_arg # eff_key: integer 1-26 for most effects; we merge cohorts by normalising. last_mem = [{} for _ in range(num_channels)] # Effects that stop rather than recall when arg=0 in old_effects mode (ST3 compat). # E/F: pitch slide stop. J: arpeggio stop (J00 = return to normal pitch in ST3). OLD_EFF_STOPS = frozenset({EFF_E, EFF_F, EFF_J}) def cohort_key(cmd): if cmd in (EFF_D, EFF_K, EFF_L): return EFF_D # vol-slide cohort if link_gef and cmd in (EFF_E, EFF_F, EFF_G): return EFF_E # EFG cohort if not link_gef and cmd in (EFF_E, EFF_F): return EFF_E # EF cohort return cmd for order in order_list: if order >= IT_ORD_END: break if order >= len(patterns_rows): continue grid, rows = patterns_rows[order] for r in range(rows): for ch in range(num_channels): if ch >= len(grid): continue cell = grid[ch][r] if cell.effect not in IT_MEM_EFFECTS: continue key = cohort_key(cell.effect) if cell.effect_arg == 0: if old_effects and cell.effect in OLD_EFF_STOPS: # E00/F00 in old_effects = stop slide — suppress entirely. # Taud's E $0000 also recalls, so convert to no-op here. cell.effect = 0 else: cell.effect_arg = last_mem[ch].get(key, 0) else: last_mem[ch][key] = cell.effect_arg # ── Pattern row-chunk splitter ──────────────────────────────────────────────── def split_patterns(patterns_rows: list): """ Returns (chunks, chunk_map, chunk_lens). 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_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 = [] chunk_map = [] chunk_lens = [] for pi, (grid, rows) in enumerate(patterns_rows): if rows == 0: chunk_map.append([]) continue n_chunks = (rows + PATTERN_ROWS - 1) // PATTERN_ROWS if n_chunks > 1: vprint(f" pattern {pi}: {rows} rows → {n_chunks} chunks") pat_chunks = [] for k in range(n_chunks): r0 = k * PATTERN_ROWS r1 = min(r0 + PATTERN_ROWS, rows) 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 = [] for ch in range(64): ch_rows = [] src = grid[ch] if ch < len(grid) else [] for ri in range(PATTERN_ROWS): sr = r0 + ri if sr < r1 and ri < len(src): ch_rows.append(src[sr]) else: ch_rows.append(ITRow()) chunk_grid.append(ch_rows) idx = len(chunks) chunks.append(chunk_grid) chunk_lens.append(chunk_len) pat_chunks.append(idx) chunk_map.append(pat_chunks) return chunks, chunk_map, chunk_lens def _remap_bc_effects(chunks: list, chunk_map: list, order_list: list, it_ord_to_taud_cue: dict, num_channels: int, *, default_target: int = None, warn_label: str = '', chunk_indices=None) -> None: """Rewrite B (position-jump) effects using remapped order indices. B effects are rewritten to point to the first chunk of the target IT order. C effects (pattern break) need no special handling: each 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. `default_target` (multi-song): when a Bxx points to an order outside `it_ord_to_taud_cue` (a cross-subsong jump), rewrite to this cue index instead of preserving the literal target. Set to 0 to make cross-song jumps loop the subsong; leave None for legacy behaviour. `chunk_indices`: optional iterable; when provided, only these chunks are visited. Used by multi-song to skip unreferenced chunks (avoids spurious cross-song warnings on chunks that won't be emitted). """ crossings = 0 iter_indices = (chunk_indices if chunk_indices is not None else range(len(chunks))) for ci in iter_indices: chunk_grid = chunks[ci] for ch in range(num_channels): if ch >= len(chunk_grid): continue for row in chunk_grid[ch]: if row.effect == EFF_B: it_tgt = row.effect_arg if it_tgt in it_ord_to_taud_cue: row.effect_arg = it_ord_to_taud_cue[it_tgt] & 0xFF elif default_target is not None: crossings += 1 row.effect_arg = default_target & 0xFF else: row.effect_arg = it_tgt & 0xFF if crossings and warn_label: vprint(f" warning: {warn_label}: {crossings} Bxx target(s) cross " f"subsong boundary; clamped to cue {default_target}") # ── Sample / instrument bin (same as s3m2taud) ──────────────────────────────── def build_sample_inst_bin_it(samples_or_proxy: list, instr_data_by_slot: dict = None) -> tuple: """samples_or_proxy: list of ITSample | None, indexed 1-based (index 0 unused). instr_data_by_slot: optional dict mapping taud_slot → dict with keys: vol_env, vol_sus, pan_env, pan_sus, pf_env, pf_sus, pf_is_filter, inst_gv, fadeout, vib_speed, vib_depth, vib_sweep, vib_rate, vib_wave, default_pan, pps, ppc_taud, pan_swing, vol_swing, ifc, ifr, sample_detune, nna, dct, dca. All optional; missing keys default to neutral values. Returns (bin_bytes[SAMPLEINST_SIZE], offsets_dict, slot_ratios) where slot_ratios maps Taud slot index → effective TOP_O scale (combined global × per-sample resample ratio). """ pcm_list = [(i, s) for i, s in enumerate(samples_or_proxy) if s is not None and s.sample_data] def _scale_sample(s, r): s.sample_data = resample_linear(s.sample_data, r) s.length = len(s.sample_data) s.loop_beg = max(0, int(s.loop_beg * r)) s.loop_end = max(0, min(int(s.loop_end * r), s.length)) s.sus_beg = max(0, int(s.sus_beg * r)) s.sus_end = max(0, min(int(s.sus_end * r), s.length)) s.c5_speed = max(1, int(s.c5_speed * r)) # ── Pass 1: global pool-overflow resample (8 MB cap) ──────────────────── total = sum(len(s.sample_data) for _, s in pcm_list) global_ratio = 1.0 if total > SAMPLEBIN_SIZE: global_ratio = SAMPLEBIN_SIZE / total vprint(f" info: sample bin overflow ({total} bytes); resampling all by {global_ratio:.4f}") seen_g = set() for _, s in pcm_list: if id(s) in seen_g: continue seen_g.add(id(s)) _scale_sample(s, global_ratio) # ── Pass 2: per-sample u16 cap (each sample must fit in 65535 bytes) ──── # The Taud instrument record stores the sample length as u16, and TOP_O # offsets address up to 0xFF00 bytes — anything longer would silently # truncate at load time and over-shoot O-jumps. Resample only the # over-long samples and remember each one's individual ratio so the # caller can rescale TOP_O args per channel rather than globally. per_sample_ratio = {} # id(s) → per-sample ratio (after global) seen_p = set() for _, s in pcm_list: if id(s) in seen_p: continue seen_p.add(id(s)) if len(s.sample_data) > SAMPLE_LEN_LIMIT: r = SAMPLE_LEN_LIMIT / len(s.sample_data) vprint(f" info: '{s.name}' exceeds {SAMPLE_LEN_LIMIT}-byte cap " f"({len(s.sample_data)}); resampling by {r:.4f}") _scale_sample(s, r) per_sample_ratio[id(s)] = r # Effective slot → ratio for TOP_O rescaling. Slots sharing a sample # object (IT use_instruments mode) get the same ratio. slot_ratios = {} for slot_idx, s in pcm_list: slot_ratios[slot_idx] = global_ratio * per_sample_ratio.get(id(s), 1.0) ratio = slot_ratios sample_bin = bytearray(SAMPLEBIN_SIZE) offsets = {} pos = 0 for idx, s in pcm_list: n = min(len(s.sample_data), SAMPLEBIN_SIZE - pos) if n <= 0: vprint(f" warning: sample bin full, dropping '{s.name}'") offsets[idx] = 0; s.length = 0; continue sample_bin[pos:pos+n] = s.sample_data[:n] offsets[idx] = pos if n < len(s.sample_data): vprint(f" warning: '{s.name}' truncated {len(s.sample_data)} → {n}") s.length = n s.loop_end = min(s.loop_end, n) s.sus_end = min(s.sus_end, n) pos += n # 256-byte instrument layout (terranmon.txt:2001+). INST_STRIDE = 256 USE_ENV_BIT = 0x0020 # b — LOOP wrap enable (legacy; engine still honours) ENV_PRESENT_BIT = 0x2000 # P — envelope present in source (terranmon.txt byte 16/18/20 bit 5) def _write_env(buf: bytearray, base: int, env_pts): """Write 25 (value, minifloat) pairs starting at `buf[base]`. Pads with the previous value (or 0/0x80) and offset=0 if shorter than 25.""" for k in range(25): if env_pts and k < len(env_pts): val, mf = env_pts[k] else: val = (env_pts[-1][0] if env_pts else 0) mf = 0 buf[base + k*2] = val & 0xFF buf[base + k*2 + 1] = mf & 0xFF inst_bin = bytearray(INSTBIN_SIZE) for i, s in enumerate(samples_or_proxy): taud_idx = i # samples_or_proxy is 0-based here; slot 0 unused if i == 0 or i >= 256 or s is None: continue ptr = offsets.get(i, 0) & 0xFFFFFFFF s_len = min(s.length, 65535) c2spd = min(s.c5_speed, 65535) # Sustain loop wins over the regular loop because Taud carries one loop # region. After key-off the engine drops the loop entirely (terranmon.txt:2007). if s.flags & IT_SMP_SUS_LOOP: ls = min(s.sus_beg, 65535) le = min(s.sus_end, 65535) sustain_bit = 0x4 pingpong_active = bool(s.flags & IT_SMP_PINGPONG_SUS) has_active_loop = True elif s.has_loop: ls = min(s.loop_beg, 65535) le = min(s.loop_end, 65535) sustain_bit = 0x0 pingpong_active = bool(s.flags & IT_SMP_PINGPONG) has_active_loop = True else: ls = min(s.loop_beg, 65535) le = min(s.loop_end, 65535) sustain_bit = 0x0 pingpong_active = False has_active_loop = False if has_active_loop and pingpong_active: loop_mode = 2 # backandforth elif has_active_loop: loop_mode = 1 # forward loop else: loop_mode = 0 # no loop flags_byte = (loop_mode & 0x3) | sustain_bit base = taud_idx * INST_STRIDE struct.pack_into('> 8) & 0x0F inst_bin[base + 174] = idata.get('vol_swing', 0) & 0xFF inst_bin[base + 175] = idata.get('vib_speed', 0) & 0xFF inst_bin[base + 176] = idata.get('vib_sweep', 0) & 0xFF inst_bin[base + 177] = idata.get('default_pan', 0x80) & 0xFF struct.pack_into(' int: """Convert raw IT channel-pan byte to Taud 0..63.""" if raw_pan == 100: # surround → centre return 31 p = raw_pan & 0x7F return min(0x3F, round(p * 63 / 64)) def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, inst_vols: dict, amiga_mode: bool = False) -> bytes: """Build a 512-byte Taud pattern for one IT channel from a 64-row chunk grid.""" out = bytearray(PATTERN_BYTES) rows = chunk_grid[ch_idx] if ch_idx < len(chunk_grid) else [ITRow()] * PATTERN_ROWS last_note_it = -1 # IT shares one mem_vc_volslide across A/B/C/D vol-col commands (Schism # player/effects.c:2099-2131). Track it locally so x=0 resolves to the # last explicit value within the chunk. mem_vc_volslide = 0 for r, cell in enumerate(rows[:PATTERN_ROWS]): # ── Resolve vol-col into overrides ────────────────────────────────── # Update mem_vc_volslide before decode so a fresh non-zero on this # row stays visible for any later x=0 in the same channel. if (VC_FVUP_LO <= cell.volcol <= VC_VDN_HI): raw_mag = (cell.volcol - VC_FVUP_LO) % 10 if raw_mag != 0: mem_vc_volslide = raw_mag vs, vv, pan_from_vc, aux_eff = decode_volcol(cell.volcol, mem_vc_volslide) # ── Slot juggling: combine D + G/H into L/K when both are present ── # When the main effect is a pure vol-slide (D) and the vol-col aux is # tone-porta (G) or vibrato depth (H), Taud has dedicated combined # opcodes that capture both: L $xy00 (porta + vol slide) and K $xy00 # (vibrato + vol slide). Without this swap the vol-col aux would be # dropped because the main slot is occupied. if aux_eff is not None and cell.effect == EFF_D and cell.effect_arg != 0: aux_op, aux_arg = aux_eff d_arg = cell.effect_arg & 0xFF if aux_op == EFF_G: cell.effect, cell.effect_arg = EFF_L, d_arg aux_eff = None elif aux_op == EFF_H: # K runs vibrato with current memory_HU; vol-col Hh's depth # update is lost (warn so the trade-off is visible). cell.effect, cell.effect_arg = EFF_K, d_arg aux_eff = None if (aux_arg & 0xF) != 0: vprint(f" ch{ch_idx} row{r}: D+Hh→K, depth update " f"{aux_arg & 0xF} folded into K vibrato recall") # If vol-col provides an aux effect and cell has no main effect, use it if aux_eff is not None and cell.effect == 0: cell.effect, cell.effect_arg = aux_eff aux_eff = None elif aux_eff is not None: vprint(f" ch{ch_idx} row{r}: dropped vol-col aux effect " f"(main effect slot occupied: cmd={cell.effect:02X} arg={cell.effect_arg:02X})") aux_eff = None # If vol-col has a pan override if pan_from_vc is not None: cell.pan_set = pan_from_vc # Encode main effect op, arg16, vol_override, pan_override = encode_effect_it( cell.effect, cell.effect_arg, ch_idx, r, amiga_mode=amiga_mode) # ── Note ──────────────────────────────────────────────────────────── note_taud = NOTE_NOP if cell.note >= 0: note_taud = encode_note_it(cell.note) note_triggers = (0 <= (cell.note if cell.note >= 0 else -1) <= 119) # ── Volume column ──────────────────────────────────────────────────── # Priority: explicit cell vol (vol-col 0-64) > vol-col slide > main- # effect vol override > nop. Per-trigger default volume now lives # in byte 196 of the instrument record (DNV); the engine seeds # noteVolume from it when this row has no V column, so the converter # still doesn't need to emit SEL_SET=Sv on plain trigger rows. if cell.volcol >= 0 and cell.volcol <= VC_VOL_HI: vol_sel, vol_value = SEL_SET, min(cell.volcol, 0x3F) elif vs != SEL_FINE or vv != 0: vol_sel, vol_value = vs, vv elif vol_override is not None: vol_sel, vol_value = vol_override else: vol_sel, vol_value = SEL_FINE, 0 if cell.note is not None and 0 <= (cell.note if cell.note >= 0 else -1) <= 119: last_note_it = cell.note # ── Pan column ─────────────────────────────────────────────────────── if cell.pan_set is not None: pan_sel, pan_value = SEL_SET, cell.pan_set elif pan_override is not None: pan_sel, pan_value = pan_override elif r == 0: pan_sel, pan_value = SEL_SET, default_pan & 0x3F else: pan_sel, pan_value = SEL_FINE, 0 vol_byte = (vol_value & 0x3F) | ((vol_sel & 0x3) << 6) pan_byte = (pan_value & 0x3F) | ((pan_sel & 0x3) << 6) # 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 struct.pack_into(' None: """Move SDx-delayed notes to the next row when x ≥ tick speed. IT triggers a Note Delay during the current row; if x reaches the tick speed, the trigger never lands. When the next row in the same channel is empty, relocate the note (with delay = x − speed) so it actually plays. """ visited = set() for order in order_list: if order >= IT_ORD_END: break if order >= len(patterns_rows) or order in visited: continue visited.add(order) grid, rows = patterns_rows[order] speed = initial_speed for r in range(rows): for ch in range(min(num_channels, len(grid))): cell = grid[ch][r] if cell.effect == EFF_A and cell.effect_arg > 0: speed = cell.effect_arg break if r + 1 >= rows or speed <= 0: continue for ch in range(min(num_channels, len(grid))): cell = grid[ch][r] if cell.effect != EFF_S or cell.note < 0: continue if ((cell.effect_arg >> 4) & 0xF) != 0xD: continue x = cell.effect_arg & 0xF if x < speed: continue nxt = grid[ch][r + 1] if (nxt.note >= 0 or nxt.inst or nxt.effect or nxt.effect_arg or nxt.vol != -1 or nxt.volcol != -1 or nxt.pan_set is not None or nxt.aux_effect is not None): continue new_delay = x - speed nxt.note = cell.note nxt.inst = cell.inst nxt.vol = cell.vol nxt.volcol = cell.volcol nxt.pan_set = cell.pan_set nxt.aux_effect = cell.aux_effect if new_delay > 0: nxt.effect = EFF_S nxt.effect_arg = 0xD0 | (new_delay & 0xF) cell.note = -1 cell.inst = 0 cell.vol = -1 cell.volcol = -1 cell.pan_set = None cell.aux_effect = None cell.effect = 0 cell.effect_arg = 0 vprint(f" fix: pat{order} ch{ch} row{r}: SD{x:X} ≥ speed{speed}, " f"moved note to row{r+1}" + (f" with SD{new_delay:X}" if new_delay > 0 else "")) def find_initial_bpm_speed(patterns_rows: list, order_list: list, default_speed: int, default_tempo: int) -> tuple: speed = default_speed or 6 tempo = default_tempo or 125 for order in order_list: if order >= IT_ORD_END: break if order >= len(patterns_rows): continue grid, _rows = patterns_rows[order] for ch_rows in grid: if not ch_rows: continue cell = ch_rows[0] if cell.effect == EFF_A and cell.effect_arg > 0: speed = cell.effect_arg if cell.effect == EFF_T and cell.effect_arg > 0: tempo = cell.effect_arg break return speed, tempo def _active_channels(h: ITHeader, patterns_rows: list) -> list: """Return up to 20 non-muted, in-use channel indices.""" # Muted = bit 7 of chnl_pan set, or == 0xC0 muted = set() for i, p in enumerate(h.chnl_pan): if p & 0x80 or p == 0xC0: muted.add(i) # In-use = any non-empty cell appears on this channel in_use = set() for grid, rows in patterns_rows: for ch in range(64): if ch >= len(grid): continue for cell in grid[ch]: if cell.note >= 0 or cell.inst > 0 or cell.effect != 0: in_use.add(ch) break active = [i for i in range(64) if i in in_use and i not in muted] if len(active) > NUM_VOICES: vprint(f" warning: {len(active)} active channels; capping at {NUM_VOICES}") active = active[:NUM_VOICES] return active def _per_pattern_bxx_it(patterns_rows: list): """Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough) for use by `detect_subsongs`. `kills_fallthrough` is True iff the pattern carries a Bxx on its absolute last row — the unconditional terminating jump idiom every tracker uses for "song ends here, loop back". """ def fn(pat_idx: int): if pat_idx < 0 or pat_idx >= len(patterns_rows): return set(), False grid, rows = patterns_rows[pat_idx] targets = set() last_row_has_b = False for ch in range(64): if ch >= len(grid): continue ch_rows = grid[ch] for r in range(min(rows, len(ch_rows))): cell = ch_rows[r] if cell.effect == EFF_B: targets.add(cell.effect_arg) if r == rows - 1: last_row_has_b = True return targets, last_row_has_b return fn def _build_song_payload(h: ITHeader, patterns_rows_template: list, positions: list, sample_ratio: dict, inst_vols: dict, active_channels: list, *, song_label: str = 'song') -> tuple: """Build pattern bin + cue sheet + song-entry kwargs for one subsong. Returns (pat_comp, cue_comp, entry_kwargs). The caller fills in `song_offset` from the global layout before calling encode_song_entry. `patterns_rows_template` is deep-copied so per-song stateful walks (recall resolution, late-note-delay relocation, Bxx remap on chunks) don't leak into the next subsong. """ pats = copy.deepcopy(patterns_rows_template) virtual_orders = [h.order_list[pos] for pos in positions] vprint(f" [{song_label}] resolving IT recalls…") resolve_it_recalls(pats, virtual_orders, 64, h.link_gef, old_effects=h.old_effects) init_speed, _ = find_initial_bpm_speed(pats, virtual_orders, h.initial_speed, h.initial_tempo) relocate_late_note_delays(pats, virtual_orders, 64, init_speed) chunks, chunk_map, chunk_lens = split_patterns(pats) C = len(active_channels) # Cue list = expand each subsong position into chunk indices for its pattern. # pos_to_cue maps the original order-list position → first cue in this song. cue_list = [] pos_to_cue = {} for pos in positions: order = h.order_list[pos] if order >= IT_ORD_END or order >= len(chunk_map): continue pos_to_cue[pos] = len(cue_list) for ci in chunk_map[order]: cue_list.append(ci) # Bxx remap: source-position → cue-index. Cross-subsong Bxx targets clamp # to cue 0 (loop the subsong rather than jump out of bounds). Only walk # chunks that this song actually emits — avoids spurious warnings on # patterns owned by other subsongs. _remap_bc_effects(chunks, chunk_map, virtual_orders, pos_to_cue, C, default_target=0, warn_label=song_label, chunk_indices=set(cue_list)) speed, tempo = find_initial_bpm_speed(pats, virtual_orders, h.initial_speed, h.initial_tempo) tempo = max(25, min(280, tempo)) bpm_stored = (tempo - 25) & 0xFF vprint(f" [{song_label}] initial speed={speed}, tempo={tempo} BPM") default_pans = [_it_default_pan(h.chnl_pan[ch]) for ch in active_channels] total_taud_pats = len(cue_list) * C if total_taud_pats > NUM_PATTERNS_MAX: sys.exit( f"error: [{song_label}] {len(cue_list)} cues × {C} channels = " f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit." ) pat_bin = bytearray() for ci in cue_list: cg = chunks[ci] for vi, ch in enumerate(active_channels): pat_bin += build_pattern_it(cg, ch, default_pans[vi], inst_vols, amiga_mode=not h.linear_slides) pat_bin = rescale_offset_effects_per_slot( bytes(pat_bin), len(cue_list), C, sample_ratio) orig_count = len(cue_list) * C pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count) vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique " f"({orig_count - num_taud_pats} deduplicated)") sheet = bytearray(NUM_CUES * CUE_SIZE) for c in range(NUM_CUES): sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) last_active = -1 len_cue_count = 0 for cue_idx, ci in enumerate(cue_list): if cue_idx >= NUM_CUES: break base_pat = cue_idx * C pat_idx_list = [pat_remap[base_pat + vi] for vi in range(C)] 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(pat_idx_list, instr) last_active = cue_idx if last_active >= 0: b30_existing = sheet[last_active * CUE_SIZE + 30] if b30_existing == CUE_INST_LEN: vprint(f" [{song_label}] 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: sheet[30] = CUE_INST_HALT if len_cue_count: vprint(f" [{song_label}] emitted {len_cue_count} LEN cue instruction(s) " f"for partial-length patterns") pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin") cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet") flags_byte = 0x00 if h.linear_slides else 0x01 global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128)) mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128)) entry_kwargs = dict( num_voices=C, num_patterns=num_taud_pats, bpm_stored=bpm_stored, tick_rate=speed, base_note=0xA000, # C9 base_freq=8363.0, flags_byte=flags_byte, pat_bin_comp_size=len(pat_comp), cue_sheet_comp_size=len(cue_comp), global_vol=global_vol_taud, mixing_vol=mixing_vol_taud, ) return pat_comp, cue_comp, entry_kwargs def assemble_taud(h: ITHeader, samples: list, instruments: list, patterns_rows: list, decompress: bool, with_project_data: bool = True) -> bytes: # ── Active channels (shared across subsongs) ───────────────────────────── active_channels = _active_channels(h, patterns_rows) C = len(active_channels) if C == 0: sys.exit("error: no active channels found") # ── SBx chunk-crossing warning (informational only; pattern data is read, # not modified, so this is safe to do once over the shared template) ── for pi, (grid, rows) in enumerate(patterns_rows): if rows <= PATTERN_ROWS: continue for ch in range(64): if ch >= len(grid): continue loop_start_chunk = None for r, cell in enumerate(grid[ch]): if cell.effect == EFF_S: sub = (cell.effect_arg >> 4) & 0xF val = cell.effect_arg & 0xF k = r // PATTERN_ROWS if sub == 0xB and val == 0: loop_start_chunk = k elif sub == 0xB and val > 0: if loop_start_chunk is not None and k != loop_start_chunk: vprint(f" warning: pattern {pi} ch{ch}: SBx crosses " f"chunk boundary (loops may misbehave)") break # ── Build sample proxy list (0-indexed, slot 0 unused) ────────────────── # When use_instruments: map Taud instrument slots to samples via canonical_sample. # Pattern cells carry IT instrument numbers; for use_instruments mode, those # are instrument indices; we remap to samples below. # Taud only knows "instrument" slots (1-based, 8-bit). We lay samples in order. if h.use_instruments: # Build a proxy sample list where Taud inst slot = IT inst index, # resolved to the canonical sample. Slot 0 unused. proxy = [None] * (max(len(instruments), 256) + 1) inst_vols = {} instr_data_by_slot = {} for ii, inst in enumerate(instruments): taud_slot = ii + 1 if taud_slot >= 256: break if inst is None: continue si = inst.canonical_sample - 1 # 0-based sample index if si < 0 or si >= len(samples) or samples[si] is None: continue src_smp = samples[si] proxy[taud_slot] = src_smp # IT splits per-sample volume into TWO concepts that Taud now # carries in two separate bytes: # * inst.gv (0..128) * sample.gv (0..64) — continuous multiplier # on every output sample (matches Schism's # `chan->instrument_volume = (psmp->global_volume * penv->global_volume) >> 7`, # csndfile.c:1317). Goes to byte 171 (IGV). # * sample.vol (Sv, 0..64) — per-trigger seed for chan->volume, # replaceable by an explicit V column on the same row (Schism # effects.c:1302, :1432, :1819). Goes to byte 196 (DNV). # Folding sample.vol into IGV (the pre-2026-05-09 layout) caused # any V-column override on a sample with default vol < 64 to be # attenuated a second time — see terranmon §2350. smp_default_vol = min(getattr(src_smp, 'vol', 64), 64) inst_vols[taud_slot] = min(smp_default_vol, 0x3F) smp_gv = min(getattr(src_smp, 'gv', 64), 64) inst_gv_255 = min(255, round(inst.gv * smp_gv * 255 / (128 * 64))) default_note_vol_255 = min(255, round(smp_default_vol * 255 / 64)) # IT pitch-pan centre: note number 0..119 (C-5 = 60). The Taud # representation is the absolute 4096-TET note value used in patterns # (anchored to TAUD_C4 at IT note 60). ppc_taud = TAUD_C4 + (max(0, min(119, inst.ppc)) - 60) * 4096 // 12 # IT default pan: instrumentwise (IMPI+0x19) takes precedence when # its "use" bit is set; otherwise samplewise (IMPS+0x2F) wins when # its "use" bit is set; otherwise centre (0x80). Both fields encode # 0..64 → rescale to Taud's 0..255 range. smp_dfp_raw = getattr(src_smp, 'dfp', 0) if inst.dfp is not None: default_pan = min(255, max(0, round(inst.dfp * 255 / 64))) elif smp_dfp_raw & 0x80: default_pan = min(255, max(0, round((smp_dfp_raw & 0x7F) * 255 / 64))) else: default_pan = 0x80 # Auto-vibrato lives on the canonical sample (not the IT instrument). # IT samplewise auto-vibrato: Vis (speed 0..64), Vid (depth 0..64), # Vir (rate 0..255 — IT-style ramp-in), Vit (waveform 0..3). # Taud byte 175 (Vibrato Speed) follows FT2 0..255 scale: rescale Vis. # Taud byte 187 (Vibrato Depth) is full 0..255: rescale Vid 0..64 → 0..255. # Taud byte 188 (Vibrato Rate) is IT Vir verbatim. # Taud byte 176 (Vibrato Sweep) is FT2-only — leave 0 for IT. vib_speed_taud = min(255, round(src_smp.av_speed * 255 / 64)) vib_depth_taud = min(255, round(src_smp.av_depth * 255 / 64)) # IT NNA (0=cut, 1=continue, 2=note off, 3=note fade) → # Taud NNA (00=note off, 01=cut, 10=continue, 11=fade). it_to_taud_nna = (0b01, 0b10, 0b00, 0b11) nna_taud = it_to_taud_nna[inst.nna & 0x03] instr_data_by_slot[taud_slot] = { 'vol_env': inst.vol_envelope, 'vol_env_loop': inst.vol_env_loop, 'vol_env_sus': inst.vol_env_sus, 'pan_env': inst.pan_envelope, 'pan_env_loop': inst.pan_env_loop, '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, 'default_note_vol': default_note_vol_255, 'fadeout': inst.fadeout, 'vib_speed': vib_speed_taud, 'vib_depth': vib_depth_taud, 'vib_sweep': 0, # FT2-only; IT uses vib_rate 'vib_rate': src_smp.av_sweep & 0xFF, # IT Vir (samplewise sweep) 'vib_wave': src_smp.av_wave & 0x07, # IT vib type (0..3) 'default_pan': default_pan, 'pps': inst.pps, 'ppc_taud': ppc_taud & 0xFFFF, 'pan_swing': min(255, round(inst.rp * 255 / 64)) if inst.rp else 0, 'vol_swing': min(255, round(inst.rv * 255 / 100)) if inst.rv else 0, 'ifc': inst.ifc, 'ifr': inst.ifr, 'sample_detune': 0, # IT samples have no finetune 'nna': nna_taud, 'dct': inst.dct, 'dca': inst.dca, } sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy, instr_data_by_slot) else: # Samples referenced directly; proxy is samples list (0-based, slot 0 unused) proxy = [None] + list(samples) inst_vols = { i+1: min(s.vol, 0x3F) for i, s in enumerate(samples) if s is not None } sampleinst_raw, _, sample_ratio = build_sample_inst_bin_it(proxy) assert len(sampleinst_raw) == SAMPLEINST_SIZE compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) # ── Detect subsongs ────────────────────────────────────────────────────── subsongs = detect_subsongs(h.order_list, _per_pattern_bxx_it(patterns_rows), terminators=(IT_ORD_END,), skip_marker=IT_ORD_SKIP) if not subsongs: # Degenerate file: every order is a terminator. Emit one empty subsong. vprint(" warning: no traversable orders in source; emitting empty song") subsongs = [{'entry': 0, 'positions': []}] n_songs = len(subsongs) if n_songs == 1: vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)") else: vprint(f" detected {n_songs} subsongs:") for i, ss in enumerate(subsongs): vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders") # ── Build per-song payloads ────────────────────────────────────────────── song_payloads = [] # list of (pat_comp, cue_comp, entry_kwargs) for i, ss in enumerate(subsongs): label = f"song {i}" if n_songs > 1 else "song" song_payloads.append(_build_song_payload( h, patterns_rows, ss['positions'], sample_ratio, inst_vols, active_channels, song_label=label)) # ── Compute layout offsets and assemble song table ─────────────────────── song_table_off = TAUD_HEADER_SIZE + comp_size first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs song_table = bytearray() cur_off = first_song_off for pat_comp, cue_comp, entry_kwargs in song_payloads: entry = encode_song_entry(song_offset=cur_off, **entry_kwargs) assert len(entry) == TAUD_SONG_ENTRY song_table += entry cur_off += len(pat_comp) + len(cue_comp) # ── Project Data (optional) ────────────────────────────────────────────── # IT distinguishes instruments from samples, so both INam and SNam can carry # distinct content. Slot 0 is unused, so the tables are 1-indexed with an # empty slot-0 entry. proj_data = b'' proj_off = 0 if with_project_data: inst_names = [''] + [(inst.name if inst is not None else '') for inst in instruments[:255]] smp_names = [''] + [(s.name if s is not None else '') for s in samples[:255]] proj_data = build_project_data( project_name=h.title, instrument_names=inst_names, sample_names=smp_names, ) if proj_data: proj_off = cur_off vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") # ── Header ─────────────────────────────────────────────────────────────── sig = (SIGNATURE + b' ' * 14)[:14] header = ( TAUD_MAGIC + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack('