diff --git a/it2taud.py b/it2taud.py index 8023f6a..4a8a544 100644 --- a/it2taud.py +++ b/it2taud.py @@ -35,7 +35,6 @@ Effect support: """ import argparse -import gzip import struct import sys @@ -53,7 +52,7 @@ from taud_common import ( EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z, J_SEMI_TABLE, d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, - normalise_sample, encode_song_entry, nearest_minifloat, + normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, ) @@ -702,39 +701,80 @@ def encode_note_it(it_note: int) -> int: # ── Vol-column decoder ──────────────────────────────────────────────────────── -def decode_volcol(vc: int): - """Return (vol_sel, vol_value, pan_set, aux_effect) or None for each field.""" +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 + 1 # 1..10 + 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 + 1 + 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: - return SEL_UP, vc - VC_VUP_LO + 1, None, None + 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: - return SEL_DOWN, vc - VC_VDN_LO + 1, None, None + 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: - # Pitch slide down: each unit = 4 ST3 coarse units (1/16 semitone each) - units = (vc - VC_PDN_LO + 1) * 4 + # 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: - units = (vc - VC_PUP_LO + 1) * 4 + 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: - spd = VC_TPORTA_TABLE[vc - VC_TPORTA_LO] + # 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: - depth = vc - VC_VIB_LO + 1 # 1..10 - return SEL_FINE, 0, None, (EFF_H, depth & 0x0F) + # 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 @@ -1273,10 +1313,41 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, 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 ────────────────────────────────── - vs, vv, pan_from_vc, aux_eff = decode_volcol(cell.volcol) + # 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: @@ -1284,7 +1355,7 @@ def build_pattern_it(chunk_grid: list, ch_idx: int, default_pan: int, 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)") + f"(main effect slot occupied: cmd={cell.effect:02X} arg={cell.effect_arg:02X})") aux_eff = None # If vol-col has a pan override @@ -1621,9 +1692,8 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, assert len(sampleinst_raw) == SAMPLEINST_SIZE - compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0) + compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)") # ── BPM / speed ────────────────────────────────────────────────────────── speed, tempo = find_initial_bpm_speed(patterns_rows, h.order_list, @@ -1707,10 +1777,8 @@ def assemble_taud(h: ITHeader, samples: list, instruments: list, assert len(header) == TAUD_HEADER_SIZE # Compress pattern bin and cue sheet (per Taud spec) - pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0) - cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0) - vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)") - vprint(f" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)") + pat_comp = compress_blob(bytes(pat_bin), "pattern bin") + cue_comp = compress_blob(bytes(sheet), "cue sheet") # flags byte: bit 1 (f) = Amiga pitch-slide mode (IT linear_slides flag inverted). # bit 2 was the old 'm' fadeout-zero policy flag and is now reserved (always 0); fadeout diff --git a/mod2taud.py b/mod2taud.py index dce004e..b2e304e 100644 --- a/mod2taud.py +++ b/mod2taud.py @@ -24,7 +24,6 @@ Effect support: """ import argparse -import gzip import math import struct import sys @@ -40,7 +39,7 @@ from taud_common import ( SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, J_SEMI_TABLE, d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, - encode_song_entry, + encode_song_entry, compress_blob, ) @@ -718,9 +717,8 @@ def assemble_taud(mod: dict) -> bytes: sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples) assert len(sampleinst_raw) == SAMPLEINST_SIZE - compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0) + compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)") speed, tempo = find_initial_bpm_speed(patterns, order_list) tempo = max(24, min(280, tempo)) @@ -766,10 +764,8 @@ def assemble_taud(mod: dict) -> bytes: cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap) assert len(cue_sheet) == NUM_CUES * CUE_SIZE - pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0) - cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0) - vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)") - vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)") + pat_comp = compress_blob(bytes(pat_bin), "pattern bin") + cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") # ProTracker is Amiga-period-based by definition, so we set the f bit so # the engine applies coarse pitch slides in period space (recovers PT's diff --git a/mon2taud.py b/mon2taud.py index b024442..1afca19 100644 --- a/mon2taud.py +++ b/mon2taud.py @@ -22,7 +22,6 @@ Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095. """ import argparse -import gzip import struct import sys @@ -35,7 +34,7 @@ from taud_common import ( TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_E, TOP_F, TOP_G, TOP_H, TOP_J, SEL_SET, SEL_FINE, J_SEMI_TABLE, - encode_cue, deduplicate_patterns, encode_song_entry, + encode_cue, deduplicate_patterns, encode_song_entry, compress_blob, ) @@ -324,9 +323,8 @@ def assemble_taud(mon: dict) -> bytes: vprint(" building sample/instrument bin…") sampleinst_raw = build_sample_inst_bin() assert len(sampleinst_raw) == SAMPLEINST_SIZE - compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0) + compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)") vprint(" building pattern bin…") pat_bin = bytearray() @@ -346,10 +344,8 @@ def assemble_taud(mon: dict) -> bytes: cue_sheet = build_cue_sheet(order_list, num_voices, pat_remap) assert len(cue_sheet) == NUM_CUES * CUE_SIZE - pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0) - cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0) - vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)") - vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)") + pat_comp = compress_blob(bytes(pat_bin), "pattern bin") + cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") # Header: magic, version, num_songs=1, comp_size of sample+inst, projOff=0, sig. sig = (SIGNATURE + b' ' * 14)[:14] diff --git a/s3m2taud.py b/s3m2taud.py index 40cea1b..a9e867f 100644 --- a/s3m2taud.py +++ b/s3m2taud.py @@ -25,7 +25,6 @@ Effect support: """ import argparse -import gzip import math import struct import sys @@ -44,7 +43,7 @@ from taud_common import ( EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z, J_SEMI_TABLE, d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, - normalise_sample, encode_song_entry, + normalise_sample, encode_song_entry, compress_blob, ) @@ -752,9 +751,8 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: assert len(sampleinst_raw) == SAMPLEINST_SIZE # Compress - compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0) + compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)") # Initial BPM / speed speed, tempo = find_initial_bpm_speed(patterns, h.order_list, @@ -810,10 +808,8 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes: assert len(cue_sheet) == NUM_CUES * CUE_SIZE # Compress pattern bin and cue sheet (per Taud spec) - pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0) - cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0) - vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)") - vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)") + pat_comp = compress_blob(bytes(pat_bin), "pattern bin") + cue_comp = compress_blob(bytes(cue_sheet), "cue sheet") # Song table row (32 bytes; see encode_song_entry). # flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted). diff --git a/taud_common.py b/taud_common.py index 01bfae0..8767ccc 100644 --- a/taud_common.py +++ b/taud_common.py @@ -7,9 +7,16 @@ pattern deduper, sample normaliser) that all three converters used to duplicate verbatim. """ +import gzip as _gzip import struct import sys +try: + import zstandard as _zstd + _ZSTD_CCTX = _zstd.ZstdCompressor(level=22) +except ImportError: + _ZSTD_CCTX = None + # ── Verbose logging (shared across converters via set_verbose) ─────────────── @@ -24,6 +31,37 @@ def vprint(*a, **kw) -> None: print(*a, **kw, file=sys.stderr) +# ── Compression (gzip vs zstd; whichever is smaller) ───────────────────────── +# +# The Taud loader sniffs the 4-byte magic of every compressed slot and routes +# to GZIPInputStream or ZstdInputStream accordingly (CompressorDelegate.kt:148-149), +# so each blob can independently pick whichever codec compresses it smaller. + +def best_compress(payload: bytes) -> tuple: + """Return (compressed_bytes, method) for the smaller of gzip/zstd output. + + Method is "gzip" or "zstd". Falls back to gzip when the `zstandard` + package is not installed. + """ + gz = _gzip.compress(payload, compresslevel=9, mtime=0) + if _ZSTD_CCTX is None: + return gz, "gzip" + zs = _ZSTD_CCTX.compress(payload) + if len(zs) < len(gz): + return zs, "zstd" + return gz, "gzip" + + +def compress_blob(payload: bytes, label: str) -> bytes: + """Compress `payload` with whichever of gzip/zstd is smaller; vprint stats; return bytes. + + `label` is the human-readable name in the verbose log line, e.g. "sample+inst bin". + """ + out, method = best_compress(payload) + vprint(f" {label}: {len(payload)} → {len(out)} bytes ({method})") + return out + + # ── Taud container constants ───────────────────────────────────────────────── TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64]) diff --git a/terranmon.txt b/terranmon.txt index 0d67060..7ec8b8a 100644 --- a/terranmon.txt +++ b/terranmon.txt @@ -2351,6 +2351,7 @@ TODO: [ ] remove panning mode selection and replace global panning rule to 3 dB rule (not the equal energy) [x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility. Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects + [ ] 8 MB sample RAM via 512k banks Play Data: play data are series of tracker-like instructions, visualised as: diff --git a/xm2taud.py b/xm2taud.py index a808d2f..19fd88b 100644 --- a/xm2taud.py +++ b/xm2taud.py @@ -37,7 +37,6 @@ Reference: """ import argparse -import gzip import math import struct import sys @@ -53,7 +52,7 @@ from taud_common import ( SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE, J_SEMI_TABLE, d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns, - normalise_sample, encode_song_entry, nearest_minifloat, + normalise_sample, encode_song_entry, nearest_minifloat, compress_blob, CUE_INST_NOP, CUE_INST_HALT, CUE_INST_LEN, cue_instruction_len, ) @@ -1080,6 +1079,32 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int, # Pan slide via vol-col D/E (encoded as pan_override below) vc_pan_override = _xm_volcol_pan_override(cell.volcol) + # ── Slot juggling for combined effects ────────────────────────────── + # XM main 0x0A (vol slide → TOP_D) + vol-col Mx (porta → TOP_G aux) + # combine cleanly into Taud L (porta + vol slide). Same for + # vol-col Bx/Ax (vibrato → TOP_H aux) → Taud K (vibrato + vol slide). + # Without this swap the vol-col aux would be dropped because the main + # slot is already occupied by D. The combined K/L take their slide + # nibbles directly from the source D arg (high byte of XM 0x0A), + # matching the encoding used by main XM effects 5 (→ L) and 6 (→ K). + if (aux_eff is not None and cell.effect == 0x0A + and cell.effect_arg != 0): + aux_op, aux_arg = aux_eff + d_arg = cell.effect_arg & 0xFF + if aux_op == TOP_G: + # XM A + vol-col M → Taud L verbatim. Porta speed already + # lives in Taud's private G memory (vol-col aux → G $00xx). + cell.effect, cell.effect_arg = 0x05, d_arg + aux_eff = None + elif aux_op == TOP_H: + # XM A + vol-col B (vibrato depth) → Taud K. K reuses + # memory_HU; the vol-col Bx depth update is lost. + cell.effect, cell.effect_arg = 0x06, d_arg + aux_eff = None + if (aux_arg & 0xFF) != 0: + vprint(f" ch{ch_idx} row{r}: A+Bx→K, depth update " + f"{aux_arg & 0xFF:02X} folded into K vibrato recall") + # ── Main effect translation ───────────────────────────────────────── op, arg16, vol_override, pan_override = encode_effect_xm( cell.effect, cell.effect_arg, ch_idx, r, amiga_mode=amiga_mode) @@ -1095,7 +1120,7 @@ def build_pattern_xm(chunk_grid: list, ch_idx: int, default_pan: int, aux_eff = None else: vprint(f" ch{ch_idx} row{r}: dropped vol-col aux effect " - f"(main effect slot occupied)") + f"(main effect slot occupied: cmd={cell.effect:02X} arg={cell.effect_arg:02X})") # ── Note ──────────────────────────────────────────────────────────── note_taud = NOTE_NOP @@ -1230,9 +1255,8 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes: # ── Sample / instrument bin ───────────────────────────────────────────── vprint(f" building sample/inst bin… ({len(proxies) - 1} sample slots used)") sampleinst_raw, _, sample_ratio = build_sample_inst_bin_xm(proxies) - compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0) + compressed = compress_blob(sampleinst_raw, "sample+inst bin") comp_size = len(compressed) - vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)") # ── Tempo / speed ─────────────────────────────────────────────────────── speed = h.default_speed if h.default_speed > 0 else 6 @@ -1350,10 +1374,8 @@ def assemble_taud(h: XMHeader, patterns: list, instruments: list) -> bytes: ) assert len(header) == TAUD_HEADER_SIZE - pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0) - cue_comp = gzip.compress(bytes(sheet), compresslevel=9, mtime=0) - vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)") - vprint(f" cue sheet: {len(sheet)} → {len(cue_comp)} bytes (gzip)") + pat_comp = compress_blob(bytes(pat_bin), "pattern bin") + cue_comp = compress_blob(bytes(sheet), "cue sheet") # Flags byte: # bit 1 (f) = Amiga pitch-slide mode (set when XM uses Amiga period table).