First public commit.

This commit is contained in:
The Dust Council 2026-07-03 19:35:35 -07:00
parent 2a48f52979
commit 4bac9d83ed
288 changed files with 18417 additions and 1076 deletions

104
lenser/diskimage.py Normal file
View file

@ -0,0 +1,104 @@
"""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