206 lines
8.5 KiB
Python
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
|