pydefmon 0.2.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.
- pydefmon/__init__.py +62 -0
- pydefmon/_load_format.py +304 -0
- pydefmon/defmon.py +1336 -0
- pydefmon/defmon_player.py +1578 -0
- pydefmon-0.2.0.dist-info/METADATA +150 -0
- pydefmon-0.2.0.dist-info/RECORD +10 -0
- pydefmon-0.2.0.dist-info/WHEEL +5 -0
- pydefmon-0.2.0.dist-info/entry_points.txt +2 -0
- pydefmon-0.2.0.dist-info/licenses/LICENSE +201 -0
- pydefmon-0.2.0.dist-info/top_level.txt +1 -0
pydefmon/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Python reader / writer / player for defMON C64 tracker tunes.
|
|
2
|
+
|
|
3
|
+
Top-level API:
|
|
4
|
+
|
|
5
|
+
* :class:`DefmonSong` — read / edit / write defMON ``.prg`` files
|
|
6
|
+
via :meth:`DefmonSong.from_file` and :meth:`DefmonSong.to_file`.
|
|
7
|
+
* :class:`PatternEvent` — one 4-byte step in a pattern body
|
|
8
|
+
(flag + slot_a + slot_b + note).
|
|
9
|
+
* :class:`SidtabRow` — one 15-byte sidTAB row (per-frame envelope
|
|
10
|
+
/ timbre / filter program; bitmap-encoded columns).
|
|
11
|
+
* :class:`SidcallFrame` — one row of a sidTAB cascade walk
|
|
12
|
+
starting from a given row index.
|
|
13
|
+
* :class:`DefmonPlayer` — frame-accurate per-NMI player IRQ model
|
|
14
|
+
(byte-faithful against the real defMON binary running in
|
|
15
|
+
asid-vice, verified by the integration test suite).
|
|
16
|
+
* :class:`Voice` — per-voice runtime record exposed for
|
|
17
|
+
introspection / programmatic poking.
|
|
18
|
+
|
|
19
|
+
Constants:
|
|
20
|
+
|
|
21
|
+
* :data:`LOAD_ADDRESS` — ``$1800``, where defMON tunes load.
|
|
22
|
+
* :data:`STANDARD_SNAPSHOT_END` / :data:`STANDARD_SNAPSHOT_SIZE`
|
|
23
|
+
— the runtime RAM image end (``$7167``) and length (22887).
|
|
24
|
+
* :data:`NOTE_PITCH_LO` / :data:`NOTE_PITCH_HI` — 128-byte
|
|
25
|
+
note-to-SID-freq LUTs the player walks.
|
|
26
|
+
|
|
27
|
+
For raw byte-level work on defMON's ``$D6C9`` LOAD codec, see
|
|
28
|
+
:mod:`pydefmon._load_format` (private). Most users only need
|
|
29
|
+
:class:`DefmonSong` and :class:`DefmonPlayer`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from pydefmon.defmon import (
|
|
33
|
+
DefmonError,
|
|
34
|
+
DefmonSong,
|
|
35
|
+
LOAD_ADDRESS,
|
|
36
|
+
NOTE_PITCH_HI,
|
|
37
|
+
NOTE_PITCH_LO,
|
|
38
|
+
PatternEvent,
|
|
39
|
+
SidcallFrame,
|
|
40
|
+
SidtabRow,
|
|
41
|
+
STANDARD_SNAPSHOT_END,
|
|
42
|
+
STANDARD_SNAPSHOT_SIZE,
|
|
43
|
+
)
|
|
44
|
+
from pydefmon.defmon_player import DefmonPlayer, Voice
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"DefmonError",
|
|
48
|
+
"DefmonPlayer",
|
|
49
|
+
"DefmonSong",
|
|
50
|
+
"LOAD_ADDRESS",
|
|
51
|
+
"NOTE_PITCH_HI",
|
|
52
|
+
"NOTE_PITCH_LO",
|
|
53
|
+
"PatternEvent",
|
|
54
|
+
"SidcallFrame",
|
|
55
|
+
"SidtabRow",
|
|
56
|
+
"STANDARD_SNAPSHOT_END",
|
|
57
|
+
"STANDARD_SNAPSHOT_SIZE",
|
|
58
|
+
"Voice",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
__version__ = "0.2.0"
|
pydefmon/_load_format.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""defMON's ``$D6C9`` LOAD-time codec — RLE decoder + encoder.
|
|
2
|
+
|
|
3
|
+
Private to :mod:`pydefmon`; reach for :class:`pydefmon.DefmonSong` to
|
|
4
|
+
read/write defMON ``.prg`` files. The codec sits underneath
|
|
5
|
+
:meth:`DefmonSong.from_bytes` / :meth:`DefmonSong.to_bytes` and
|
|
6
|
+
matches the real defMON loader byte-for-byte (a freshly-encoded
|
|
7
|
+
tune is byte-loadable by the original binary).
|
|
8
|
+
|
|
9
|
+
Two entry points:
|
|
10
|
+
|
|
11
|
+
* :func:`decode_load_stream` — parses a defMON PRG body into a
|
|
12
|
+
``{addr: byte}`` write map. Inverse of the on-chip ``$D6C9``
|
|
13
|
+
backward-walking decoder.
|
|
14
|
+
* :func:`encode_ram_block` — builds a defMON-loadable PRG (load
|
|
15
|
+
address header + body) that decodes back to a given RAM block.
|
|
16
|
+
Greedy backward emission; the encoder picks any valid encoding
|
|
17
|
+
(defMON's own SAVE picks edit-history-dependent encodings that
|
|
18
|
+
pydefmon doesn't reproduce — both are valid PRGs that load to
|
|
19
|
+
the same RAM image).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
LOAD_ADDR = 0x1800
|
|
25
|
+
ESC = 0xFF
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CodecError(ValueError):
|
|
29
|
+
"""Raised on malformed input to decode or encode."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- decode -----------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decode_load_stream(
|
|
36
|
+
body: bytes,
|
|
37
|
+
src_end_addr: int,
|
|
38
|
+
src_floor: int,
|
|
39
|
+
dest_start: int,
|
|
40
|
+
max_iters: int = 1_000_000,
|
|
41
|
+
) -> tuple[dict[int, int], int]:
|
|
42
|
+
"""Apply the $D6C9 backward RLE decoder to ``body``.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
body: file body bytes as loaded into RAM at $1800.
|
|
46
|
+
src_end_addr: starting src address, = body_end_addr - 5
|
|
47
|
+
(= $1800 + len(body) - 5).
|
|
48
|
+
src_floor: src must stay >= src_floor; below it, decoder exits.
|
|
49
|
+
dest_start: dest start address; walks downward from here.
|
|
50
|
+
|
|
51
|
+
Returns ``(writes_by_addr, iterations)``.
|
|
52
|
+
"""
|
|
53
|
+
body_start = 0x1800
|
|
54
|
+
body_len = len(body)
|
|
55
|
+
|
|
56
|
+
def read(addr: int) -> int | None:
|
|
57
|
+
offset = addr - body_start
|
|
58
|
+
if 0 <= offset < body_len:
|
|
59
|
+
return body[offset]
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
src = src_end_addr
|
|
63
|
+
dest = dest_start
|
|
64
|
+
writes: dict[int, int] = {}
|
|
65
|
+
it = 0
|
|
66
|
+
|
|
67
|
+
for it in range(max_iters):
|
|
68
|
+
# defMON's $D6C9 termination check ($D6D5-$D6E1):
|
|
69
|
+
# SEC + SBC of src against (src_floor>>8 << 8 | src_floor&FF) — operand
|
|
70
|
+
# bytes at $D6D9 / $D6E0 patched to src_floor's lo/hi BEFORE the JSR
|
|
71
|
+
# $D6C9 call (see $CED7-$CEDE). BCS taken iff src >= src_floor (no
|
|
72
|
+
# borrow). When BCS is not taken, the fall-through path at
|
|
73
|
+
# $D6E3-$D6E9 can still do ONE more FF-escape if src_lo - src_floor_lo
|
|
74
|
+
# >= $FE (i.e., src in [$17FE, $17FF] for src_floor=$1800). Practical
|
|
75
|
+
# outcome verified by `harness/probe_jp_target_origin.py` on
|
|
76
|
+
# .AUTOMATAS2017: defMON keeps iterating down to src=$1800 (and
|
|
77
|
+
# occasionally one step below via the FF-escape continuation),
|
|
78
|
+
# whereas this decoder previously stopped at src=$1802 — missing
|
|
79
|
+
# the iterations that synthesise the JP-target bytes in $1800-$18FF.
|
|
80
|
+
if src < src_floor:
|
|
81
|
+
break
|
|
82
|
+
b0 = read(src + 0)
|
|
83
|
+
b1 = read(src + 1)
|
|
84
|
+
b2 = read(src + 2)
|
|
85
|
+
if b0 is None or b1 is None or b2 is None:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
if b1 != ESC:
|
|
89
|
+
writes[dest] = b2
|
|
90
|
+
dest -= 1
|
|
91
|
+
src -= 1
|
|
92
|
+
elif b2 == ESC:
|
|
93
|
+
writes[dest] = ESC
|
|
94
|
+
dest -= 1
|
|
95
|
+
src -= 2
|
|
96
|
+
elif b0 == ESC:
|
|
97
|
+
writes[dest] = b2
|
|
98
|
+
dest -= 1
|
|
99
|
+
src -= 1
|
|
100
|
+
else:
|
|
101
|
+
count = b2 + 2
|
|
102
|
+
for _ in range(count):
|
|
103
|
+
writes[dest] = b0
|
|
104
|
+
dest -= 1
|
|
105
|
+
src -= 3
|
|
106
|
+
else:
|
|
107
|
+
raise CodecError(f"decode hit max_iters={max_iters}")
|
|
108
|
+
|
|
109
|
+
return writes, it + 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---- encode -----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def encode_load_stream(target: bytes | bytearray, dest_start: int) -> bytes:
|
|
116
|
+
"""Return a PRG (load-addr-prefixed) that decodes to ``target`` at
|
|
117
|
+
``dest_start..dest_start - len(target) + 1`` via the $D6C9 decoder.
|
|
118
|
+
|
|
119
|
+
``target[0]`` lands at ``dest_start``; ``target[i]`` at
|
|
120
|
+
``dest_start - i``.
|
|
121
|
+
"""
|
|
122
|
+
if not target:
|
|
123
|
+
raise CodecError("empty target")
|
|
124
|
+
if not 0x1800 <= dest_start <= 0xFFFF:
|
|
125
|
+
raise CodecError(f"dest_start ${dest_start:04X} out of range")
|
|
126
|
+
if dest_start - (len(target) - 1) < LOAD_ADDR:
|
|
127
|
+
raise CodecError(
|
|
128
|
+
f"target span would underflow load addr: "
|
|
129
|
+
f"dest_start=${dest_start:04X}, len={len(target)}"
|
|
130
|
+
)
|
|
131
|
+
dest_hi_byte = (dest_start >> 8) - 0x18
|
|
132
|
+
if not 0 <= dest_hi_byte <= 0xFF:
|
|
133
|
+
raise CodecError(
|
|
134
|
+
f"dest_hi out of range: ${dest_start:04X} -> {dest_hi_byte:#x}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
D = target
|
|
138
|
+
N = len(D)
|
|
139
|
+
S: dict[int, int] = {}
|
|
140
|
+
cost_sum = 0
|
|
141
|
+
|
|
142
|
+
def place(idx: int, val: int) -> None:
|
|
143
|
+
prev = S.get(idx)
|
|
144
|
+
if prev is not None and prev != val:
|
|
145
|
+
raise CodecError(f"S[{idx}] conflict: was {prev:#04x}, set {val:#04x}")
|
|
146
|
+
S[idx] = val
|
|
147
|
+
|
|
148
|
+
k = 0
|
|
149
|
+
last_iter_cost = 1
|
|
150
|
+
while k < N:
|
|
151
|
+
cur = D[k]
|
|
152
|
+
b2_idx = cost_sum
|
|
153
|
+
b1_idx = cost_sum + 1
|
|
154
|
+
b0_idx = cost_sum + 2
|
|
155
|
+
|
|
156
|
+
if cur == ESC:
|
|
157
|
+
place(b2_idx, ESC)
|
|
158
|
+
place(b1_idx, ESC)
|
|
159
|
+
cost_sum += 2
|
|
160
|
+
last_iter_cost = 2
|
|
161
|
+
k += 1
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
run_len = 1
|
|
165
|
+
while k + run_len < N and D[k + run_len] == cur and run_len < 256:
|
|
166
|
+
run_len += 1
|
|
167
|
+
|
|
168
|
+
if run_len >= 3:
|
|
169
|
+
count_byte = run_len - 2
|
|
170
|
+
place(b2_idx, count_byte)
|
|
171
|
+
place(b1_idx, ESC)
|
|
172
|
+
place(b0_idx, cur)
|
|
173
|
+
cost_sum += 3
|
|
174
|
+
last_iter_cost = 3
|
|
175
|
+
k += run_len
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
if k + 1 < N and D[k + 1] == ESC:
|
|
179
|
+
place(b2_idx, cur)
|
|
180
|
+
place(b1_idx, ESC)
|
|
181
|
+
place(b0_idx, ESC)
|
|
182
|
+
cost_sum += 1
|
|
183
|
+
last_iter_cost = 1
|
|
184
|
+
k += 1
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
place(b2_idx, cur)
|
|
188
|
+
cost_sum += 1
|
|
189
|
+
last_iter_cost = 1
|
|
190
|
+
k += 1
|
|
191
|
+
|
|
192
|
+
# body_size = cost_sum + 5 - last_iter_cost places the LAST iter's
|
|
193
|
+
# body bytes at body[0..2]. The factor depends on the last iter's
|
|
194
|
+
# path cost: Path A/C → +4, Path B → +3, Path D → +2.
|
|
195
|
+
# For tunes ending in a zero run (most), Path D wins and body_size
|
|
196
|
+
# = cost_sum + 2 — matching defMON byte-for-byte. For T11/T17 with
|
|
197
|
+
# isolated non-zero target bytes at $1804/$1807/$1809 (JP-source
|
|
198
|
+
# authoring), this also avoids the in-place collision cascade,
|
|
199
|
+
# because the JP-source bytes land at body offsets read before
|
|
200
|
+
# dest catches up. See [[project-d6c9-low-address-collision]].
|
|
201
|
+
body_size = cost_sum + 5 - last_iter_cost
|
|
202
|
+
body = bytearray(body_size)
|
|
203
|
+
for idx, val in S.items():
|
|
204
|
+
body_pos = body_size - 3 - idx
|
|
205
|
+
if 0 <= body_pos < body_size - 2:
|
|
206
|
+
body[body_pos] = val
|
|
207
|
+
|
|
208
|
+
body[body_size - 2] = dest_start & 0xFF
|
|
209
|
+
body[body_size - 1] = dest_hi_byte
|
|
210
|
+
|
|
211
|
+
_verify_inplace_decode(body, dest_start, target)
|
|
212
|
+
|
|
213
|
+
return bytes([LOAD_ADDR & 0xFF, LOAD_ADDR >> 8]) + bytes(body)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _verify_inplace_decode(
|
|
217
|
+
body_bytes: bytes | bytearray,
|
|
218
|
+
dest_start: int,
|
|
219
|
+
target: bytes | bytearray,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Simulate the $D6C9 decoder against a *mutable* body buffer.
|
|
222
|
+
|
|
223
|
+
Models the live-C64-RAM scenario where dest writes can overwrite
|
|
224
|
+
source bytes before src reads them. Raises ``CodecError`` if the
|
|
225
|
+
simulated decode does not reproduce ``target``.
|
|
226
|
+
"""
|
|
227
|
+
body_start = LOAD_ADDR
|
|
228
|
+
body_end = body_start + len(body_bytes)
|
|
229
|
+
work = bytearray(body_bytes)
|
|
230
|
+
above_body: dict[int, int] = {}
|
|
231
|
+
|
|
232
|
+
def read(addr: int) -> int:
|
|
233
|
+
if body_start <= addr < body_end:
|
|
234
|
+
return work[addr - body_start]
|
|
235
|
+
return above_body.get(addr, 0)
|
|
236
|
+
|
|
237
|
+
def write_dest(addr: int, val: int) -> None:
|
|
238
|
+
if body_start <= addr < body_end:
|
|
239
|
+
work[addr - body_start] = val
|
|
240
|
+
else:
|
|
241
|
+
above_body[addr] = val
|
|
242
|
+
|
|
243
|
+
src = body_end - 1 - 4
|
|
244
|
+
dest = dest_start
|
|
245
|
+
|
|
246
|
+
while src >= body_start:
|
|
247
|
+
b0 = read(src + 0)
|
|
248
|
+
b1 = read(src + 1)
|
|
249
|
+
b2 = read(src + 2)
|
|
250
|
+
if b1 != ESC:
|
|
251
|
+
write_dest(dest, b2)
|
|
252
|
+
dest -= 1
|
|
253
|
+
src -= 1
|
|
254
|
+
elif b2 == ESC:
|
|
255
|
+
write_dest(dest, ESC)
|
|
256
|
+
dest -= 1
|
|
257
|
+
src -= 2
|
|
258
|
+
elif b0 == ESC:
|
|
259
|
+
write_dest(dest, b2)
|
|
260
|
+
dest -= 1
|
|
261
|
+
src -= 1
|
|
262
|
+
else:
|
|
263
|
+
count = b2 + 2
|
|
264
|
+
for _ in range(count):
|
|
265
|
+
write_dest(dest, b0)
|
|
266
|
+
dest -= 1
|
|
267
|
+
src -= 3
|
|
268
|
+
|
|
269
|
+
target_top = dest_start
|
|
270
|
+
diffs: list[int] = []
|
|
271
|
+
for i, expected in enumerate(target):
|
|
272
|
+
addr = target_top - i
|
|
273
|
+
got = (
|
|
274
|
+
work[addr - body_start]
|
|
275
|
+
if body_start <= addr < body_end
|
|
276
|
+
else above_body.get(addr, 0)
|
|
277
|
+
)
|
|
278
|
+
if got != expected:
|
|
279
|
+
diffs.append(addr)
|
|
280
|
+
if len(diffs) >= 4:
|
|
281
|
+
break
|
|
282
|
+
if diffs:
|
|
283
|
+
raise CodecError(
|
|
284
|
+
f"in-place decode mismatch (likely dest-src collision); "
|
|
285
|
+
f"first {len(diffs)} bad addrs: "
|
|
286
|
+
f"{', '.join(f'${a:04X}' for a in diffs)}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def encode_ram_block(ram: bytes | bytearray, base_addr: int) -> bytes:
|
|
291
|
+
"""Encode a contiguous RAM block ``ram[0]@base_addr ... ram[-1]@base_addr+len-1``.
|
|
292
|
+
|
|
293
|
+
The $D6C9 decoder writes top-down, so the target byte list is
|
|
294
|
+
reversed and ``dest_start`` is the HIGHEST address.
|
|
295
|
+
"""
|
|
296
|
+
if not ram:
|
|
297
|
+
raise CodecError("empty ram block")
|
|
298
|
+
if not 0x1800 <= base_addr <= 0xFFFF:
|
|
299
|
+
raise CodecError(f"base_addr ${base_addr:04X} out of range")
|
|
300
|
+
top_addr = base_addr + len(ram) - 1
|
|
301
|
+
if top_addr > 0xFFFF:
|
|
302
|
+
raise CodecError(f"block ${base_addr:04X}+{len(ram)} overflows past $FFFF")
|
|
303
|
+
target = bytes(reversed(ram))
|
|
304
|
+
return encode_load_stream(target, dest_start=top_addr)
|