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.
@@ -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())
@@ -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
+ ]
@@ -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())