From fb42ab4413f9334efc6b3914ecc8edfafe6102f1 Mon Sep 17 00:00:00 2001 From: minjaesong Date: Mon, 11 May 2026 04:19:31 +0900 Subject: [PATCH] 2taud: export to multiple song if possible --- it2taud.py | 397 ++++++++++++++++++++++++++++++------------------- mod2taud.py | 270 +++++++++++++++++++++++---------- mon2taud.py | 236 +++++++++++++++++++++-------- s3m2taud.py | 280 +++++++++++++++++++++++----------- taud_common.py | 111 ++++++++++++++ xm2taud.py | 358 ++++++++++++++++++++++++++++---------------- 6 files changed, 1150 insertions(+), 502 deletions(-) diff --git a/it2taud.py b/it2taud.py index 15e4ed2..8f55349 100644 --- a/it2taud.py +++ b/it2taud.py @@ -35,6 +35,7 @@ Effect support: """ import argparse +import copy import struct import sys @@ -55,7 +56,7 @@ from taud_common import ( 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, + build_project_data, detect_subsongs, ) @@ -1057,7 +1058,10 @@ def split_patterns(patterns_rows: list): def _remap_bc_effects(chunks: list, chunk_map: list, order_list: list, it_ord_to_taud_cue: dict, - num_channels: int) -> None: + 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 @@ -1068,15 +1072,36 @@ def _remap_bc_effects(chunks: list, chunk_map: list, 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). """ - for ci, chunk_grid in enumerate(chunks): + 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 - taud_cue = it_ord_to_taud_cue.get(it_tgt, it_tgt) - row.effect_arg = taud_cue & 0xFF + 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) ──────────────────────────────── @@ -1573,22 +1598,176 @@ def _active_channels(h: ITHeader, patterns_rows: list) -> list: 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: - # ── Resolve IT recalls ─────────────────────────────────────────────────── - vprint(" resolving IT recalls…") - resolve_it_recalls(patterns_rows, h.order_list, 64, h.link_gef, - old_effects=h.old_effects) + # ── 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") - init_speed, _ = find_initial_bpm_speed(patterns_rows, h.order_list, - h.initial_speed, h.initial_tempo) - relocate_late_note_delays(patterns_rows, h.order_list, 64, init_speed) - - # ── Check SBx chunk crossing (warn only) ───────────────────────────────── + # ── 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 - n_chunks = (rows + PATTERN_ROWS - 1) // PATTERN_ROWS for ch in range(64): if ch >= len(grid): continue loop_start_chunk = None @@ -1605,36 +1784,6 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, f"chunk boundary (loops may misbehave)") break - # ── Split patterns into 64-row chunks ──────────────────────────────────── - vprint(" splitting patterns…") - chunks, chunk_map, chunk_lens = split_patterns(patterns_rows) - - # ── Choose active channels ─────────────────────────────────────────────── - active_channels = _active_channels(h, patterns_rows) - C = len(active_channels) - if C == 0: - sys.exit("error: no active channels found") - - # ── Build the ordered list of (taud_chunk_idx, voice_idx) triples ──────── - # Expand order list: each IT order → sequence of chunk indices for that pattern - taud_cue_list = [] # list of chunk_idx (source patterns, already chunked) - it_ord_to_taud_cue = {} # first taud cue for IT order i - - for oi, order in enumerate(h.order_list): - if order == IT_ORD_END: - break - if order == IT_ORD_SKIP: - continue - if order >= len(chunk_map): - continue - it_ord_to_taud_cue.setdefault(oi, len(taud_cue_list)) - for ci in chunk_map[order]: - taud_cue_list.append(ci) - - # ── Remap B effects ────────────────────────────────────────────────────── - _remap_bc_effects(chunks, chunk_map, h.order_list, it_ord_to_taud_cue, - len(active_channels)) - # ── 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 @@ -1750,116 +1899,47 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - # ── BPM / speed ────────────────────────────────────────────────────────── - speed, tempo = find_initial_bpm_speed(patterns_rows, h.order_list, - h.initial_speed, h.initial_tempo) - tempo = max(25, min(280, tempo)) - bpm_stored = (tempo - 25) & 0xFF - vprint(f" initial speed={speed}, tempo={tempo} BPM") - - # ── Pattern bin ────────────────────────────────────────────────────────── - vprint(" building pattern bin…") - default_pans = [_it_default_pan(h.chnl_pan[ch]) for ch in active_channels] - total_taud_pats = len(taud_cue_list) * C - if total_taud_pats > NUM_PATTERNS_MAX: - sys.exit( - f"error: {len(taud_cue_list)} cues × {C} channels = " - f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit." - ) - - pat_bin = bytearray() - for ci in taud_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) - - # Rescale TOP_O sample-offset args per channel using the active slot's - # ratio (combined global + per-sample). Walks pat_bin in cue-major / - # channel-minor order, tracking the most recent inst byte seen on each - # channel — must run before deduplication so the channel state stays - # linear. - pat_bin = rescale_offset_effects_per_slot( - bytes(pat_bin), len(taud_cue_list), C, sample_ratio) - - orig_count = len(taud_cue_list) * C - pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count) - vprint(f" patterns: {orig_count} → {num_taud_pats} unique " - f"({orig_count - num_taud_pats} deduplicated)") - - # ── Cue sheet ──────────────────────────────────────────────────────────── - vprint(" building cue sheet…") - song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY - 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(taud_cue_list): - if cue_idx >= NUM_CUES: break - base_pat = cue_idx * C - pats = [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(pats, instr) - last_active = cue_idx - - if last_active >= 0: - # 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 + # ── 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: - sheet[30] = CUE_INST_HALT - if len_cue_count: - vprint(f" emitted {len_cue_count} LEN cue instruction(s) " - f"for partial-length patterns") + vprint(f" detected {n_songs} subsongs:") + for i, ss in enumerate(subsongs): + vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders") - # ── Header ─────────────────────────────────────────────────────────────── - sig = (SIGNATURE + b' ' * 14)[:14] + # ── 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)) - # Compress pattern bin and cue sheet (per Taud spec) - pat_comp = compress_blob(bytes(pat_bin), "pattern bin") - cue_comp = compress_blob(bytes(sheet), "cue sheet") + # ── 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 - # flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when IT's - # linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to - # the equal-energy — no `p` bit any more. Bit 2 was the old 'm' fadeout-zero - # policy flag and is now reserved (always 0); fadeout scaling is done per-instrument - # in this converter — see the fadeout pass-through below. - flags_byte = 0x00 if h.linear_slides else 0x01 - # IT global/mix volumes are 0..128; rescale to Taud's 0..255 (clamped). - global_vol_taud = min(0xFF, round(h.global_vol * 255 / 128)) - mixing_vol_taud = min(0xFF, round(h.mix_vol * 255 / 128)) - song_table = encode_song_entry( - song_offset=song_offset, - 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, - ) - assert len(song_table) == TAUD_SONG_ENTRY + 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. + # ── 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: @@ -1873,20 +1953,29 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, sample_names=smp_names, ) if proj_data: - proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ - + len(pat_comp) + len(cue_comp) + 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, 1]) + + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack(' tuple: return speed, tempo -def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes: - samples = mod['samples'] - patterns = mod['patterns'] - order_list = mod['order_list'] - n_channels = mod['n_channels'] - n_patterns = mod['n_patterns'] - - if n_channels > NUM_VOICES: - vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}") - n_channels = NUM_VOICES - - if n_patterns * n_channels > NUM_PATTERNS_MAX: - sys.exit( - f"error: {n_patterns} MOD patterns × {n_channels} channels = " - f"{n_patterns*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.\n" - f" Reduce the MOD to ≤ {NUM_PATTERNS_MAX // max(n_channels,1)} patterns." - ) - - vprint(f" channels: {n_channels}, mod patterns: {n_patterns}, " - f"taud patterns: {n_patterns * n_channels}") - - # Fold Cxx into row.vol_set so the volume column carries explicit set-volume. - # This is done in-place before recall resolution so Cxx with arg 0 still - # resolves to vol 0 (silence) rather than recalling another effect's memory. - for grid in patterns: +def _per_pattern_bxx_mod(patterns: list, n_channels: int): + """Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough) + for `detect_subsongs`. MOD patterns are 64 rows × n_channels; Bxx is + raw effect digit 0xB. + """ + def fn(pat_idx: int): + if pat_idx < 0 or pat_idx >= len(patterns): + return set(), False + grid = patterns[pat_idx] + targets = set() + last_row_has_b = False for ch in range(min(n_channels, len(grid))): - for row in grid[ch]: - if row.effect == 0xC: - row.vol_set = min(row.effect_arg, 0x3F) - row.effect = 0 - row.effect_arg = 0 + ch_rows = grid[ch] + for r in range(min(PATTERN_ROWS, len(ch_rows))): + cell = ch_rows[r] + if cell.effect == 0xB: + targets.add(cell.effect_arg & 0xFF) + if r == PATTERN_ROWS - 1: + last_row_has_b = True + return targets, last_row_has_b + return fn - vprint(" resolving PT per-effect recalls…") - resolve_pt_recalls(patterns, order_list, n_channels) - init_speed, _ = find_initial_bpm_speed(patterns, order_list) - relocate_late_note_delays(patterns, order_list, n_channels, init_speed) +def _build_song_payload_mod(mod: dict, patterns_template: list, + positions: list, sample_ratio: dict, + inst_vols: dict, n_channels: int, + *, song_label: str = 'song') -> tuple: + """Build pattern bin + cue sheet + song-entry kwargs for one MOD subsong. - vprint(" building sample/instrument bin…") - sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples) - assert len(sampleinst_raw) == SAMPLEINST_SIZE + `patterns_template` is deep-copied so per-song stateful transforms + (recall resolution, late-note-delay relocation, Bxx remap) don't leak + into the next subsong. + """ + patterns = copy.deepcopy(patterns_template) + order_list = mod['order_list'] + virtual_orders = [order_list[pos] for pos in positions] - compressed = compress_blob(sampleinst_raw, "sample+inst bin") - comp_size = len(compressed) + vprint(f" [{song_label}] resolving PT per-effect recalls…") + resolve_pt_recalls(patterns, virtual_orders, n_channels) - speed, tempo = find_initial_bpm_speed(patterns, order_list) + init_speed, _ = find_initial_bpm_speed(patterns, virtual_orders) + relocate_late_note_delays(patterns, virtual_orders, n_channels, init_speed) + + speed, tempo = find_initial_bpm_speed(patterns, virtual_orders) tempo = max(25, min(280, tempo)) bpm_stored = (tempo - 25) & 0xFF - vprint(f" initial speed={speed}, tempo(BPM)={tempo}") + vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}") - song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY + n_patterns = mod['n_patterns'] - sig = (SIGNATURE + b' ' * 14)[:14] + # Cue list and pos→cue mapping, skipping orders that aren't valid pattern refs. + cue_list = [] + pos_to_cue = {} + for pos in positions: + order = order_list[pos] + if order >= n_patterns: + continue + pos_to_cue[pos] = len(cue_list) + cue_list.append(order) + + # Densely renumber the patterns this song uses. + used_ordered = [] + seen = set() + for src_pat in cue_list: + if src_pat not in seen: + used_ordered.append(src_pat) + seen.add(src_pat) + pat_idx_remap = {src: i for i, src in enumerate(used_ordered)} + P_used = len(used_ordered) + + if P_used * n_channels > NUM_PATTERNS_MAX: + sys.exit(f"error: [{song_label}] {P_used} patterns × {n_channels} channels = " + f"{P_used*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.") + + # Bxx remap on the patterns this song actually emits. + crossings = 0 + for src_pat in used_ordered: + if src_pat >= len(patterns): continue + grid = patterns[src_pat] + for ch in range(min(n_channels, len(grid))): + for row in grid[ch]: + if row.effect == 0xB: + if row.effect_arg in pos_to_cue: + row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF + else: + crossings += 1 + row.effect_arg = 0 + if crossings: + vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross " + f"subsong boundary; clamped to cue 0") - vprint(" building pattern bin…") - inst_vols = { - i + 1: min(s.volume, 0x3F) - for i, s in enumerate(samples) - if s.sample_data - } pat_bin = bytearray() - for pi in range(n_patterns): - grid = patterns[pi] + for src_pat in used_ordered: + grid = patterns[src_pat] for ch in range(n_channels): default_pan = _default_channel_pan(ch) pat_bin += build_pattern(grid, ch, default_pan, inst_vols) - assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES - - # Rescale TOP_O sample-offset args if samples were globally downsampled. pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio) - vprint(" deduplicating patterns…") - orig_count = n_patterns * n_channels + orig_count = P_used * n_channels pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count) - vprint(f" patterns: {orig_count} → {num_taud_pats} unique " + vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique " f"({orig_count - num_taud_pats} deduplicated)") - vprint(" building cue sheet…") - cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap) - assert len(cue_sheet) == NUM_CUES * CUE_SIZE + sheet = bytearray(NUM_CUES * CUE_SIZE) + for c in range(NUM_CUES): + sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) - pat_comp = compress_blob(bytes(pat_bin), "pattern bin") - cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") + last_active = -1 + for cue_idx, src_pat in enumerate(cue_list): + if cue_idx >= NUM_CUES: break + new_pat_idx = pat_idx_remap[src_pat] + orig_pats = [new_pat_idx * n_channels + v for v in range(n_channels)] + sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue( + [pat_remap[p] for p in orig_pats], 0) + last_active = cue_idx + + if last_active >= 0: + sheet[last_active * CUE_SIZE + 30] = 0x01 + else: + sheet[30] = 0x01 + + pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin") + cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet") - # ProTracker is Amiga-period-based by definition, so we set ff=1 (bits 0-1) so - # the engine applies coarse pitch slides in period space (recovers PT's - # characteristic non-linear pitch character). Pan law is fixed to the - # equal-energy engine-wide. PT has no instrument-level fadeout, so every Taud - # instrument carries fadeout=0 ("no fade") — notes retire on sample-end or - # pattern note-cut instead, which matches PT semantics. flags_byte = GLOBAL_FLAGS_AMIGA_FREQ | GLOBAL_FLAGS_A500_INTP - song_table = encode_song_entry( - song_offset=song_offset, + entry_kwargs = dict( num_voices=n_channels, num_patterns=num_taud_pats, bpm_stored=bpm_stored, @@ -807,7 +842,82 @@ def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes: global_vol=0xFF, mixing_vol=180, ) - assert len(song_table) == TAUD_SONG_ENTRY + return pat_comp, cue_comp, entry_kwargs + + +def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes: + samples = mod['samples'] + patterns = mod['patterns'] + order_list = mod['order_list'] + n_channels = mod['n_channels'] + n_patterns = mod['n_patterns'] + + if n_channels > NUM_VOICES: + vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}") + n_channels = NUM_VOICES + vprint(f" channels: {n_channels}, mod patterns: {n_patterns}") + + # Fold Cxx into row.vol_set so the volume column carries explicit set-volume. + # This is non-stateful (doesn't depend on order list) so it runs once on the + # shared template; per-song deepcopies inherit the folded form. + for grid in patterns: + for ch in range(min(n_channels, len(grid))): + for row in grid[ch]: + if row.effect == 0xC: + row.vol_set = min(row.effect_arg, 0x3F) + row.effect = 0 + row.effect_arg = 0 + + vprint(" building sample/instrument bin…") + sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples) + assert len(sampleinst_raw) == SAMPLEINST_SIZE + compressed = compress_blob(sampleinst_raw, "sample+inst bin") + comp_size = len(compressed) + + inst_vols = { + i + 1: min(s.volume, 0x3F) + for i, s in enumerate(samples) + if s.sample_data + } + + # ── Detect subsongs ────────────────────────────────────────────────────── + # MOD shares IT/S3M's 0xFF-end / 0xFE-skip convention; orders ≥ n_patterns + # are also unplayable and treated as skips by the player (build_cue_sheet). + skip_set = set([0xFE]) | set(range(n_patterns, 256)) + subsongs = detect_subsongs(order_list, + _per_pattern_bxx_mod(patterns, n_channels), + terminators=(0xFF,), + skip_marker=skip_set) + if not subsongs: + 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 = [] + for i, ss in enumerate(subsongs): + label = f"song {i}" if n_songs > 1 else "song" + song_payloads.append(_build_song_payload_mod( + mod, patterns, ss['positions'], sample_ratio, inst_vols, + n_channels, song_label=label)) + + # ── Layout offsets and 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). MOD samples *are* its instruments — the names # populate both INam and SNam (1-based; slot 0 empty). @@ -821,20 +931,28 @@ def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes: sample_names=names, ) if proj_data: - proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ - + len(pat_comp) + len(cue_comp) + proj_off = cur_off vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") + sig = (SIGNATURE + b' ' * 14)[:14] header = ( TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack(' int # ── Top-level assembly ─────────────────────────────────────────────────────── +def _per_pattern_bxx_mon(patterns: list, num_voices: int): + """Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough) + for `detect_subsongs`. Monotone effect index 5 is 'B' (position jump); + arg is 6 bits (0..63). Patterns are 64 rows × num_voices. `grid[v][r]`. + """ + def fn(pat_idx: int): + if pat_idx < 0 or pat_idx >= len(patterns): + return set(), False + grid = patterns[pat_idx] + targets = set() + last_row_has_b = False + for v in range(min(num_voices, len(grid))): + v_rows = grid[v] + for r in range(min(MON_PATTERN_ROWS, len(v_rows))): + cell = v_rows[r] + if cell.effect == 5: + targets.add(cell.effect_arg & 0x3F) + if r == MON_PATTERN_ROWS - 1: + last_row_has_b = True + return targets, last_row_has_b + return fn + + +def _build_song_payload_mon(mon: dict, patterns_template: list, + positions: list, num_voices: int, + *, song_label: str = 'song') -> tuple: + """Build pattern bin + cue sheet + song-entry kwargs for one Monotone + subsong. Mutates a deepcopy of the patterns to remap Bxx targets to + per-song cue indices. + """ + patterns = copy.deepcopy(patterns_template) + order_list = mon['order_list'] + n_patterns = mon['n_patterns'] + virtual_orders = [order_list[pos] for pos in positions] + + speed = find_initial_speed(patterns, virtual_orders, num_voices) + vprint(f" [{song_label}] initial speed (ticks/row): {speed}") + + cue_list = [] + pos_to_cue = {} + for pos in positions: + order = order_list[pos] + if order >= n_patterns: + continue + pos_to_cue[pos] = len(cue_list) + cue_list.append(order) + + used_ordered = [] + seen = set() + for src_pat in cue_list: + if src_pat not in seen: + used_ordered.append(src_pat) + seen.add(src_pat) + pat_idx_remap = {src: i for i, src in enumerate(used_ordered)} + P_used = len(used_ordered) + + if P_used * num_voices > NUM_PATTERNS_MAX: + sys.exit(f"error: [{song_label}] {P_used} patterns × {num_voices} voices = " + f"{P_used*num_voices} > {NUM_PATTERNS_MAX} Taud pattern limit.") + + # Bxx remap: source position → cue index. Cross-song clamps to cue 0. + crossings = 0 + for src_pat in used_ordered: + if src_pat >= len(patterns): continue + grid = patterns[src_pat] + for v in range(min(num_voices, len(grid))): + for row in grid[v]: + if row.effect == 5: + if row.effect_arg in pos_to_cue: + row.effect_arg = pos_to_cue[row.effect_arg] & 0x3F + else: + crossings += 1 + row.effect_arg = 0 + if crossings: + vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross " + f"subsong boundary; clamped to cue 0") + + pat_bin = bytearray() + for src_pat in used_ordered: + grid = patterns[src_pat] + for v in range(num_voices): + pat_bin += build_taud_pattern(grid, v) + + orig_count = P_used * num_voices + pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(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+1)*CUE_SIZE] = encode_cue([], 0) + + last_active = -1 + for cue_idx, src_pat in enumerate(cue_list): + if cue_idx >= NUM_CUES: break + new_pat_idx = pat_idx_remap[src_pat] + orig_pats = [new_pat_idx * num_voices + v for v in range(num_voices)] + sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue( + [pat_remap[p] for p in orig_pats], 0) + last_active = cue_idx + if last_active >= 0: + sheet[last_active * CUE_SIZE + 30] = 0x01 + + 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 = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION + bpm_stored = 150 - 25 + entry_kwargs = dict( + num_voices=num_voices, + num_patterns=num_taud_pats, + bpm_stored=bpm_stored, + tick_rate=speed, + base_note=0xA000, + base_freq=SQUARE_C2SPD, + flags_byte=flags_byte, + pat_bin_comp_size=len(pat_comp), + cue_sheet_comp_size=len(cue_comp), + global_vol=0xFF, + mixing_vol=round(180 / num_voices), + ) + return pat_comp, cue_comp, entry_kwargs + + def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes: num_voices = mon['num_voices'] patterns = mon['patterns'] @@ -313,18 +438,7 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes: if num_voices > NUM_VOICES: vprint(f" warning: {num_voices} voices > {NUM_VOICES}; truncating") num_voices = NUM_VOICES - - if n_patterns * num_voices > NUM_PATTERNS_MAX: - sys.exit( - f"error: {n_patterns} patterns × {num_voices} voices = " - f"{n_patterns*num_voices} > {NUM_PATTERNS_MAX} Taud limit" - ) - - vprint(f" voices: {num_voices}, mon patterns: {n_patterns}, " - f"taud patterns: {n_patterns * num_voices}") - - speed = find_initial_speed(patterns, order_list, num_voices) - vprint(f" initial speed (ticks/row): {speed}") + vprint(f" voices: {num_voices}, mon patterns: {n_patterns}") vprint(" building sample/instrument bin…") sampleinst_raw = build_sample_inst_bin() @@ -332,53 +446,44 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes: compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - vprint(" building pattern bin…") - pat_bin = bytearray() - for pi in range(n_patterns): - grid = patterns[pi] - for v in range(num_voices): - pat_bin += build_taud_pattern(grid, v) - assert len(pat_bin) == n_patterns * num_voices * PATTERN_BYTES + # ── Detect subsongs ────────────────────────────────────────────────────── + # Monotone strips 0xFF (skip) markers during parse, so the order list is + # already a clean sequence of pattern indices. No terminator/skip values + # to feed the detector — subsongs only emerge from the Bxx graph. + skip_set = set(range(n_patterns, 256)) # invalid pattern refs → skip + subsongs = detect_subsongs(order_list, + _per_pattern_bxx_mon(patterns, num_voices), + terminators=(), + skip_marker=skip_set) + if not subsongs: + 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") - vprint(" deduplicating patterns…") - orig_count = n_patterns * num_voices - pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count) - vprint(f" patterns: {orig_count} → {num_taud_pats} unique " - f"({orig_count - num_taud_pats} deduplicated)") + # ── Build per-song payloads ────────────────────────────────────────────── + song_payloads = [] + for i, ss in enumerate(subsongs): + label = f"song {i}" if n_songs > 1 else "song" + song_payloads.append(_build_song_payload_mon( + mon, patterns, ss['positions'], num_voices, song_label=label)) - vprint(" building cue sheet…") - cue_sheet = build_cue_sheet(order_list, num_voices, pat_remap) - assert len(cue_sheet) == NUM_CUES * CUE_SIZE + # ── Layout offsets and song table ──────────────────────────────────────── + song_table_off = TAUD_HEADER_SIZE + comp_size + first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs - pat_comp = compress_blob(bytes(pat_bin), "pattern bin") - cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") - - sig = (SIGNATURE + b' ' * 14)[:14] - song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY - - # BPM 150 + ticks=mon_speed → row rate = 60/mon_speed (matches Monotone). - bpm_stored = 150 - 25 - # Linear-frequency tone mode (ff=2) so 1xx/2xx/3xx Hz/tick semantics survive verbatim. - # Pan law is fixed engine-wide to the equal-energy (no flag). Monotone has no - # instrument-level fadeout, so every Taud instrument carries fadeout=0 ("no fade") — - # notes retire on sample-end or pattern note-cut instead. - flags_byte = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION - - song_table = encode_song_entry( - song_offset = song_offset, - num_voices = num_voices, - num_patterns = num_taud_pats, - bpm_stored = bpm_stored, - tick_rate = speed, - base_note = 0xA000, - base_freq = SQUARE_C2SPD, - flags_byte = flags_byte, - pat_bin_comp_size = len(pat_comp), - cue_sheet_comp_size = len(cue_comp), - global_vol = 0xFF, - mixing_vol = round(180 / num_voices), - ) - assert len(song_table) == TAUD_SONG_ENTRY + 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). Monotone has no title, no user instruments and # no per-sample names, but we still emit one identifying entry so the @@ -391,21 +496,28 @@ def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes: sample_names=['', 'PC speaker square'], ) if proj_data: - proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ - + len(pat_comp) + len(cue_comp) + proj_off = cur_off vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") - # Header: magic, version, num_songs=1, comp_size of sample+inst, projOff, sig. + sig = (SIGNATURE + b' ' * 14)[:14] header = ( TAUD_MAGIC - + bytes([TAUD_VERSION, 1]) + + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack(' bytes: - # Determine active channels (bit7 clear = enabled) - active_channels = [i for i, cs in enumerate(h.channel_settings) - if i < 32 and not (cs & 0x80)][:NUM_VOICES] - C = len(active_channels) - P = len(patterns) +def _per_pattern_bxx_s3m(patterns: list): + """Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough) + for `detect_subsongs`. `kills_fallthrough` is True iff the pattern carries + a Bxx on its absolute last row (the unconditional terminating-jump idiom). + S3M patterns are always 64 rows. + """ + def fn(pat_idx: int): + if pat_idx < 0 or pat_idx >= len(patterns): + return set(), False + grid = patterns[pat_idx] + targets = set() + last_row_has_b = False + for ch in range(min(32, len(grid))): + ch_rows = grid[ch] + for r in range(min(PATTERN_ROWS, len(ch_rows))): + cell = ch_rows[r] + if getattr(cell, 'effect', 0) == EFF_B: + targets.add(cell.effect_arg) + if r == PATTERN_ROWS - 1: + last_row_has_b = True + return targets, last_row_has_b + return fn - if P * C > NUM_PATTERNS_MAX: - sys.exit( - f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n" - f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute " - f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}." - ) - vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}") +def _build_song_payload_s3m(h: S3MHeader, patterns_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. - # Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg) - # before any per-row encoding, so cohort-aware Taud effects see explicit - # arguments. Mutates patterns in place. - vprint(" resolving ST3 shared-memory recalls…") - resolve_st3_recalls(patterns, h.order_list, 32) - warn_st3_quirks(patterns, h.order_list, 32) + Returns (pat_comp, cue_comp, entry_kwargs). The caller fills in + `song_offset` from the global layout. `patterns_template` is deep-copied + so per-song stateful walks (recall resolution, late-note-delay + relocation, Bxx remap) don't leak into the next subsong. + """ + pats = copy.deepcopy(patterns_template) + virtual_orders = [h.order_list[pos] for pos in positions] - init_speed, _ = find_initial_bpm_speed(patterns, h.order_list, + vprint(f" [{song_label}] resolving ST3 shared-memory recalls…") + resolve_st3_recalls(pats, virtual_orders, 32) + warn_st3_quirks(pats, virtual_orders, 32) + + init_speed, _ = find_initial_bpm_speed(pats, virtual_orders, h.initial_speed, h.initial_tempo) - relocate_late_note_delays(patterns, h.order_list, 32, init_speed) + relocate_late_note_delays(pats, virtual_orders, 32, init_speed) - # Build sample+instrument bin - vprint(" building sample/instrument bin…") - sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments) - assert len(sampleinst_raw) == SAMPLEINST_SIZE - - # Compress - compressed = compress_blob(sampleinst_raw, "sample+inst bin") - comp_size = len(compressed) - - # Initial BPM / speed - speed, tempo = find_initial_bpm_speed(patterns, h.order_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" initial speed={speed}, tempo(BPM)={tempo}") + vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}") - # Song offset = header(32) + compressed + song_table(8) - song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY - num_taud_pats = P * C + # Cue list (source pattern indices) and pos→cue mapping. Skip orders that + # already terminate (S3M_ORDER_END) or point past the pattern table. + cue_list = [] + pos_to_cue = {} + for pos in positions: + order = h.order_list[pos] + if order >= S3M_ORDER_END or order >= len(pats): + continue + pos_to_cue[pos] = len(cue_list) + cue_list.append(order) - sig = (SIGNATURE + b' ' * 14)[:14] + # Densely renumber the patterns this song actually emits. + used_ordered = [] + seen = set() + for src_pat in cue_list: + if src_pat not in seen: + used_ordered.append(src_pat) + seen.add(src_pat) + pat_idx_remap = {src: i for i, src in enumerate(used_ordered)} + P_used = len(used_ordered) - # Pattern bin: for each s3m pattern, for each active channel, 512 bytes - vprint(" building pattern bin…") - default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels] - # 1-based inst index → default volume (0..63) for note-trigger vol injection. - inst_vols = { - i + 1: min(inst.volume, 0x3F) - for i, inst in enumerate(instruments) - if inst is not None and inst.itype == S3M_TYPE_PCM - } + C = len(active_channels) + if P_used * C > NUM_PATTERNS_MAX: + sys.exit( + f"error: [{song_label}] {P_used} patterns × {C} channels = " + f"{P_used*C} > {NUM_PATTERNS_MAX} Taud pattern limit." + ) + + # Bxx remap: target source-position → cue-index. Cross-subsong jumps + # clamp to cue 0 (loop the subsong rather than jump out of bounds). Walk + # only the patterns this song actually emits. + crossings = 0 + for src_pat in used_ordered: + if src_pat >= len(pats): continue + grid = pats[src_pat] + for ch in range(min(32, len(grid))): + for row in grid[ch]: + if row.effect == EFF_B: + if row.effect_arg in pos_to_cue: + row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF + else: + crossings += 1 + row.effect_arg = 0 + if crossings: + vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross " + f"subsong boundary; clamped to cue 0") + + # Pattern bin: emit only patterns this song uses (densely indexed). + default_pans = [_default_channel_pan(h.channel_settings[ch]) + for ch in active_channels] pat_bin = bytearray() - for pi in range(P): - grid = patterns[pi] + for src_pat in used_ordered: + grid = pats[src_pat] for vi, ch in enumerate(active_channels): - pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides, - inst_vols, amiga_mode=not h.linear_slides) - assert len(pat_bin) == num_taud_pats * PATTERN_BYTES + pat_bin += build_pattern(grid, ch, default_pans[vi], + h.linear_slides, inst_vols, + amiga_mode=not h.linear_slides) - # Rescale TOP_O sample-offset args if samples were globally downsampled. pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio) - - # Deduplicate identical patterns - vprint(" deduplicating patterns…") - orig_count = num_taud_pats + orig_count = P_used * C pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count) - vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)") + vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique " + f"({orig_count - num_taud_pats} deduplicated)") - # Cue sheet (using remapped pattern indices) - vprint(" building cue sheet…") - cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap) - assert len(cue_sheet) == NUM_CUES * CUE_SIZE + # Cue sheet + sheet = bytearray(NUM_CUES * CUE_SIZE) + for c in range(NUM_CUES): + sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0) - # Compress pattern bin and cue sheet (per Taud spec) - pat_comp = compress_blob(bytes(pat_bin), "pattern bin") - cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") + last_active = -1 + for cue_idx, src_pat in enumerate(cue_list): + if cue_idx >= NUM_CUES: break + new_pat_idx = pat_idx_remap[src_pat] + orig_pats = [new_pat_idx * C + v for v in range(C)] + sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue( + [pat_remap[p] for p in orig_pats], 0) + last_active = cue_idx + + if last_active >= 0: + sheet[last_active * CUE_SIZE + 30] = 0x01 + else: + sheet[30] = 0x01 + + pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin") + cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet") - # Song table row (32 bytes; see encode_song_entry). - # flags byte: bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when S3M's - # linear_slides flag is clear; ff=0 otherwise. Pan law is fixed engine-wide to - # the equal-energy — no `p` bit any more. Bit 2 reserved (was 'm' fadeout-zero - # policy; removed). S3M has no instrument-level fadeout, so every Taud instrument - # carries fadeout=0 ("no fade") — notes retire on sample-end or pattern note-cut - # effects (SCx) instead, which matches ST3 semantics. flags_byte = (0x00 if h.linear_slides else 0x01) - song_table = encode_song_entry( - song_offset=song_offset, + entry_kwargs = dict( num_voices=C, num_patterns=num_taud_pats, bpm_stored=bpm_stored, @@ -831,10 +877,70 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list, global_vol=0xFF, mixing_vol=180, ) - assert len(song_table) == TAUD_SONG_ENTRY + return pat_comp, cue_comp, entry_kwargs - # Project Data (optional). S3M instruments and samples share the same slot - # space, so the names go into both INam and SNam (1-based; slot 0 empty). + +def assemble_taud(h: S3MHeader, instruments: list, patterns: list, + with_project_data: bool = True) -> bytes: + # Determine active channels (bit7 clear = enabled) + active_channels = [i for i, cs in enumerate(h.channel_settings) + if i < 32 and not (cs & 0x80)][:NUM_VOICES] + C = len(active_channels) + P = len(patterns) + vprint(f" channels: {C}, s3m patterns: {P}") + + # Build sample+instrument bin (shared across subsongs) + vprint(" building sample/instrument bin…") + sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments) + assert len(sampleinst_raw) == SAMPLEINST_SIZE + compressed = compress_blob(sampleinst_raw, "sample+inst bin") + comp_size = len(compressed) + + # 1-based inst index → default volume (0..63) for note-trigger vol injection. + inst_vols = { + i + 1: min(inst.volume, 0x3F) + for i, inst in enumerate(instruments) + if inst is not None and inst.itype == S3M_TYPE_PCM + } + + # ── Detect subsongs ────────────────────────────────────────────────────── + subsongs = detect_subsongs(h.order_list, _per_pattern_bxx_s3m(patterns), + terminators=(S3M_ORDER_END,), + skip_marker=S3M_ORDER_SKIP) + if not subsongs: + 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 = [] + for i, ss in enumerate(subsongs): + label = f"song {i}" if n_songs > 1 else "song" + song_payloads.append(_build_song_payload_s3m( + h, patterns, ss['positions'], sample_ratio, inst_vols, + active_channels, song_label=label)) + + # ── Layout offsets and 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) ────────────────────────────────────────────── + # S3M instruments and samples share the same slot space, so the names go + # into both INam and SNam (1-based; slot 0 empty). proj_data = b'' proj_off = 0 if with_project_data: @@ -846,21 +952,29 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list, sample_names=names, ) if proj_data: - proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ - + len(pat_comp) + len(cue_comp) + proj_off = cur_off vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") - # Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14) + # ── Header ─────────────────────────────────────────────────────────────── + sig = (SIGNATURE + b' ' * 14)[:14] header = ( TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack(' bool: + if pos < 0 or pos >= n: + return False + v = orders[pos] + return v not in term and v not in skips + + visited = set() + songs = [] + + while True: + # Lowest unvisited traversable position = next subsong entry. + entry = next((i for i in range(n) + if i not in visited and _is_traversable(i)), None) + if entry is None: + break + + # Reachability claims orders for this subsong, stopping at orders + # already owned by a previous subsong. + owned = set() + stack = [entry] + while stack: + oi = stack.pop() + if oi in owned or oi in visited: + continue + if oi < 0 or oi >= n: + continue + v = orders[oi] + if v in term: + continue + if v in skips: + if oi + 1 < n: + stack.append(oi + 1) + continue + owned.add(oi) + tgts, kills = pattern_bxx_fn(v) + for t in tgts: + if 0 <= t < n: + stack.append(t) + if not kills and oi + 1 < n: + stack.append(oi + 1) + + if not owned: + # Avoid infinite loop on a degenerate entry (shouldn't happen + # since _is_traversable already filtered terminators / skips). + visited.add(entry) + continue + visited |= owned + + # Cue-sheet order: ascending index, rotated so entry comes first. + # The natural order-list traversal is sequential, so increasing index + # matches the play sequence when fall-through is alive; rotation + # ensures cue 0 is the entry order. + sorted_owned = sorted(owned) + rot = sorted_owned.index(entry) + positions = sorted_owned[rot:] + sorted_owned[:rot] + + songs.append({'entry': entry, 'positions': positions}) + + return songs + + # ── Project Data section (terranmon.txt:2601+) ─────────────────────────────── PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ diff --git a/xm2taud.py b/xm2taud.py index ffdc9e8..4a36514 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -41,6 +41,7 @@ Reference: """ import argparse +import copy import math import struct import sys @@ -59,7 +60,7 @@ from taud_common import ( 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, + build_project_data, detect_subsongs, ) @@ -694,18 +695,42 @@ def split_patterns_xm(patterns: list): def remap_b_effects_xm(chunks: list, chunk_map: list, order_list: list, xm_ord_to_taud_cue: dict, - num_channels: int) -> None: + num_channels: int, + *, default_target: int = None, + warn_label: str = '', + chunk_indices=None) -> None: """Rewrite XM B (position jump) effects so the argument indexes Taud cues rather than XM order positions. (Pattern break Dxx already targets a row, no remap needed — the post-break behaviour is "advance to next order", - which Taud emulates correctly when the cue ends.)""" - for chunk_grid in chunks: + which Taud emulates correctly when the cue ends.) + + `default_target`: when a Bxx target isn't in `xm_ord_to_taud_cue` (a + cross-subsong jump), rewrite to this cue index instead of preserving + the literal target. Use 0 to make cross-song jumps loop the subsong. + + `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 not emitted in this song). + """ + 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(min(num_channels, len(chunk_grid))): for row in chunk_grid[ch]: if row.effect == 0x0B: xm_ord = row.effect_arg & 0xFF - taud_cue = xm_ord_to_taud_cue.get(xm_ord, xm_ord) - row.effect_arg = taud_cue & 0xFF + if xm_ord in xm_ord_to_taud_cue: + row.effect_arg = xm_ord_to_taud_cue[xm_ord] & 0xFF + elif default_target is not None: + crossings += 1 + row.effect_arg = default_target & 0xFF + else: + row.effect_arg = xm_ord & 0xFF + if crossings and warn_label: + vprint(f" warning: {warn_label}: {crossings} Bxx target(s) cross " + f"subsong boundary; clamped to cue {default_target}") def compute_keyoff_zero_marks_xm(taud_cue_list: list, chunks: list, @@ -1253,6 +1278,147 @@ def _active_channels_xm(h: XMHeader, patterns: list) -> list: # ── Main assembly ───────────────────────────────────────────────────────────── +def _per_pattern_bxx_xm(patterns: list): + """Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough) + for `detect_subsongs`. XM patterns vary in length; `kills_fallthrough` is + True when a Bxx (effect 0x0B) appears on the absolute last row. + `patterns[pi]` is `(grid, rows)`; `grid` is `[channel][row]`. + """ + def fn(pat_idx: int): + if pat_idx < 0 or pat_idx >= len(patterns): + return set(), False + grid, rows = patterns[pat_idx] + targets = set() + last_row_has_b = False + for ch_rows in grid: + n = min(rows, len(ch_rows)) + for r in range(n): + cell = ch_rows[r] + if cell.effect == 0x0B: + targets.add(cell.effect_arg & 0xFF) + if r == rows - 1: + last_row_has_b = True + return targets, last_row_has_b + return fn + + +def _build_song_payload_xm(h: XMHeader, patterns_template: list, + instruments: list, positions: list, + sample_ratio: dict, active_channels: list, + default_pans: list, resolve_inst_slot, + *, song_label: str = 'song') -> tuple: + """Build pattern bin + cue sheet + (subset of) song-entry kwargs for + one subsong. The caller fills in song_offset, flags_byte, and shared + globals. + + Patterns aren't mutated by per-order walks in XM (no recall resolution), + but `remap_b_effects_xm` mutates chunk grids — so we deep-copy chunks + per song. (`compute_keyoff_zero_marks_xm` only reads.) + """ + chunks, chunk_map, chunk_lens = split_patterns_xm(patterns_template) + + C = len(active_channels) + + cue_list = [] + pos_to_cue = {} + for pos in positions: + order = h.order_list[pos] + if order >= h.pattern_count or order >= len(chunk_map): + continue + pos_to_cue[pos] = len(cue_list) + for ci in chunk_map[order]: + cue_list.append(ci) + + if not cue_list: + # Degenerate subsong (e.g. all orders point to invalid patterns). + vprint(f" warning: [{song_label}] no playable cues; emitting halt-only song") + + remap_b_effects_xm(chunks, chunk_map, h.order_list, pos_to_cue, C, + default_target=0, warn_label=song_label, + chunk_indices=set(cue_list)) + + keyoff_zero_marks = compute_keyoff_zero_marks_xm( + cue_list, chunks, h.channels, instruments, active_channels) + if any(keyoff_zero_marks.values()): + flagged = sum(len(s) for s in keyoff_zero_marks.values()) + vprint(f" [{song_label}] FT2 keyoff-gate: {flagged} key-off cell(s) " + f"paired with vol=0 (vol-env-off instruments)") + + 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] + chunk_marks = keyoff_zero_marks.get(ci, frozenset()) + for vi, ch in enumerate(active_channels): + row_marks = {r for (mvi, r) in chunk_marks if mvi == vi} + pat_bin += build_pattern_xm(cg, ch, default_pans[vi], + resolve_inst_slot, + amiga_mode=not h.linear_freq, + keyoff_zero_rows=row_marks) + 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 + pats = [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(pats, instr) + last_active = cue_idx + + if last_active >= 0: + if sheet[last_active * CUE_SIZE + 30] == CUE_INST_LEN: + vprint(f" [{song_label}] warning: last active cue {last_active} " + f"had LEN; 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") + + # Speed/tempo are file-wide for XM; pass them through the kwargs so the + # outer function fills in shared header fields uniformly. + speed = h.default_speed if h.default_speed > 0 else 6 + tempo = h.default_bpm if h.default_bpm > 0 else 125 + tempo = max(25, min(280, tempo)) + bpm_stored = (tempo - 25) & 0xFF + + entry_kwargs = dict( + num_voices=C, + num_patterns=num_taud_pats, + bpm_stored=bpm_stored, + tick_rate=speed, + pat_bin_comp_size=len(pat_comp), + cue_sheet_comp_size=len(cue_comp), + ) + return pat_comp, cue_comp, entry_kwargs + + def assemble_taud(h: XMHeader, patterns: list, instruments: list, with_project_data: bool = True) -> bytes: # XM envelope frames advance once per row tick. Tick rate is derived @@ -1315,139 +1481,69 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list, bpm_stored = (tempo - 25) & 0xFF vprint(f" initial speed={speed}, tempo={tempo} BPM") - # ── Channels / cue list ───────────────────────────────────────────────── + # ── Channels / pattern split (shared) ─────────────────────────────────── active_channels = _active_channels_xm(h, patterns) C = len(active_channels) if C == 0: sys.exit("error: no active channels found") - chunks, chunk_map, chunk_lens = split_patterns_xm(patterns) - - taud_cue_list = [] - xm_ord_to_taud_cue = {} - for oi, order in enumerate(h.order_list[:h.order_count]): - if order >= h.pattern_count: - continue - if order >= len(chunk_map): - continue - xm_ord_to_taud_cue.setdefault(oi, len(taud_cue_list)) - for ci in chunk_map[order]: - taud_cue_list.append(ci) - - if not taud_cue_list: - sys.exit("error: order list resolved to no playable cues") - - remap_b_effects_xm(chunks, chunk_map, h.order_list, xm_ord_to_taud_cue, C) - - # FT2 vol-env-off key-off gating: pre-compute per-(chunk, voice, row) flags - # for key-off cells whose bound XM instrument has volume envelope disabled. - # build_pattern_xm pairs each flagged key-off with `SEL_SET vol=0` so the - # IT-style Taud engine reproduces FT2's channel-volume zeroing gate. - keyoff_zero_marks = compute_keyoff_zero_marks_xm( - taud_cue_list, chunks, h.channels, instruments, active_channels) - if any(keyoff_zero_marks.values()): - flagged = sum(len(s) for s in keyoff_zero_marks.values()) - vprint(f" FT2 keyoff-gate: {flagged} key-off cell(s) paired with vol=0 " - f"(vol-env-off instruments)") - - # ── Pattern bin ───────────────────────────────────────────────────────── - total_taud_pats = len(taud_cue_list) * C - if total_taud_pats > NUM_PATTERNS_MAX: - sys.exit(f"error: {len(taud_cue_list)} cues × {C} channels = " - f"{total_taud_pats} > {NUM_PATTERNS_MAX} Taud pattern limit.") - # Default pan per active channel: alternate L/R FT2-style (0,12,12,0,...). def _xm_default_pan(idx: int) -> int: side = idx % 4 return 16 if side in (0, 3) else 47 default_pans = [_xm_default_pan(i) for i in range(C)] - pat_bin = bytearray() - for ci in taud_cue_list: - cg = chunks[ci] - chunk_marks = keyoff_zero_marks.get(ci, frozenset()) - for vi, ch in enumerate(active_channels): - row_marks = {r for (mvi, r) in chunk_marks if mvi == vi} - pat_bin += build_pattern_xm(cg, ch, default_pans[vi], - resolve_inst_slot, - amiga_mode=not h.linear_freq, - keyoff_zero_rows=row_marks) - # Rescale TOP_O sample-offset args per channel using the active slot's - # ratio (combined global + per-sample). Walks pat_bin in cue-major / - # channel-minor order, tracking the most recent inst byte seen on each - # channel — must run before deduplication so the channel state stays - # linear. - pat_bin = rescale_offset_effects_per_slot( - bytes(pat_bin), len(taud_cue_list), C, sample_ratio) - - orig_count = len(taud_cue_list) * C - pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count) - vprint(f" patterns: {orig_count} → {num_taud_pats} unique " - f"({orig_count - num_taud_pats} deduplicated)") - - # ── Cue sheet ─────────────────────────────────────────────────────────── - 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(taud_cue_list): - if cue_idx >= NUM_CUES: - break - base_pat = cue_idx * C - pats = [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(pats, instr) - last_active = cue_idx - - if last_active >= 0: - if sheet[last_active * CUE_SIZE + 30] == 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 + # ── Detect subsongs ────────────────────────────────────────────────────── + # XM has no terminator marker; `order_count` bounds the live order list. + # Out-of-range pattern refs (≥ pattern_count) are skipped during playback, + # so we feed the detector a slice of length `order_count` and treat + # everything ≥ pattern_count as a skip. + orders_view = list(h.order_list[:h.order_count]) + skip_set = set(range(h.pattern_count, 256)) + subsongs = detect_subsongs(orders_view, _per_pattern_bxx_xm(patterns), + terminators=(), + skip_marker=skip_set) + if not subsongs: + 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: - sheet[30] = CUE_INST_HALT - if len_cue_count: - vprint(f" emitted {len_cue_count} LEN cue instruction(s) " - f"for partial-length patterns") + vprint(f" detected {n_songs} subsongs:") + for i, ss in enumerate(subsongs): + vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders") - # ── Header / song table ───────────────────────────────────────────────── - song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY - sig = (SIGNATURE + b' ' * 14)[:14] + # ── Build per-song payloads ────────────────────────────────────────────── + song_payloads = [] + for i, ss in enumerate(subsongs): + label = f"song {i}" if n_songs > 1 else "song" + song_payloads.append(_build_song_payload_xm( + h, patterns, instruments, ss['positions'], + sample_ratio, active_channels, default_pans, + resolve_inst_slot, + song_label=label)) - pat_comp = compress_blob(bytes(pat_bin), "pattern bin") - cue_comp = compress_blob(bytes(sheet), "cue sheet") + # ── Layout offsets and song table ──────────────────────────────────────── + song_table_off = TAUD_HEADER_SIZE + comp_size + first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs - # Flags byte: - # bits 0-1 (ff) = tone mode. ff=1 (Amiga period slides) when XM uses the Amiga - # period table; ff=0 otherwise. Pan law is fixed engine-wide to - # the equal-energy — no `p` bit any more. - # bit 2 = reserved (was 'm' fadeout-zero policy; removed). XM fadeout values - # are now scaled per-instrument above (÷32 with round-to-nearest), so - # the engine sees Taud-native units and uses its single divisor of 1024. flags_byte = (0x00 if h.linear_freq else 0x01) - song_table = encode_song_entry( - song_offset=song_offset, - num_voices=C, - num_patterns=num_taud_pats, - bpm_stored=bpm_stored, - tick_rate=speed, - base_note=0xA000, - base_freq=8363.0, - flags_byte=flags_byte, - pat_bin_comp_size=len(pat_comp), - cue_sheet_comp_size=len(cue_comp), - global_vol=0xFF, - mixing_vol=0x80, - ) - assert len(song_table) == TAUD_SONG_ENTRY + song_table = bytearray() + cur_off = first_song_off + for pat_comp, cue_comp, entry_kwargs in song_payloads: + # Header BPM/speed go into per-song; flags is shared (XM doesn't switch + # period mode mid-file). + entry = encode_song_entry(song_offset=cur_off, + flags_byte=flags_byte, + global_vol=0xFF, + mixing_vol=0x80, + base_note=0xA000, + base_freq=8363.0, + **entry_kwargs) + assert len(entry) == TAUD_SONG_ENTRY + song_table += entry + cur_off += len(pat_comp) + len(cue_comp) # Project Data (optional). XM nests samples under instruments and the # converter creates one Taud slot per (xm_inst, sample) pair, so SNam is @@ -1466,20 +1562,28 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list, sample_names=smp_names, ) if proj_data: - proj_off = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY \ - + len(pat_comp) + len(cue_comp) + proj_off = cur_off vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}") + sig = (SIGNATURE + b' ' * 14)[:14] header = ( TAUD_MAGIC + - bytes([TAUD_VERSION, 1]) + + bytes([TAUD_VERSION, n_songs & 0xFF]) + struct.pack('