"""Assemble the 6502 viewer stubs with `xa` and build self-contained viewer PRGs. Each viewer is a small ML stub originating at $0801 (behind a BASIC SYS 2061 autostart). The picture data is appended after the stub, zero-padded so the bitmap lands exactly at $2000, screen RAM at $3F40, etc. The whole thing loads in a single pass -- no second disk access -- so it works identically on real hardware and in any emulator regardless of device configuration. `xa -o` emits raw bytes starting at the origin without the 2-byte CBM load address, which we prepend here. """ from __future__ import annotations import os import shutil import subprocess import tempfile VIEWER_DIR = os.path.dirname(os.path.abspath(__file__)) LOAD_ADDR = 0x0801 DATA_ADDR = 0x2000 # where appended picture data must land # mode/viewer key -> source filename SOURCES = { "hires": "hires.s", "multicolor": "multicolor.s", "fli": "fli.s", "fli_ntsc": "fli_ntsc.s", "interlace": "interlace.s", "slideshow": "slideshow.s", "slideshow_fli": "slideshow_fli.s", "slideshow_interlace": "slideshow_interlace.s", } # slideshow advance behaviour -> WAITMODE (see viewer/wait.i) SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3} _cache: dict[tuple, bytes] = {} # How long the viewer holds the picture (see viewer/wait.i). WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2} class AssemblerError(RuntimeError): pass def have_xa() -> bool: return shutil.which("xa") is not None def assemble_stub(viewer_key: str, display: str = "key", seconds: int = 0, video: str = "pal", separate: bool = False) -> bytes: """Assemble a viewer stub to raw bytes (origin $0801, no load-address prefix). ``display`` / ``seconds`` choose the wait behaviour (viewer/wait.i); ``video`` sets the jiffy rate the seconds timer counts (50 PAL / 60 NTSC). ``separate`` makes the viewer KERNAL-load the picture from a "data" file instead of having it appended (viewer/loaddata.i). """ waitmode = WAIT_MODES.get(display, 1) jiffyps = 60 if video == "ntsc" else 50 sep = 1 if separate else 0 key = (viewer_key, waitmode, int(seconds), jiffyps, sep) if key in _cache: return _cache[key] if not have_xa(): raise AssemblerError( "The 'xa' (xa65) assembler was not found on PATH.\n" "Install it with: sudo apt install xa65 (Debian/Ubuntu)\n" "or build from https://www.floodgap.com/retrotech/xa/") if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])): raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}") # A generated wrapper sets the options then includes the real source. wrapper = ( f"#define WAITMODE {waitmode}\n" f"#define WAITSECS {max(0, int(seconds))}\n" f"#define JIFFYPS {jiffyps}\n" f"#define SEPARATE {sep}\n" f'#include "{SOURCES[viewer_key]}"\n') raw = _xa(wrapper, viewer_key) _cache[key] = raw return raw def _xa(wrapper: str, what: str) -> bytes: """Assemble a generated wrapper with xa; return raw bytes (no load prefix). The wrapper lives in VIEWER_DIR and xa runs there so its #include "...s" and the source's #include "wait.i" both resolve (xa looks relative to cwd).""" if not have_xa(): raise AssemblerError( "The 'xa' (xa65) assembler was not found on PATH.\n" "Install it with: sudo apt install xa65 (Debian/Ubuntu)") with tempfile.TemporaryDirectory() as td: out = os.path.join(td, "viewer.bin") fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR) try: with os.fdopen(fd, "w") as f: f.write(wrapper) proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)], capture_output=True, text=True, cwd=VIEWER_DIR) if proc.returncode != 0: raise AssemblerError(f"xa failed for {what}:\n{proc.stdout}{proc.stderr}") with open(out, "rb") as f: return f.read() finally: os.unlink(wrap) CART_SIZE = 0x4000 # 16K C64 cartridge ROM at $8000-$BFFF def build_cart_rom(viewer_key: str, data: bytes, display: str = "forever", seconds: int = 0, video: str = "pal") -> bytes: """Assemble cart.s for `viewer_key` (hires/multicolor), append the image data, and pad to a 16K cart ROM. Raises if the mode/size can't be a cart.""" if viewer_key not in ("hires", "multicolor"): raise AssemblerError( f"the {viewer_key} mode is too large for a 16K cartridge -- " "use a disk image, or pick hires/multicolor/mono") mcmode = 1 if viewer_key == "multicolor" else 0 waitmode = WAIT_MODES.get(display, 0) rate = 60 if video == "ntsc" else 50 npages = (len(data) + 255) // 256 wrapper = ( f"#define MCMODE {mcmode}\n" f"#define NPAGES {npages}\n" f"#define WAITMODE {waitmode}\n" f"#define WAITSECS {max(0, int(seconds))}\n" f"#define RATE {rate}\n" '#include "cart.s"\n') rom = _xa(wrapper, "cart") + bytes(data) if len(rom) > CART_SIZE: raise AssemblerError( f"image + cart viewer = {len(rom)} bytes, over the 16K cartridge " "limit -- use a disk image for this mode") return rom + bytes(CART_SIZE - len(rom)) def build_viewer_prg(viewer_key: str, data: bytes, data_addr: int = DATA_ADDR, display: str = "key", seconds: int = 0, video: str = "pal", separate: bool = False) -> bytes: """Combine the assembled stub + padding + picture ``data`` into one PRG. ``data`` is the block that must reside from ``data_addr`` upward (bitmap, screen, colour RAM, background, ...). When ``separate`` is set the picture is NOT appended -- the viewer KERNAL-loads it from a "data" file at run time (use ``build_data_prg`` to write that file) -- so this returns just the code. """ stub = assemble_stub(viewer_key, display, seconds, video, separate) if separate: return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + stub pad_len = (data_addr - LOAD_ADDR) - len(stub) if pad_len < 0: raise AssemblerError( f"viewer stub {viewer_key} is {len(stub)} bytes, exceeds the " f"{data_addr - LOAD_ADDR} bytes available before ${data_addr:04x}") payload = stub + bytes(pad_len) + bytes(data) return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + payload def build_data_prg(data: bytes, data_addr: int = DATA_ADDR) -> bytes: """The picture as a standalone PRG (2-byte load address + data), for the separate-binary layout's "data" file.""" return bytes([data_addr & 0xFF, (data_addr >> 8) & 0xFF]) + bytes(data) def build_slideshow_prg(mode_bytes, advance: str = "both", seconds: int = 10, loop: bool = True, video: str = "pal", flavor: str = "simple") -> bytes: """Assemble a slideshow viewer PRG (code only; pictures are separate files). ``flavor`` selects the engine: "simple" (mixed hires/multicolor/mono, one mode byte per image), "fli" (all FLI), or "interlace" (all IFLI). For the simple flavor ``mode_bytes`` is one byte per image (0 hires/mono, 1 multicolor), emitted as the ss_modes table; for fli/interlace only its length (the image count) matters. ``advance`` picks the wait behaviour (key/seconds/both), ``seconds`` the timeout, ``loop`` whether it wraps. """ if not mode_bytes: raise AssemblerError("a slideshow needs at least one image") waitmode = SS_WAITMODE[advance] jiffyps = 60 if video == "ntsc" else 50 defines = ( f"#define WAITMODE {waitmode}\n" f"#define WAITSECS {max(0, int(seconds))}\n" f"#define JIFFYPS {jiffyps}\n" f"#define NIMAGES {len(mode_bytes)}\n" f"#define LOOPFLAG {1 if loop else 0}\n") if flavor == "simple": table = ",".join(str(int(b) & 1) for b in mode_bytes) wrapper = (defines + '#include "slideshow.s"\n' "ss_modes:\n" f" .byte {table}\n") elif flavor == "fli": wrapper = (defines + f"#define NTSC {1 if video == 'ntsc' else 0}\n" '#include "slideshow_fli.s"\n') elif flavor == "interlace": wrapper = defines + '#include "slideshow_interlace.s"\n' else: raise AssemblerError(f"unknown slideshow flavor: {flavor}") raw = _xa(wrapper, f"slideshow_{flavor}") return bytes([LOAD_ADDR & 0xFF, (LOAD_ADDR >> 8) & 0xFF]) + raw