8bitlenser/lenser/viewer/assemble.py
2026-07-03 19:35:35 -07:00

206 lines
8.5 KiB
Python

"""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