"""Build .d64/.d71/.d81 Commodore disk images with VICE's `c1541`. We shell out to `c1541` rather than re-implementing CBM-DOS: it is battle-tested and handles BAM/directory layout for all three formats correctly. """ from __future__ import annotations import os import shutil import subprocess import tempfile FORMATS = { "d64": ("d64", 174848), # 35 tracks, 664 blocks free "d71": ("d71", 349696), # double sided "d81": ("d81", 819200), # 3.5" 800K } # Per-format usable data budget (blocks * 254), conservative. BLOCKS_FREE = {"d64": 664, "d71": 1328, "d81": 3160} class DiskError(RuntimeError): pass def have_c1541() -> bool: return shutil.which("c1541") is not None def petscii_name(text: str, maxlen: int = 16) -> str: """Sanitise a host string into a legal CBM filename. Letters are forced to lower case: c1541 maps lower-case ASCII into the standard PETSCII letter range ($41-$5A), which renders as clean letters in a C64 directory listing, whereas upper-case ASCII lands in the shifted range that displays oddly. """ out = [] for ch in text.lower(): if ch.isalnum() or ch in " -+.": out.append(ch) name = "".join(out).strip() or "8bitlenser" return name[:maxlen] def fmt_from_path(path: str, override: str | None) -> str: if override: if override not in FORMATS: raise DiskError(f"unknown disk format: {override}") return override ext = os.path.splitext(path)[1].lower().lstrip(".") return ext if ext in FORMATS else "d64" def build_disk(path: str, disk_format: str, disk_name: str, disk_id: str, files: list[tuple[str, bytes]]) -> str: """Create ``path`` of ``disk_format`` containing ``files`` (cbm_name, prg_bytes). The first file in the list lands first in the directory, so ``LOAD"*",8,1`` loads it -- make that the viewer. """ if not have_c1541(): raise DiskError( "VICE's 'c1541' tool was not found on PATH.\n" "Install it with: sudo apt install vice (Debian/Ubuntu)\n" "or build from https://vice-emu.sourceforge.io/") total = sum(len(b) for _, b in files) budget = BLOCKS_FREE[disk_format] * 254 if total > budget: raise DiskError( f"data ({total} bytes) exceeds {disk_format} capacity (~{budget} bytes); " f"use a larger disk format") vtype = FORMATS[disk_format][0] name = petscii_name(disk_name) did = petscii_name(disk_id, 2) or "01" path = os.path.abspath(path) if os.path.exists(path): os.remove(path) with tempfile.TemporaryDirectory() as td: cmd = ["c1541", "-format", f"{name},{did}", vtype, path] for i, (cbm, data) in enumerate(files): host = os.path.join(td, f"f{i}.prg") with open(host, "wb") as f: f.write(data) cmd += ["-write", host, petscii_name(cbm)] proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace") # c1541 prints a harmless "OPENCBM ... libopencbm.so" warning; only fail # if the image was not actually produced. if not os.path.exists(path): raise DiskError(f"c1541 failed:\n{proc.stdout}{proc.stderr}") return path def directory(path: str) -> str: """Return the disk directory listing (for verification).""" proc = subprocess.run(["c1541", "-attach", path, "-dir"], capture_output=True, text=True, errors="replace") return proc.stdout