sidwizard-driver 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.
- sidwizard_driver/__init__.py +16 -0
- sidwizard_driver/capture.py +191 -0
- sidwizard_driver/d64.py +180 -0
- sidwizard_driver/dump.py +144 -0
- sidwizard_driver/fetch.py +123 -0
- sidwizard_driver/fixtures/bronkosaurus.reference.csv +948 -0
- sidwizard_driver/fixtures/euphoria.reference.csv +626 -0
- sidwizard_driver/fixtures/flashitback.reference.csv +4910 -0
- sidwizard_driver/fixtures/rain8580.reference.csv +3578 -0
- sidwizard_driver/ghost_dump.py +210 -0
- sidwizard_driver/sidwizard.py +321 -0
- sidwizard_driver/smoke.py +82 -0
- sidwizard_driver-0.2.0.dist-info/METADATA +95 -0
- sidwizard_driver-0.2.0.dist-info/RECORD +17 -0
- sidwizard_driver-0.2.0.dist-info/WHEEL +5 -0
- sidwizard_driver-0.2.0.dist-info/licenses/LICENSE +201 -0
- sidwizard_driver-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""sidwizard-driver — Python harness for SID-Wizard inside asid-vice."""
|
|
2
|
+
|
|
3
|
+
from .d64 import build_d64_with_prg, write_d64_with_prg, write_d64_with_swm
|
|
4
|
+
from .fetch import fetch_disk1_d64
|
|
5
|
+
from .sidwizard import Sidwizard, SidwizardError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Sidwizard",
|
|
9
|
+
"SidwizardError",
|
|
10
|
+
"build_d64_with_prg",
|
|
11
|
+
"fetch_disk1_d64",
|
|
12
|
+
"write_d64_with_prg",
|
|
13
|
+
"write_d64_with_swm",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Capture SID register writes from SID-Wizard's real player to CSV."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from vice_driver import BinMon, DiskMount, ViceContainer, ViceContainerError
|
|
12
|
+
|
|
13
|
+
from .dump import PAL_CYCLES_PER_FRAME, decode_dump_file
|
|
14
|
+
from .fetch import fetch_disk1_d64
|
|
15
|
+
from .sidwizard import Sidwizard, SidwizardError
|
|
16
|
+
|
|
17
|
+
# SWM header: 2-byte PRG load address + 4-byte magic + framespeed byte at +4.
|
|
18
|
+
SWM_FRAMESPEED_OFFSET = 2 + 0x04
|
|
19
|
+
|
|
20
|
+
# anarkiwi/headlessvice's x64sc needs a writable $HOME/.local/state/vice.
|
|
21
|
+
VICE_STATE_DIR = "/root/.local/state/vice"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_swm_framespeed(swm_path: str) -> int:
|
|
25
|
+
with open(swm_path, "rb") as fp:
|
|
26
|
+
head = fp.read(SWM_FRAMESPEED_OFFSET + 1)
|
|
27
|
+
if len(head) <= SWM_FRAMESPEED_OFFSET:
|
|
28
|
+
raise ValueError(f"{swm_path}: too short to read framespeed")
|
|
29
|
+
fs = head[SWM_FRAMESPEED_OFFSET]
|
|
30
|
+
if not 1 <= fs <= 4:
|
|
31
|
+
raise ValueError(f"{swm_path}: unreasonable framespeed value {fs}")
|
|
32
|
+
return fs
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("sidwizard_driver.capture")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace: # pragma: no cover - CLI glue
|
|
39
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
40
|
+
p.add_argument("--d64", help="SID-Wizard editor .d64 (auto-fetched if omitted)")
|
|
41
|
+
p.add_argument("--swm", help="SWM module to play (required unless --smoke or --dump-only)")
|
|
42
|
+
p.add_argument("--frames", type=int, default=1500, help="number of PAL frames to capture")
|
|
43
|
+
p.add_argument("--out", required=True, help="output CSV path")
|
|
44
|
+
p.add_argument(
|
|
45
|
+
"--smoke", action="store_true", help="skip SWM load; capture default editor state"
|
|
46
|
+
)
|
|
47
|
+
p.add_argument("--dump-only", metavar="PATH", help="re-decode an existing dump file")
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--no-dedup", action="store_true", help="don't collapse consecutive duplicate writes"
|
|
50
|
+
)
|
|
51
|
+
p.add_argument("--image", default="anarkiwi/headlessvice:latest")
|
|
52
|
+
p.add_argument("--port", type=int, default=6502)
|
|
53
|
+
p.add_argument("--idle-timeout", type=float, default=60.0)
|
|
54
|
+
p.add_argument("--load-timeout", type=float, default=10.0)
|
|
55
|
+
p.add_argument("--capture-timeout", type=float, default=120.0)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"--mute-editor",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="zero $D400-$D418 at the pre-F1 checkpoint to suppress editor audition residue",
|
|
60
|
+
)
|
|
61
|
+
p.add_argument("-v", "--verbose", action="count", default=0)
|
|
62
|
+
return p.parse_args(argv)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decode_to_csv(
|
|
66
|
+
dump_path: str, out_path: str, dedup: bool
|
|
67
|
+
) -> int: # pragma: no cover - CLI glue
|
|
68
|
+
with open(out_path, "w", newline="") as fp:
|
|
69
|
+
return decode_dump_file(dump_path, fp, dedup=dedup)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int: # pragma: no cover - CLI entry point
|
|
73
|
+
args = _parse_args(argv)
|
|
74
|
+
logging.basicConfig(
|
|
75
|
+
level=logging.DEBUG if args.verbose >= 2 else logging.INFO,
|
|
76
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if args.dump_only:
|
|
80
|
+
if not os.path.isfile(args.dump_only):
|
|
81
|
+
print(f"not a file: {args.dump_only}", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
n = _decode_to_csv(args.dump_only, args.out, dedup=not args.no_dedup)
|
|
84
|
+
print(f"wrote {n} rows to {args.out}")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
if not args.smoke and not args.swm:
|
|
88
|
+
print("--swm is required (or pass --smoke)", file=sys.stderr)
|
|
89
|
+
return 2
|
|
90
|
+
|
|
91
|
+
if not args.d64:
|
|
92
|
+
args.d64 = str(fetch_disk1_d64())
|
|
93
|
+
|
|
94
|
+
return _run_live(args)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _run_live(args: argparse.Namespace) -> int: # pragma: no cover - requires live VICE
|
|
98
|
+
if not os.path.isfile(args.d64):
|
|
99
|
+
print(f"not a file: {args.d64}", file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
if args.swm and not os.path.isfile(args.swm):
|
|
102
|
+
print(f"not a file: {args.swm}", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
|
|
105
|
+
host_work_dir = tempfile.mkdtemp(prefix="sidwizard-driver-")
|
|
106
|
+
container_work_dir = "/tmp/sidwizard-driver"
|
|
107
|
+
host_dump = os.path.join(host_work_dir, "trace.txt")
|
|
108
|
+
container_dump = f"{container_work_dir}/trace.txt"
|
|
109
|
+
host_swm_d64 = os.path.join(host_work_dir, "tune.d64")
|
|
110
|
+
container_swm_d64 = f"{container_work_dir}/tune.d64"
|
|
111
|
+
host_vice_state = tempfile.mkdtemp(prefix="sidwizard-driver-vice-")
|
|
112
|
+
|
|
113
|
+
container_d64 = "/tmp/sidwizard-editor.d64"
|
|
114
|
+
|
|
115
|
+
mounts = [
|
|
116
|
+
DiskMount(host_path=args.d64, container_path=container_d64, read_only=True),
|
|
117
|
+
DiskMount(host_path=host_work_dir, container_path=container_work_dir, read_only=False),
|
|
118
|
+
DiskMount(host_path=host_vice_state, container_path=VICE_STATE_DIR, read_only=False),
|
|
119
|
+
]
|
|
120
|
+
container = ViceContainer(
|
|
121
|
+
image=args.image,
|
|
122
|
+
entrypoint="x64sc",
|
|
123
|
+
binmon_port=args.port,
|
|
124
|
+
autostart=container_d64,
|
|
125
|
+
mounts=mounts,
|
|
126
|
+
warp=True,
|
|
127
|
+
sounddev="dump",
|
|
128
|
+
sounddump_path=container_dump,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
framespeed = _read_swm_framespeed(args.swm) if args.swm else 1
|
|
132
|
+
cycles_per_frame = PAL_CYCLES_PER_FRAME // framespeed
|
|
133
|
+
target_cycles = args.frames * cycles_per_frame
|
|
134
|
+
|
|
135
|
+
start_cycle = 0
|
|
136
|
+
try:
|
|
137
|
+
with container:
|
|
138
|
+
with BinMon(port=args.port) as bm:
|
|
139
|
+
bm.exit()
|
|
140
|
+
sw = Sidwizard(bm)
|
|
141
|
+
tuneheader = sw.wait_for_idle(timeout=args.idle_timeout)
|
|
142
|
+
log.info("TUNEHEADER = $%04X", tuneheader)
|
|
143
|
+
|
|
144
|
+
if args.swm:
|
|
145
|
+
sw.load_swm_via_menu(
|
|
146
|
+
swm_path=args.swm,
|
|
147
|
+
host_d64_path=host_swm_d64,
|
|
148
|
+
container_d64_path=container_swm_d64,
|
|
149
|
+
tuneheader=tuneheader,
|
|
150
|
+
load_timeout=args.load_timeout,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Pre-arm a stop-on-hit checkpoint at $1003 (PLAYER
|
|
154
|
+
# dispatch) BEFORE F1, so frame 0 anchors to the first
|
|
155
|
+
# player tick instead of to the keypress (~0.5s of menu
|
|
156
|
+
# IRQ state).
|
|
157
|
+
pre_cp = bm.checkpoint_set(0x1003, stop_when_hit=True)
|
|
158
|
+
sw.play()
|
|
159
|
+
|
|
160
|
+
start_cycle = sw.cycle()
|
|
161
|
+
if args.mute_editor:
|
|
162
|
+
sw.clear_sid_registers()
|
|
163
|
+
bm.checkpoint_delete(pre_cp.checknum)
|
|
164
|
+
_, end_cycle = sw.wait_for_cycles(target_cycles, timeout=args.capture_timeout)
|
|
165
|
+
log.info("captured %d cycles", end_cycle - start_cycle)
|
|
166
|
+
except ViceContainerError as e:
|
|
167
|
+
print(f"VICE container error: {e}", file=sys.stderr)
|
|
168
|
+
return 4
|
|
169
|
+
except SidwizardError as e:
|
|
170
|
+
print(f"SID-Wizard error: {e}", file=sys.stderr)
|
|
171
|
+
return 5
|
|
172
|
+
|
|
173
|
+
if not os.path.isfile(host_dump):
|
|
174
|
+
print(f"no dump file produced at {host_dump}", file=sys.stderr)
|
|
175
|
+
return 6
|
|
176
|
+
|
|
177
|
+
with open(args.out, "w", newline="") as fp:
|
|
178
|
+
n = decode_dump_file(
|
|
179
|
+
host_dump,
|
|
180
|
+
fp,
|
|
181
|
+
cycles_per_frame=cycles_per_frame,
|
|
182
|
+
dedup=not args.no_dedup,
|
|
183
|
+
start_cycle=start_cycle,
|
|
184
|
+
max_frame=args.frames - 1,
|
|
185
|
+
)
|
|
186
|
+
print(f"wrote {n} rows to {args.out} (workdir preserved at {host_work_dir})")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
raise SystemExit(main())
|
sidwizard_driver/d64.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Minimal Commodore 1541 .d64 disk image writer (single PRG)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
SECTOR_SIZE = 256
|
|
6
|
+
TOTAL_SECTORS = 683
|
|
7
|
+
D64_SIZE = SECTOR_SIZE * TOTAL_SECTORS # 174848
|
|
8
|
+
|
|
9
|
+
SECTORS_PER_TRACK: list[int] = (
|
|
10
|
+
[21] * 17 # tracks 1..17
|
|
11
|
+
+ [19] * 7 # tracks 18..24
|
|
12
|
+
+ [18] * 6 # tracks 25..30
|
|
13
|
+
+ [17] * 5 # tracks 31..35
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
DIR_TRACK = 18
|
|
17
|
+
BAM_SECTOR = 0
|
|
18
|
+
FIRST_DIR_SECTOR = 1
|
|
19
|
+
|
|
20
|
+
SECTOR_DATA = SECTOR_SIZE - 2
|
|
21
|
+
|
|
22
|
+
PRG_START_TRACK = 17
|
|
23
|
+
PRG_START_SECTOR = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _track_sector_offset(track: int, sector: int) -> int:
|
|
27
|
+
if not 1 <= track <= 35:
|
|
28
|
+
raise ValueError(f"track out of range: {track}")
|
|
29
|
+
spt = SECTORS_PER_TRACK[track - 1]
|
|
30
|
+
if not 0 <= sector < spt:
|
|
31
|
+
raise ValueError(f"sector {sector} out of range for track {track} (0..{spt - 1})")
|
|
32
|
+
off = 0
|
|
33
|
+
for t in range(1, track):
|
|
34
|
+
off += SECTORS_PER_TRACK[t - 1]
|
|
35
|
+
return (off + sector) * SECTOR_SIZE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _petscii_name(name: str, fill: int = 0xA0, length: int = 16) -> bytes:
|
|
39
|
+
up = name.upper()
|
|
40
|
+
if not all(0x20 <= ord(c) < 0x7F for c in up):
|
|
41
|
+
raise ValueError(f"filename has non-printable ASCII: {name!r}")
|
|
42
|
+
raw = up.encode("ascii")
|
|
43
|
+
if len(raw) > length:
|
|
44
|
+
raise ValueError(f"filename too long (>{length} chars): {name!r}")
|
|
45
|
+
return raw + bytes([fill] * (length - len(raw)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_d64_with_prg(
|
|
49
|
+
prg_bytes: bytes,
|
|
50
|
+
filename: str,
|
|
51
|
+
disk_name: str = "SIDWIZARD",
|
|
52
|
+
disk_id: bytes = b"01",
|
|
53
|
+
) -> bytes:
|
|
54
|
+
"""Return a 174848-byte d64 image containing one PRG file."""
|
|
55
|
+
if len(prg_bytes) == 0:
|
|
56
|
+
raise ValueError("prg_bytes is empty")
|
|
57
|
+
if len(disk_id) != 2:
|
|
58
|
+
raise ValueError("disk_id must be exactly 2 bytes")
|
|
59
|
+
|
|
60
|
+
image = bytearray(D64_SIZE)
|
|
61
|
+
|
|
62
|
+
needed_sectors = (len(prg_bytes) + SECTOR_DATA - 1) // SECTOR_DATA
|
|
63
|
+
spt_17 = SECTORS_PER_TRACK[PRG_START_TRACK - 1]
|
|
64
|
+
if needed_sectors > spt_17:
|
|
65
|
+
# Refuse rather than silently implementing a multi-track chain
|
|
66
|
+
# we can't test offline; SWM files in practice fit one track.
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"prg too large for single-track chain on track {PRG_START_TRACK}: "
|
|
69
|
+
f"{needed_sectors} sectors needed, {spt_17} available"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
cursor = 0
|
|
73
|
+
used_sectors_on_prg_track: list[int] = []
|
|
74
|
+
for sector_index in range(needed_sectors):
|
|
75
|
+
sector_no = (PRG_START_SECTOR + sector_index) % spt_17
|
|
76
|
+
used_sectors_on_prg_track.append(sector_no)
|
|
77
|
+
is_last = sector_index == needed_sectors - 1
|
|
78
|
+
chunk = prg_bytes[cursor : cursor + SECTOR_DATA]
|
|
79
|
+
cursor += len(chunk)
|
|
80
|
+
sector_buf = bytearray(SECTOR_SIZE)
|
|
81
|
+
if is_last:
|
|
82
|
+
sector_buf[0] = 0
|
|
83
|
+
sector_buf[1] = len(chunk) + 1
|
|
84
|
+
else:
|
|
85
|
+
next_sector_no = (PRG_START_SECTOR + sector_index + 1) % spt_17
|
|
86
|
+
sector_buf[0] = PRG_START_TRACK
|
|
87
|
+
sector_buf[1] = next_sector_no
|
|
88
|
+
sector_buf[2 : 2 + len(chunk)] = chunk
|
|
89
|
+
off = _track_sector_offset(PRG_START_TRACK, sector_no)
|
|
90
|
+
image[off : off + SECTOR_SIZE] = sector_buf
|
|
91
|
+
|
|
92
|
+
dir_off = _track_sector_offset(DIR_TRACK, FIRST_DIR_SECTOR)
|
|
93
|
+
dir_sector = bytearray(SECTOR_SIZE)
|
|
94
|
+
dir_sector[0] = 0
|
|
95
|
+
dir_sector[1] = 0xFF
|
|
96
|
+
entry = dir_sector
|
|
97
|
+
entry[2] = 0x82 # closed PRG
|
|
98
|
+
entry[3] = PRG_START_TRACK
|
|
99
|
+
entry[4] = PRG_START_SECTOR
|
|
100
|
+
entry[5:21] = _petscii_name(filename, fill=0xA0, length=16)
|
|
101
|
+
entry[28:30] = b"\x00\x00"
|
|
102
|
+
size_blocks = needed_sectors
|
|
103
|
+
entry[30] = size_blocks & 0xFF
|
|
104
|
+
entry[31] = (size_blocks >> 8) & 0xFF
|
|
105
|
+
|
|
106
|
+
image[dir_off : dir_off + SECTOR_SIZE] = dir_sector
|
|
107
|
+
|
|
108
|
+
bam_off = _track_sector_offset(DIR_TRACK, BAM_SECTOR)
|
|
109
|
+
bam_sector = bytearray(SECTOR_SIZE)
|
|
110
|
+
bam_sector[0] = DIR_TRACK
|
|
111
|
+
bam_sector[1] = FIRST_DIR_SECTOR
|
|
112
|
+
bam_sector[2] = 0x41 # DOS 'A'
|
|
113
|
+
bam_sector[3] = 0x00
|
|
114
|
+
|
|
115
|
+
for track in range(1, 36):
|
|
116
|
+
spt = SECTORS_PER_TRACK[track - 1]
|
|
117
|
+
if track == DIR_TRACK:
|
|
118
|
+
free = 0
|
|
119
|
+
bitmap = 0
|
|
120
|
+
elif track == PRG_START_TRACK:
|
|
121
|
+
free = spt - len(used_sectors_on_prg_track)
|
|
122
|
+
bitmap = (1 << spt) - 1
|
|
123
|
+
for s in used_sectors_on_prg_track:
|
|
124
|
+
bitmap &= ~(1 << s)
|
|
125
|
+
else:
|
|
126
|
+
free = spt
|
|
127
|
+
bitmap = (1 << spt) - 1
|
|
128
|
+
slot = 4 + (track - 1) * 4
|
|
129
|
+
bam_sector[slot] = free
|
|
130
|
+
bam_sector[slot + 1] = bitmap & 0xFF
|
|
131
|
+
bam_sector[slot + 2] = (bitmap >> 8) & 0xFF
|
|
132
|
+
bam_sector[slot + 3] = (bitmap >> 16) & 0xFF
|
|
133
|
+
|
|
134
|
+
bam_sector[0x90:0xA0] = _petscii_name(disk_name, fill=0xA0, length=16)
|
|
135
|
+
bam_sector[0xA0] = 0xA0
|
|
136
|
+
bam_sector[0xA1] = 0xA0
|
|
137
|
+
bam_sector[0xA2:0xA4] = disk_id
|
|
138
|
+
bam_sector[0xA4] = 0xA0
|
|
139
|
+
bam_sector[0xA5] = 0x32
|
|
140
|
+
bam_sector[0xA6] = 0x41
|
|
141
|
+
bam_sector[0xA7:0xAB] = b"\xa0\xa0\xa0\xa0"
|
|
142
|
+
|
|
143
|
+
image[bam_off : bam_off + SECTOR_SIZE] = bam_sector
|
|
144
|
+
|
|
145
|
+
return bytes(image)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def write_d64_with_prg(out_path: str, prg_bytes: bytes, filename: str) -> None:
|
|
149
|
+
image = build_d64_with_prg(prg_bytes, filename)
|
|
150
|
+
with open(out_path, "wb") as fp:
|
|
151
|
+
fp.write(image)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def write_d64_with_swm(out_path: str, swm_path: str, filename: str | None = None) -> str:
|
|
155
|
+
"""Copy an on-disk ``.swm`` into a fresh single-file d64.
|
|
156
|
+
|
|
157
|
+
SID-Wizard's ``regname`` appends ``.SWM`` to the typed filename, so
|
|
158
|
+
the on-disk name must carry the extension too. Returns the on-disk
|
|
159
|
+
filename (with extension) so the caller can drive the file dialog.
|
|
160
|
+
"""
|
|
161
|
+
with open(swm_path, "rb") as fp:
|
|
162
|
+
swm_bytes = fp.read()
|
|
163
|
+
if filename is None:
|
|
164
|
+
import os
|
|
165
|
+
|
|
166
|
+
stem = os.path.splitext(os.path.basename(swm_path))[0].upper()
|
|
167
|
+
filename = stem + ".SWM"
|
|
168
|
+
write_d64_with_prg(out_path, swm_bytes, filename)
|
|
169
|
+
return filename
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"D64_SIZE",
|
|
174
|
+
"SECTOR_SIZE",
|
|
175
|
+
"TOTAL_SECTORS",
|
|
176
|
+
"SECTORS_PER_TRACK",
|
|
177
|
+
"build_d64_with_prg",
|
|
178
|
+
"write_d64_with_prg",
|
|
179
|
+
"write_d64_with_swm",
|
|
180
|
+
]
|
sidwizard_driver/dump.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Decoder for VICE ``sounddev=dump`` files.
|
|
2
|
+
|
|
3
|
+
Each SID write produces one space-separated decimal line:
|
|
4
|
+
|
|
5
|
+
<cycle_delta> <irq_delta> <nmi_delta> <chipno> <addr> <byte>
|
|
6
|
+
|
|
7
|
+
The legacy 3-field shape (``cycle_delta addr byte``, no chipno) is also
|
|
8
|
+
accepted. Pass ``cycles_per_frame`` for NTSC (17095) or framespeed=2.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterable, Iterator, Optional, TextIO
|
|
16
|
+
|
|
17
|
+
PAL_CYCLES_PER_FRAME = 19656
|
|
18
|
+
|
|
19
|
+
SID_REG_MIN = 0x00
|
|
20
|
+
SID_REG_MAX = 0x18
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DumpRecord:
|
|
25
|
+
cycle: int
|
|
26
|
+
chipno: int
|
|
27
|
+
reg: int
|
|
28
|
+
value: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def iter_records(stream: Iterable[str]) -> Iterator[DumpRecord]:
|
|
32
|
+
"""Yield ``DumpRecord`` for each dump line in ``stream``.
|
|
33
|
+
|
|
34
|
+
Tolerates a final partial line (VICE doesn't flush on shutdown),
|
|
35
|
+
but a short line followed by more data is treated as corruption.
|
|
36
|
+
"""
|
|
37
|
+
abs_cycle = 0
|
|
38
|
+
pending_partial: Optional[str] = None
|
|
39
|
+
for raw in stream:
|
|
40
|
+
if pending_partial is not None:
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"unrecognised dump record (got "
|
|
43
|
+
f"{len(pending_partial.split())} fields): {pending_partial!r}"
|
|
44
|
+
)
|
|
45
|
+
line = raw.strip()
|
|
46
|
+
if not line or line.startswith("#"):
|
|
47
|
+
continue
|
|
48
|
+
parts = line.split()
|
|
49
|
+
if len(parts) == 6:
|
|
50
|
+
clks, _irq, _nmi, chipno, addr, byte = (int(p) for p in parts)
|
|
51
|
+
elif len(parts) == 3:
|
|
52
|
+
clks, addr, byte = (int(p) for p in parts)
|
|
53
|
+
chipno = 0
|
|
54
|
+
else:
|
|
55
|
+
pending_partial = line
|
|
56
|
+
continue
|
|
57
|
+
abs_cycle += clks
|
|
58
|
+
yield DumpRecord(cycle=abs_cycle, chipno=chipno, reg=addr, value=byte)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def filter_sid(
|
|
62
|
+
records: Iterable[DumpRecord],
|
|
63
|
+
chipno: int = 0,
|
|
64
|
+
reg_min: int = SID_REG_MIN,
|
|
65
|
+
reg_max: int = SID_REG_MAX,
|
|
66
|
+
) -> Iterator[DumpRecord]:
|
|
67
|
+
for r in records:
|
|
68
|
+
if r.chipno == chipno and reg_min <= r.reg <= reg_max:
|
|
69
|
+
yield r
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def quantise_to_frames(
|
|
73
|
+
records: Iterable[DumpRecord],
|
|
74
|
+
cycles_per_frame: int = PAL_CYCLES_PER_FRAME,
|
|
75
|
+
start_cycle: int = 0,
|
|
76
|
+
) -> Iterator[tuple[int, int, int]]:
|
|
77
|
+
for r in records:
|
|
78
|
+
frame = (r.cycle - start_cycle) // cycles_per_frame
|
|
79
|
+
yield frame, r.reg, r.value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def dedupe_consecutive(
|
|
83
|
+
rows: Iterable[tuple[int, int, int]],
|
|
84
|
+
) -> Iterator[tuple[int, int, int]]:
|
|
85
|
+
last: dict[int, int] = {}
|
|
86
|
+
for frame, reg, value in rows:
|
|
87
|
+
prev = last.get(reg)
|
|
88
|
+
if prev == value:
|
|
89
|
+
continue
|
|
90
|
+
last[reg] = value
|
|
91
|
+
yield frame, reg, value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_csv(rows: Iterable[tuple[int, int, int]], out: TextIO) -> int:
|
|
95
|
+
writer = csv.writer(out)
|
|
96
|
+
writer.writerow(["frame", "reg", "value"])
|
|
97
|
+
n = 0
|
|
98
|
+
for frame, reg, value in rows:
|
|
99
|
+
writer.writerow([frame, reg, value])
|
|
100
|
+
n += 1
|
|
101
|
+
return n
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def decode_dump_file(
|
|
105
|
+
path: str,
|
|
106
|
+
out: TextIO,
|
|
107
|
+
cycles_per_frame: int = PAL_CYCLES_PER_FRAME,
|
|
108
|
+
chipno: int = 0,
|
|
109
|
+
start_cycle: int = 0,
|
|
110
|
+
dedup: bool = True,
|
|
111
|
+
drop_pre_anchor: bool = True,
|
|
112
|
+
max_frame: Optional[int] = None,
|
|
113
|
+
) -> int:
|
|
114
|
+
"""Decode a dump file to CSV. Returns row count.
|
|
115
|
+
|
|
116
|
+
``drop_pre_anchor`` discards negative frame numbers when
|
|
117
|
+
``start_cycle > 0``. ``max_frame`` caps frames > N (trims writes
|
|
118
|
+
that leak in during container shutdown).
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def _drop_negative(rows):
|
|
122
|
+
for frame, reg, value in rows:
|
|
123
|
+
if frame >= 0:
|
|
124
|
+
yield frame, reg, value
|
|
125
|
+
|
|
126
|
+
def _cap(rows, cap):
|
|
127
|
+
for frame, reg, value in rows:
|
|
128
|
+
if frame > cap:
|
|
129
|
+
return
|
|
130
|
+
yield frame, reg, value
|
|
131
|
+
|
|
132
|
+
with open(path) as fp:
|
|
133
|
+
records = iter_records(fp)
|
|
134
|
+
records = filter_sid(records, chipno=chipno)
|
|
135
|
+
rows = quantise_to_frames(
|
|
136
|
+
records, cycles_per_frame=cycles_per_frame, start_cycle=start_cycle
|
|
137
|
+
)
|
|
138
|
+
if drop_pre_anchor and start_cycle > 0:
|
|
139
|
+
rows = _drop_negative(rows)
|
|
140
|
+
if max_frame is not None:
|
|
141
|
+
rows = _cap(rows, max_frame)
|
|
142
|
+
if dedup:
|
|
143
|
+
rows = dedupe_consecutive(rows)
|
|
144
|
+
return write_csv(rows, out)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Fetch SID-Wizard 1.94's ``disk1.d64`` from CSDB on demand."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import tarfile
|
|
9
|
+
import tempfile
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
SIDWIZARD_TARBALL_URL = (
|
|
16
|
+
"https://csdb.dk/getinternalfile.php/276275/SID-Wizard-1.94-with-sources.tar.gz"
|
|
17
|
+
)
|
|
18
|
+
SIDWIZARD_TARBALL_SHA256 = "544e36aff3fe14b7e4cf81a04c680a6883191a222754b2f0489e15349a89b559"
|
|
19
|
+
SIDWIZARD_TARBALL_SIZE = 8984028
|
|
20
|
+
|
|
21
|
+
DISK1_TAR_MEMBER = "SID-Wizard-1.94/SID-Wizard-1.94-disk1.d64"
|
|
22
|
+
DISK1_SHA256 = "4f6896db53c07aec7e6e7377acdb337d93b632b8c965bd37ea88db71216dcc39"
|
|
23
|
+
DISK1_FILENAME = "SID-Wizard-1.94-disk1.d64"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_cache_dir() -> Path:
|
|
27
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
28
|
+
base = Path(xdg) if xdg else Path.home() / ".cache"
|
|
29
|
+
return base / "sidwizard-driver"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sha256(path: Path) -> str:
|
|
33
|
+
h = hashlib.sha256()
|
|
34
|
+
with open(path, "rb") as fp:
|
|
35
|
+
for chunk in iter(lambda: fp.read(1 << 16), b""):
|
|
36
|
+
h.update(chunk)
|
|
37
|
+
return h.hexdigest()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _download(url: str, dest: Path) -> None:
|
|
41
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
with tempfile.NamedTemporaryFile(
|
|
43
|
+
prefix=dest.name + ".",
|
|
44
|
+
suffix=".part",
|
|
45
|
+
dir=dest.parent,
|
|
46
|
+
delete=False,
|
|
47
|
+
) as tmp:
|
|
48
|
+
tmp_path = Path(tmp.name)
|
|
49
|
+
try:
|
|
50
|
+
log.info("downloading %s", url)
|
|
51
|
+
with urllib.request.urlopen(url) as resp, open(tmp_path, "wb") as out:
|
|
52
|
+
while True:
|
|
53
|
+
chunk = resp.read(1 << 16)
|
|
54
|
+
if not chunk:
|
|
55
|
+
break
|
|
56
|
+
out.write(chunk)
|
|
57
|
+
os.replace(tmp_path, dest)
|
|
58
|
+
except BaseException:
|
|
59
|
+
tmp_path.unlink(missing_ok=True)
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_disk1_d64(cache_dir: Path | None = None) -> Path:
|
|
64
|
+
"""Return a path to ``SID-Wizard-1.94-disk1.d64``, fetching on demand.
|
|
65
|
+
|
|
66
|
+
Idempotent. Raises ``RuntimeError`` if SHA-256 verification fails.
|
|
67
|
+
"""
|
|
68
|
+
cache = cache_dir or default_cache_dir()
|
|
69
|
+
disk1 = cache / DISK1_FILENAME
|
|
70
|
+
if disk1.is_file() and _sha256(disk1) == DISK1_SHA256:
|
|
71
|
+
return disk1
|
|
72
|
+
|
|
73
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
tarball = cache / "SID-Wizard-1.94-with-sources.tar.gz"
|
|
75
|
+
if not (tarball.is_file() and _sha256(tarball) == SIDWIZARD_TARBALL_SHA256):
|
|
76
|
+
_download(SIDWIZARD_TARBALL_URL, tarball)
|
|
77
|
+
got = _sha256(tarball)
|
|
78
|
+
if got != SIDWIZARD_TARBALL_SHA256:
|
|
79
|
+
tarball.unlink(missing_ok=True)
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"SID-Wizard tarball SHA-256 mismatch: got {got}, "
|
|
82
|
+
f"expected {SIDWIZARD_TARBALL_SHA256}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with tarfile.open(tarball, "r:gz") as tf:
|
|
86
|
+
member = tf.getmember(DISK1_TAR_MEMBER)
|
|
87
|
+
src = tf.extractfile(member)
|
|
88
|
+
if src is None:
|
|
89
|
+
raise RuntimeError(f"tarball member {DISK1_TAR_MEMBER} is not a file")
|
|
90
|
+
with open(disk1, "wb") as out:
|
|
91
|
+
while True:
|
|
92
|
+
chunk = src.read(1 << 16)
|
|
93
|
+
if not chunk:
|
|
94
|
+
break
|
|
95
|
+
out.write(chunk)
|
|
96
|
+
|
|
97
|
+
got = _sha256(disk1)
|
|
98
|
+
if got != DISK1_SHA256:
|
|
99
|
+
disk1.unlink(missing_ok=True)
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
f"extracted disk1.d64 SHA-256 mismatch: got {got}, expected {DISK1_SHA256}"
|
|
102
|
+
)
|
|
103
|
+
return disk1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: list[str] | None = None) -> int:
|
|
107
|
+
import argparse
|
|
108
|
+
|
|
109
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
110
|
+
p.add_argument("--cache-dir", type=Path, default=None)
|
|
111
|
+
p.add_argument("-v", "--verbose", action="count", default=0)
|
|
112
|
+
args = p.parse_args(argv)
|
|
113
|
+
logging.basicConfig(
|
|
114
|
+
level=logging.DEBUG if args.verbose >= 2 else logging.INFO,
|
|
115
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
116
|
+
)
|
|
117
|
+
path = fetch_disk1_d64(cache_dir=args.cache_dir)
|
|
118
|
+
print(path)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
raise SystemExit(main())
|