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 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"
@@ -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)