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 +30 -0
- pyjch/cli.py +64 -0
- pyjch/constants.py +86 -0
- pyjch/errors.py +9 -0
- pyjch/model.py +80 -0
- pyjch/player.py +300 -0
- pyjch/reader.py +132 -0
- pyjch/reglog.py +107 -0
- pyjch-0.1.0.dist-info/METADATA +334 -0
- pyjch-0.1.0.dist-info/RECORD +14 -0
- pyjch-0.1.0.dist-info/WHEEL +5 -0
- pyjch-0.1.0.dist-info/entry_points.txt +2 -0
- pyjch-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyjch-0.1.0.dist-info/top_level.txt +1 -0
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
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
|