111 lines
4 KiB
Python
111 lines
4 KiB
Python
#!/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 ""))
|