pyjch 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pyjch/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Read and play JCH NewPlayer SID songs (byte-exact register output)."""
2
+
3
+ from pyjch.errors import JCHError, SidParseError
4
+ from pyjch.model import Song
5
+ from pyjch.player import Player, iter_frames, render_grid
6
+ from pyjch.reader import parse, read
7
+ from pyjch.reglog import (
8
+ RegWrite,
9
+ iter_register_writes,
10
+ read_reglog,
11
+ write_reglog,
12
+ )
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ __all__ = [
17
+ "JCHError",
18
+ "Player",
19
+ "RegWrite",
20
+ "SidParseError",
21
+ "Song",
22
+ "__version__",
23
+ "iter_frames",
24
+ "iter_register_writes",
25
+ "parse",
26
+ "read",
27
+ "read_reglog",
28
+ "render_grid",
29
+ "write_reglog",
30
+ ]
pyjch/cli.py ADDED
@@ -0,0 +1,64 @@
1
+ """Command line interface: song info and register logs."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from pyjch import reglog
7
+ from pyjch.errors import JCHError
8
+ from pyjch.reader import read
9
+
10
+
11
+ def _info(args) -> None:
12
+ song = read(args.song)
13
+ print(f"name: {song.name}")
14
+ print(f"author: {song.author}")
15
+ print(f"released: {song.released}")
16
+ print(f"load: ${song.load_addr:04X}")
17
+ print(f"init/play: ${song.init_addr:04X} / ${song.play_addr:04X}")
18
+ print(f"AD/SR: ${song.init_ad:02X} / ${song.init_sr:02X}")
19
+ print(f"gate on/off: ${song.gate_ctrl:02X} / ${song.gateoff_ctrl:02X}")
20
+ print(f"subptr lo/hi:${song.subptr_lo:04X} / ${song.subptr_hi:04X}")
21
+ for voice in range(3):
22
+ addr = song.orderlist_ptr(0, voice)
23
+ print(f" voice {voice}: orderlist @ ${addr:04X}")
24
+
25
+
26
+ def _reglog(args) -> None:
27
+ song = read(args.song)
28
+ frames = round(args.seconds * 50)
29
+ writes = reglog.iter_register_writes(song, max_frames=frames)
30
+ reglog.write_reglog(writes, args.output)
31
+ print(f"wrote {args.output}")
32
+
33
+
34
+ def _parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="pyjch", description="JCH NewPlayer song tools"
37
+ )
38
+ commands = parser.add_subparsers(dest="command", required=True)
39
+
40
+ info = commands.add_parser("info", help="print song metadata")
41
+ info.add_argument("song", help="JCH NewPlayer .sid/.prg file")
42
+ info.set_defaults(func=_info)
43
+
44
+ log = commands.add_parser("reglog", help="write a SID register log")
45
+ log.add_argument("song", help="JCH NewPlayer .sid/.prg file")
46
+ log.add_argument("output", help="register log file to write")
47
+ log.add_argument("--seconds", type=float, default=60.0)
48
+ log.set_defaults(func=_reglog)
49
+ return parser
50
+
51
+
52
+ def main(argv=None) -> int:
53
+ """CLI entry point; returns a process exit code."""
54
+ args = _parser().parse_args(argv)
55
+ try:
56
+ args.func(args)
57
+ except (JCHError, OSError) as exc:
58
+ print(f"error: {exc}", file=sys.stderr)
59
+ return 1
60
+ return 0
61
+
62
+
63
+ if __name__ == "__main__":
64
+ sys.exit(main())
pyjch/constants.py ADDED
@@ -0,0 +1,86 @@
1
+ """Constants for the JCH NewPlayer and song format.
2
+
3
+ Values follow the reverse-engineered JCH NewPlayer
4
+ (``re-trackers/JCH_NewPlayer/{decompile.c,disasm.asm}``), cross-checked
5
+ byte-exact against the ``preframr-sidtrace`` register oracle. The player
6
+ code is identical across JCH tunes; only its DATA (orderlists, subpatterns,
7
+ instruments, frequency tables and the addresses they live at) differs per
8
+ tune, so the reader DISCOVERS each per-tune immediate and table base from
9
+ the player code's 16-bit operands (relocation-safe -- no fixed-address
10
+ assumption).
11
+ """
12
+
13
+ # SID register map.
14
+ SID_REGISTERS = 25
15
+ VOICES = 3
16
+ VOICE_REG_SIZE = 7
17
+
18
+ # Per-voice SID base offsets ($D400 + offset).
19
+ SID_OFFSET = (0, 7, 14)
20
+
21
+ # Pulse-width high registers carry only their low nibble (12-bit pulse).
22
+ PW_HI_REGS = (0x03, 0x0A, 0x11)
23
+
24
+ # Per-tune operand offsets-from-load. The player binary is identical
25
+ # across JCH tunes, so each per-tune immediate / pointer-table base lives
26
+ # at a fixed offset in the code; the reader reads the byte (or 16-bit
27
+ # operand) there to discover the per-tune value (relocation-safe).
28
+ OP_INIT_AD = 0x0AA # LDA #imm @ $10AA -> attack/decay default
29
+ OP_INIT_SR = 0x0B5 # LDA #imm @ $10B5 -> sustain/release default
30
+ OP_GATEOFF_CTRL = 0x199 # LDA #imm @ $1198 -> gate-off / rest CTRL
31
+ OP_GATE_CTRL = 0x1E1 # LDA #imm @ $11E0 -> gate-on CTRL
32
+ OP_SUBPTR_LO = 0x12C # LDA abs,Y operand @ $112B -> subpattern ptr lo base
33
+ OP_SUBPTR_HI = 0x131 # LDA abs,Y operand @ $1130 -> subpattern ptr hi base
34
+
35
+ # Fixed in-image work / data addresses the player reads (absolute, relative
36
+ # to load; the player binary places them at the same offsets across tunes).
37
+ ORDERLIST_PTR_TABLE = 0x010 # $1010: per-subtune orderlist ptr pairs (8/subtune)
38
+ FREQ_LO = 0x21F # $121F: note frequency table, low bytes (0x80 entries)
39
+ FREQ_HI = 0x220 # $1220: note frequency table, high bytes
40
+ FREQ_TABLE_LEN = 0x80
41
+
42
+ # Per-voice transpose / detune immediates the play routine indexes by X.
43
+ VOICE_TRANSPOSE = 0x00A # $100A,X : per-voice arp/transpose base byte
44
+ VOICE_DETUNE = 0x00D # $100D,X : per-voice frequency add (detune)
45
+
46
+ # Player work-RAM addresses (the play routine's $..,X arrays). Init writes
47
+ # only a handful; the rest keep their loaded-image value.
48
+ WORK_CUR_PTR_LO = 0x2DF # $12DF,X : current opcode-stream pointer, low
49
+ WORK_CUR_PTR_HI = 0x2E2 # $12E2,X : current opcode-stream pointer, high
50
+ WORK_BASE_PTR_LO = 0x2E5 # $12E5,X : orderlist base pointer, low
51
+ WORK_BASE_PTR_HI = 0x2E8 # $12E8,X : orderlist base pointer, high
52
+ WORK_SAVE_PTR_LO = 0x2EB # $12EB,X : saved (pushed) pointer, low
53
+ WORK_SAVE_PTR_HI = 0x2EE # $12EE,X : saved (pushed) pointer, high
54
+ WORK_SID_OFFSET = 0x2F1 # $12F1,X : SID voice register offset (0,7,14)
55
+ WORK_TEMPO = 0x2F4 # $12F4 / $12F5 : tempo counter / reload
56
+ WORK_GATE_CTR = 0x2F9 # $12F9,X : per-voice gate-delay counter
57
+ WORK_GATE_RELOAD = 0x2F6 # $12F6,X : per-voice gate-delay reload
58
+ WORK_GATE_FLAG = 0x2FC # $12FC,X : gate-this-frame flag
59
+ WORK_FREQ_LO = 0x2FF # $12FF,X : computed FREQ_LO
60
+ WORK_FREQ_HI = 0x302 # $1302,X : computed FREQ_HI
61
+ WORK_TRANSPOSE = 0x305 # $1305,X : current transpose offset
62
+ WORK_SUBPAT_DEPTH = 0x308 # $1308,X : in-subpattern flag/depth
63
+
64
+ # Default subtune orderlist-pointer index (the 7th pair feeds the tempo).
65
+ TEMPO_PTR_INDEX = 0x2F4
66
+
67
+ # Zero-page opcode-stream pointer used by the play routine.
68
+ ZP_PTR_LO = 0xFB
69
+ ZP_PTR_HI = 0xFC
70
+
71
+ # SID register init values written by the JCH init routine.
72
+ INIT_CTRL = 0x88 # $D404/$D40B/$D412
73
+ INIT_PW_HI_V0 = 0x04 # $D403
74
+ INIT_PW_HI_V12 = 0x01 # $D40A / $D411
75
+ INIT_VOLUME = 0x0F # $D418
76
+
77
+ # C64 timing. A PAL frame is 312 rasterlines x 63 cycles.
78
+ PAL_CLOCK_HZ = 985248
79
+ PAL_CYCLES_PER_FRAME = 19656
80
+ NTSC_CLOCK_HZ = 1022727
81
+ NTSC_CYCLES_PER_FRAME = 17095
82
+
83
+ # Standard JCH NewPlayer entry points (PSID header normally matches).
84
+ DEFAULT_LOAD = 0x1000
85
+ DEFAULT_INIT = 0x1060
86
+ DEFAULT_PLAY = 0x10E8
pyjch/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ """Exceptions raised by pyjch."""
2
+
3
+
4
+ class JCHError(Exception):
5
+ """Base class for all pyjch errors."""
6
+
7
+
8
+ class SidParseError(JCHError):
9
+ """A PSID/RSID/PRG image (or byte string) could not be parsed."""
pyjch/model.py ADDED
@@ -0,0 +1,80 @@
1
+ """Typed model of a JCH NewPlayer song.
2
+
3
+ A :class:`Song` holds everything the reader recovers from a JCH tune
4
+ image: the per-tune discovered immediates (the AD/SR defaults and the
5
+ gate-on / gate-off CTRL bytes), the relocated subpattern pointer-table
6
+ bases, the per-subtune orderlist pointer pairs, the split note-frequency
7
+ tables, and the raw whole-image bytes. The player reads the opcode
8
+ streams (orderlists + subpatterns) directly out of ``image`` via the
9
+ discovered addresses, exactly as the 6502 player does, so a relocated
10
+ tune of the same player plays unchanged.
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import List
15
+
16
+ from pyjch import constants
17
+
18
+
19
+ @dataclass
20
+ class Song:
21
+ """A complete JCH NewPlayer song.
22
+
23
+ ``load_addr`` is the C64 load address; ``image`` the raw whole-image
24
+ bytes (player + data). ``init_ad`` / ``init_sr`` are the discovered
25
+ attack-decay / sustain-release defaults; ``gate_ctrl`` / ``gateoff_ctrl``
26
+ the discovered gate-on / gate-off (rest) CTRL bytes; ``subptr_lo`` /
27
+ ``subptr_hi`` the (relocated) subpattern pointer-table base addresses;
28
+ ``orderlist_ptr_table`` the absolute base of the per-subtune orderlist
29
+ pointer pairs; ``freq_lo`` / ``freq_hi`` the split note-frequency
30
+ tables.
31
+ """
32
+
33
+ # pylint: disable=too-many-instance-attributes # a full tune is wide
34
+ load_addr: int = constants.DEFAULT_LOAD
35
+ init_addr: int = constants.DEFAULT_INIT
36
+ play_addr: int = constants.DEFAULT_PLAY
37
+ name: str = ""
38
+ author: str = ""
39
+ released: str = ""
40
+ # Per-tune discovered immediates.
41
+ init_ad: int = 0
42
+ init_sr: int = 0
43
+ gate_ctrl: int = 0
44
+ gateoff_ctrl: int = 0
45
+ # Per-tune discovered (relocated) table bases (absolute addresses).
46
+ subptr_lo: int = 0
47
+ subptr_hi: int = 0
48
+ orderlist_ptr_table: int = constants.DEFAULT_LOAD + constants.ORDERLIST_PTR_TABLE
49
+ # Split note-frequency tables.
50
+ freq_lo: List[int] = field(default_factory=list)
51
+ freq_hi: List[int] = field(default_factory=list)
52
+ # Raw whole-image bytes (player + data) the player reads opcode streams
53
+ # and pointer tables out of, via the discovered addresses.
54
+ image: bytes = b""
55
+ # The original container header (PSID/RSID bytes before the image), kept
56
+ # for reference; empty for a bare ``.prg``.
57
+ container_header: bytes = b""
58
+
59
+ def orderlist_ptr(self, subtune: int, voice: int) -> int:
60
+ """Absolute address of ``voice``'s orderlist for ``subtune``.
61
+
62
+ The init routine indexes the pointer table by ``subtune * 8`` and
63
+ reads three consecutive 2-byte pairs (one per voice).
64
+ """
65
+ base = self.orderlist_ptr_table + (subtune << 3) + voice * 2
66
+ lo = self._byte(base)
67
+ hi = self._byte(base + 1)
68
+ return lo | (hi << 8)
69
+
70
+ def subpattern_ptr(self, index: int) -> int:
71
+ """Absolute address of subpattern ``index`` (0-127)."""
72
+ lo = self._byte(self.subptr_lo + index)
73
+ hi = self._byte(self.subptr_hi + index)
74
+ return lo | (hi << 8)
75
+
76
+ def _byte(self, abs_addr: int) -> int:
77
+ idx = abs_addr - self.load_addr
78
+ if 0 <= idx < len(self.image):
79
+ return self.image[idx]
80
+ return 0
pyjch/player.py ADDED
@@ -0,0 +1,300 @@
1
+ """JCH NewPlayer playroutine in Python.
2
+
3
+ A faithful, byte-exact transcription of the JCH NewPlayer's init
4
+ (``FUN_1060``) and play (``FUN_10e8``) routines, recovered from the
5
+ player's disassembly (``re-trackers/JCH_NewPlayer/disasm.asm``) and
6
+ validated byte-exact against the ``preframr-sidtrace`` register oracle.
7
+
8
+ The transcription keeps full 6502 semantics: a 64K memory image (the
9
+ player walks per-voice opcode streams via the zero-page pointer
10
+ ``$fb/$fc``, reads its work-RAM and pointer tables out of the loaded
11
+ image, and writes the SID registers into ``$D400..``). The per-tune
12
+ ADDRESSES and immediates it operates on are discovered by the reader, so
13
+ a relocated tune of the same player plays unchanged.
14
+
15
+ Each :meth:`Player.play_frame` call runs one frame and returns the SID
16
+ register writes for that frame as ``(register, value)`` pairs in
17
+ ascending register order (all 25 on the first frame, only changed
18
+ registers afterwards).
19
+ """
20
+
21
+ from typing import List, Optional
22
+
23
+ from pyjch import constants
24
+ from pyjch.model import Song
25
+
26
+ _D400 = 0xD400
27
+
28
+
29
+ class Player:
30
+ """Play a :class:`~pyjch.model.Song` one frame at a time."""
31
+
32
+ # pylint: disable=too-many-instance-attributes # full player machine state
33
+ def __init__(self, song: Song, subtune: int = 0):
34
+ self._song = song
35
+ self._load = song.load_addr
36
+ # 64K memory; place the image at its load address.
37
+ self._mem = bytearray(0x10000)
38
+ for i, byte in enumerate(song.image):
39
+ self._mem[(self._load + i) & 0xFFFF] = byte
40
+ # Per-tune discovered values (already resolved by the reader).
41
+ self._init_ad = song.init_ad
42
+ self._init_sr = song.init_sr
43
+ self._gate_ctrl = song.gate_ctrl
44
+ self._gateoff_ctrl = song.gateoff_ctrl
45
+ self._subptr_lo = song.subptr_lo
46
+ self._subptr_hi = song.subptr_hi
47
+ self.regs = [0] * constants.SID_REGISTERS
48
+ self._last_regs: Optional[List[int]] = None
49
+ self._init(subtune)
50
+
51
+ # -- raw memory access -----------------------------------------------
52
+ def _rd(self, addr: int) -> int:
53
+ return self._mem[addr & 0xFFFF]
54
+
55
+ def _wr(self, addr: int, val: int) -> None:
56
+ self._mem[addr & 0xFFFF] = val & 0xFF
57
+
58
+ # -- init: FUN_1060(subtune) -----------------------------------------
59
+ def _init(self, subtune: int = 0) -> None:
60
+ m = self._mem
61
+ load = self._load
62
+ # Y = subtune*8; loop X=0..2 copying orderlist ptr pairs into the
63
+ # per-voice cur/base pointers.
64
+ y = (subtune << 3) & 0xFF
65
+ for x in range(3):
66
+ uvar = m[(load + constants.ORDERLIST_PTR_TABLE + y) & 0xFFFF]
67
+ m[(load + constants.WORK_CUR_PTR_LO + x) & 0xFFFF] = uvar
68
+ m[(load + constants.WORK_BASE_PTR_LO + x) & 0xFFFF] = uvar
69
+ uvar = m[(load + constants.ORDERLIST_PTR_TABLE + y + 1) & 0xFFFF]
70
+ m[(load + constants.WORK_CUR_PTR_HI + x) & 0xFFFF] = uvar
71
+ m[(load + constants.WORK_BASE_PTR_HI + x) & 0xFFFF] = uvar
72
+ y = (y + 2) & 0xFF
73
+ # Tempo reload from the 7th pair byte.
74
+ m[(load + constants.WORK_TEMPO) & 0xFFFF] = m[
75
+ (load + constants.ORDERLIST_PTR_TABLE + y) & 0xFFFF
76
+ ]
77
+ m[(load + constants.WORK_TEMPO + 1) & 0xFFFF] = m[
78
+ (load + constants.WORK_TEMPO) & 0xFFFF
79
+ ]
80
+ for x in range(6):
81
+ m[(load + 0x305 + x) & 0xFFFF] = 0
82
+ for x in range(0x19):
83
+ m[(_D400 + x) & 0xFFFF] = 0
84
+ m[_D400 + 0x04] = constants.INIT_CTRL
85
+ m[_D400 + 0x0B] = constants.INIT_CTRL
86
+ m[_D400 + 0x12] = constants.INIT_CTRL
87
+ m[_D400 + 0x05] = self._init_ad
88
+ m[_D400 + 0x0C] = self._init_ad
89
+ m[_D400 + 0x13] = self._init_ad
90
+ m[_D400 + 0x06] = self._init_sr
91
+ m[_D400 + 0x0D] = self._init_sr
92
+ m[_D400 + 0x14] = self._init_sr
93
+ m[_D400 + 0x03] = constants.INIT_PW_HI_V0
94
+ m[_D400 + 0x0A] = constants.INIT_PW_HI_V12
95
+ m[_D400 + 0x11] = constants.INIT_PW_HI_V12
96
+ m[_D400 + 0x18] = constants.INIT_VOLUME
97
+ for x in range(3):
98
+ m[(load + constants.WORK_GATE_CTR + x) & 0xFFFF] = 0xFF
99
+ m[(load + constants.WORK_GATE_RELOAD + x) & 0xFFFF] = 0x03
100
+ self._snapshot_regs()
101
+
102
+ # -- play: FUN_10e8 --------------------------------------------------
103
+ def _frame(self) -> None:
104
+ load = self._load
105
+ rd = self._rd
106
+ wr = self._wr
107
+
108
+ x = 2
109
+ while True:
110
+ # 10f0: load cur ptr for voice X into the zero-page pointer.
111
+ wr(constants.ZP_PTR_LO, rd(load + constants.WORK_CUR_PTR_LO + x))
112
+ wr(constants.ZP_PTR_HI, rd(load + constants.WORK_CUR_PTR_HI + x))
113
+ a = rd(load + constants.WORK_GATE_CTR + x) # gate-delay counter
114
+ if a & 0x80: # BMI -> LAB_110a (a < 0)
115
+ self._note_advance(x)
116
+ else:
117
+ wr(load + constants.WORK_GATE_FLAG + x, 1)
118
+ # 11d7:
119
+ if rd(load + constants.WORK_GATE_FLAG + x) == 0:
120
+ wr(
121
+ _D400 + 0x04 + rd(load + constants.WORK_SID_OFFSET + x),
122
+ self._gate_ctrl,
123
+ )
124
+ # 11e5:
125
+ yreg = rd(load + constants.WORK_SID_OFFSET + x)
126
+ wr(_D400 + yreg, rd(load + constants.WORK_FREQ_LO + x))
127
+ wr(_D400 + 0x01 + yreg, rd(load + constants.WORK_FREQ_HI + x))
128
+ wr(load + constants.WORK_CUR_PTR_LO + x, rd(constants.ZP_PTR_LO))
129
+ wr(load + constants.WORK_CUR_PTR_HI + x, rd(constants.ZP_PTR_HI))
130
+ x = (x - 1) & 0xFF
131
+ if x & 0x80: # BMI 1204
132
+ break
133
+ # 1204: tempo divider; on wrap, decrement the per-voice gate counters.
134
+ tempo = (rd(load + constants.WORK_TEMPO) - 1) & 0xFF
135
+ wr(load + constants.WORK_TEMPO, tempo)
136
+ if tempo != 0:
137
+ return
138
+ wr(load + constants.WORK_TEMPO, rd(load + constants.WORK_TEMPO + 1))
139
+ for off in range(3):
140
+ wr(
141
+ load + constants.WORK_GATE_CTR + off,
142
+ (rd(load + constants.WORK_GATE_CTR + off) - 1) & 0xFF,
143
+ )
144
+
145
+ def _ptr_get(self) -> int:
146
+ return self._rd(constants.ZP_PTR_LO) | (self._rd(constants.ZP_PTR_HI) << 8)
147
+
148
+ def _inc_fb(self) -> None:
149
+ v = (self._rd(constants.ZP_PTR_LO) + 1) & 0xFF
150
+ self._wr(constants.ZP_PTR_LO, v)
151
+ if v == 0:
152
+ self._wr(constants.ZP_PTR_HI, (self._rd(constants.ZP_PTR_HI) + 1) & 0xFF)
153
+
154
+ def _note_advance(self, x: int) -> None:
155
+ # pylint: disable=too-many-statements,too-many-branches # 6502 label graph
156
+ load = self._load
157
+ rd = self._rd
158
+ wr = self._wr
159
+ bvar1 = 0 # latched between labels (the 6502 accumulator)
160
+ pc = "110a"
161
+ while True:
162
+ if pc == "110a":
163
+ wr(
164
+ load + constants.WORK_GATE_CTR + x,
165
+ rd(load + constants.WORK_GATE_RELOAD + x),
166
+ )
167
+ pc = "1110"
168
+ elif pc == "1110":
169
+ pc = (
170
+ "1118"
171
+ if rd(load + constants.WORK_SUBPAT_DEPTH + x) == 0
172
+ else "1163"
173
+ )
174
+ elif pc == "1118":
175
+ bvar1 = rd(self._ptr_get()) # LDA (fb),Y Y=0
176
+ if bvar1 < 0x80: # CMP #$80; BPL 113b -> else (<$80) fallthrough
177
+ # 1120: subpattern-ref; push cur ptr, jump via subptr tables.
178
+ yidx = bvar1
179
+ wr(load + constants.WORK_SAVE_PTR_LO + x, rd(constants.ZP_PTR_LO))
180
+ wr(load + constants.WORK_SAVE_PTR_HI + x, rd(constants.ZP_PTR_HI))
181
+ wr(constants.ZP_PTR_LO, rd(self._subptr_lo + yidx))
182
+ wr(constants.ZP_PTR_HI, rd(self._subptr_hi + yidx))
183
+ wr(
184
+ load + constants.WORK_SUBPAT_DEPTH + x,
185
+ (rd(load + constants.WORK_SUBPAT_DEPTH + x) + 1) & 0xFF,
186
+ )
187
+ pc = "1163"
188
+ else: # 113b
189
+ if (bvar1 & 0xE0) != 0x80: # BNE 1155
190
+ pc = "1155"
191
+ else:
192
+ v = (((bvar1 & 0x1F) - 0x0C) << 1) & 0xFF
193
+ wr(load + constants.WORK_TRANSPOSE + x, v)
194
+ self._inc_fb()
195
+ pc = "1118"
196
+ elif pc == "1155":
197
+ wr(constants.ZP_PTR_LO, rd(load + constants.WORK_BASE_PTR_LO + x))
198
+ wr(constants.ZP_PTR_HI, rd(load + constants.WORK_BASE_PTR_HI + x))
199
+ pc = "110a"
200
+ elif pc == "1163":
201
+ bvar1 = rd(self._ptr_get())
202
+ if bvar1 >= 0x80: # BPL 11a5 -> else (>=$80)
203
+ pc = "11a5"
204
+ else:
205
+ c = (bvar1 << 1) & 0xFF # ASL A
206
+ if c == 0: # BEQ 1195
207
+ pc = "1195"
208
+ elif c == 0xFE: # CMP #$fe; BEQ 119d
209
+ pc = "119d"
210
+ else:
211
+ bv2 = (
212
+ c
213
+ + rd(load + constants.VOICE_TRANSPOSE + x)
214
+ + rd(load + constants.WORK_TRANSPOSE + x)
215
+ ) & 0xFF
216
+ lo = rd(load + constants.FREQ_LO + bv2)
217
+ add = rd(load + constants.VOICE_DETUNE + x)
218
+ s = lo + add
219
+ wr(load + constants.WORK_FREQ_LO + x, s & 0xFF)
220
+ hi = rd(load + constants.FREQ_HI + bv2)
221
+ wr(
222
+ load + constants.WORK_FREQ_HI + x,
223
+ (hi + (1 if s > 0xFF else 0)) & 0xFF,
224
+ )
225
+ wr(load + constants.WORK_GATE_FLAG + x, 0)
226
+ pc = "11d1"
227
+ elif pc == "1195":
228
+ wr(
229
+ _D400 + 0x04 + rd(load + constants.WORK_SID_OFFSET + x),
230
+ self._gateoff_ctrl,
231
+ )
232
+ pc = "119d"
233
+ elif pc == "119d":
234
+ wr(load + constants.WORK_GATE_FLAG + x, 1)
235
+ pc = "11d1"
236
+ elif pc == "11a5":
237
+ if (bvar1 & 0xF0) == 0x80: # BNE 11b8 -> else (==$80)
238
+ val = bvar1 & 0x0F
239
+ wr(load + constants.WORK_GATE_CTR + x, val)
240
+ wr(load + constants.WORK_GATE_RELOAD + x, val)
241
+ pc = "11c8"
242
+ else:
243
+ pc = "11b8"
244
+ elif pc == "11b8":
245
+ wr(load + constants.WORK_SUBPAT_DEPTH + x, 0)
246
+ wr(constants.ZP_PTR_LO, rd(load + constants.WORK_SAVE_PTR_LO + x))
247
+ wr(constants.ZP_PTR_HI, rd(load + constants.WORK_SAVE_PTR_HI + x))
248
+ pc = "11c8"
249
+ elif pc == "11c8":
250
+ self._inc_fb()
251
+ pc = "1110"
252
+ elif pc == "11d1":
253
+ self._inc_fb()
254
+ return
255
+
256
+ # -- frame output / public API ---------------------------------------
257
+ def _snapshot_regs(self) -> None:
258
+ self.regs = [self._mem[_D400 + i] for i in range(constants.SID_REGISTERS)]
259
+
260
+ def play_frame(self) -> List[tuple]:
261
+ """Run one player tick; return this frame's SID register writes.
262
+
263
+ The first frame returns all 25 registers; later frames return only
264
+ registers whose value changed.
265
+ """
266
+ self._frame()
267
+ self._snapshot_regs()
268
+ if self._last_regs is None:
269
+ writes = list(enumerate(self.regs))
270
+ else:
271
+ writes = [
272
+ (reg, value)
273
+ for reg, (value, last) in enumerate(zip(self.regs, self._last_regs))
274
+ if value != last
275
+ ]
276
+ self._last_regs = list(self.regs)
277
+ return writes
278
+
279
+
280
+ def iter_frames(song: Song, max_frames: Optional[int] = None):
281
+ """Yield per-frame register write lists for ``song``.
282
+
283
+ Stops after ``max_frames`` frames (required for a non-looping render,
284
+ since the JCH player loops forever).
285
+ """
286
+ player = Player(song)
287
+ frame = 0
288
+ while max_frames is None or frame < max_frames:
289
+ yield player.play_frame()
290
+ frame += 1
291
+
292
+
293
+ def render_grid(song: Song, nframes: int) -> List[List[int]]:
294
+ """Render ``nframes`` of forward-filled per-frame register snapshots."""
295
+ player = Player(song)
296
+ rows: List[List[int]] = []
297
+ for _ in range(nframes):
298
+ player.play_frame()
299
+ rows.append(player.regs[:])
300
+ return rows