#!/usr/bin/env python3 """SID register log (sidCapture.py) -> JoeyLib .song text for songbake. One row per PAL frame (tickms 20). Per voice, a note-on is a gate rising edge or a pitch change while gated; gate falling is 'off'. SID frequency -> Hz exactly (@Hz cells), so no equal-temperament rounding. Attenuation comes from the sustain nibble. A voice whose waveform is NOISE goes to the noise column instead of a tone column, with a pitch scaled from its frequency. Loop detection: the player is deterministic, so once the (frame-relative) event rows repeat with a fixed period, that period is the loop. """ import json, sys PAL_CLOCK = 985248.0 songs = json.load(open(sys.argv[1])) outDir = sys.argv[2] def sidHz(lo, hi): return ((hi << 8) | lo) * PAL_CLOCK / 16777216.0 def rowsFor(writes, nframes): # replay register writes frame by frame, producing per-frame voice state regs = [0] * 0x19 state = [] byFrame = {} for f, r, v in writes: byFrame.setdefault(f, []).append((r, v)) for f in range(nframes): for r, v in byFrame.get(f, []): regs[r] = v vs = [] for vc in range(3): b = vc * 7 vs.append(dict(hz=sidHz(regs[b], regs[b + 1]), gate=regs[b + 4] & 1, wave=regs[b + 4] & 0xF0, sus=regs[b + 6] >> 4)) state.append(vs) return state def atten(sus): # SID sustain 0..15 -> AGI attenuation 14..0 (0 loudest) return max(0, min(14, round(14 - sus * 14 / 15))) def noisePitch(hz): # brighter hiss for higher SID "frequency"; 0..31, 0 = brightest p = int(31 - min(31, hz / 400.0)) return max(0, min(31, p)) def toRows(state): rows = [] prev = [dict(hz=None, gate=0, wave=0, sus=0) for _ in range(3)] for vs in state: tone = ["---", "---", "---"] noise = "---" for vc in range(3): cur, was = vs[vc], prev[vc] isNoise = (cur["wave"] & 0x80) != 0 on = cur["gate"] and (not was["gate"] or abs(cur["hz"] - was["hz"]) > 0.5 or cur["wave"] != was["wave"]) off = was["gate"] and not cur["gate"] if on: if isNoise: noise = f"N{noisePitch(cur['hz'])}:{atten(cur['sus'])}" else: hz = max(1, min(65535, int(round(cur["hz"])))) tone[vc] = f"@{hz}:{atten(cur['sus'])}" elif off: if (was["wave"] & 0x80): noise = "off" else: tone[vc] = "off" prev[vc] = dict(cur) rows.append((tone, noise)) return rows def findLoop(rows): # smallest (offset, period) with rows[o+k] == rows[o+p+k] for the tail n = len(rows) for p in range(8, n // 2): for o in range(0, n - 2 * p): if rows[o:o + p] == rows[o + p:o + 2 * p] and rows[o:n - p] == rows[o + p:n]: return o, p return None for num, s in sorted(songs.items(), key=lambda kv: int(kv[0])): num = int(num) st = rowsFor(s["writes"], s["frames"]) rows = toRows(st) loop = findLoop(rows) if s["frames"] >= 4000 else None if loop: o, p = loop rows = rows[:o + p] lines = ["; Space Taxi (C64) song %d, extracted by running the game's own" % num, "; player ($CB00/$CF52) under cpu6502.py and logging SID writes.", "; One row = one PAL frame.", "tickms 20"] for i, (tone, noise) in enumerate(rows): if loop and i == loop[0]: lines.append("loop") cells = tone + ([noise] if noise != "---" or True else []) lines.append(" ".join(cells)) open(f"{outDir}/song{num}.song", "w").write("\n".join(lines) + "\n") events = sum(1 for t, n in rows for c in t + [n] if c != "---") waves = sorted({hex(v["wave"]) for vs in st for v in vs if v["gate"]}) print(f"song {num}: {len(rows)} rows, {events} events, waveforms {waves}" + (f", LOOP at row {loop[0]} period {loop[1]}" if loop else ""))