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

1
lenser/bbc/__init__.py Normal file
View file

@ -0,0 +1 @@
"""BBC Micro (Model B) image conversion and DFS disk / sideways-ROM export."""

View file

@ -0,0 +1,17 @@
"""BBC Micro conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import mode0, mode1, mode2, mode5, mono
_MODULES = {"mode0": mode0, "mode1": mode1, "mode2": mode2, "mode5": mode5,
"mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="mode2", palette_name="bbc",
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES[mode]
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return module.convert(img_rgb, palette_name, dither_mode, intensive, base_color=base_color)

View file

@ -0,0 +1,72 @@
"""Shared helpers for the BBC Micro converters."""
from __future__ import annotations
import itertools
import numpy as np
from ... import dither
from ...convert.base import Conversion, perceptual_error, _box_blur
from ...palette import srgb_to_lab
from .. import palette as bpal
def _choose_physicals(img_lab, n, dither_mode):
"""Pick the n physical colours (of 8) that best reproduce the image, then
dither once with that palette. Candidates are ranked by a fast vectorised
proxy -- the perceptual error of the nearest-colour (un-dithered)
reconstruction -- so we do NOT run the slow Floyd dither for all C(8,n)
combinations (doing so made the 4-colour modes hang for a minute+); Floyd runs
only on the winning palette. Returns (physical_indices, logical_idx, error)."""
plab_all = bpal.phys_lab()
H, W, _ = img_lab.shape
target_blur = _box_blur(img_lab)
best_combo, best_score = None, np.inf
for combo in itertools.combinations(range(8), n):
sub = plab_all[list(combo)]
nidx = ((img_lab[:, :, None, :] - sub[None, None]) ** 2).sum(-1).argmin(-1)
diff = _box_blur(sub[nidx]) - target_blur
score = float(np.sqrt((diff ** 2).sum(-1)).mean())
if score < best_score:
best_score, best_combo = score, list(combo)
sub = plab_all[best_combo]
allowed = np.tile(np.arange(n), (H, W, 1))
idx = dither.quantize(img_lab, allowed, sub, dither_mode).astype(np.uint8)
return best_combo, idx, perceptual_error(idx, img_lab, sub)
def build(img_rgb, *, mode, bbc_mode, ncol, bpp, width, height, base,
dither_mode, mono=False):
if mono:
L = srgb_to_lab(img_rgb)[..., 0]
img_lab = np.zeros((height, width, 3))
img_lab[..., 0] = L
plab = np.zeros((2, 3)); plab[:, 0] = bpal.mono_lab()[:, 0]
allowed = np.tile(np.array([0, 1]), (height, width, 1))
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
physicals = [0, 7] # black, white
err = perceptual_error(idx, img_lab, plab)
prgb = bpal.PHYS[[0, 7]].astype(np.uint8)
else:
img_lab = srgb_to_lab(img_rgb)
if ncol >= 8:
physicals = list(range(8))
plab = bpal.phys_lab()
allowed = np.tile(np.arange(8), (height, width, 1))
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
err = perceptual_error(idx, img_lab, plab)
else:
physicals, idx, err = _choose_physicals(img_lab, ncol, dither_mode)
prgb = bpal.PHYS[physicals].astype(np.uint8)
data = bpal.pack(idx, width, bpp)
preview = prgb[idx]
return Conversion(
mode=mode, width=width, height=height,
pixel_aspect=(4 / 3) / (width / height),
index_image=idx.astype(np.uint16), data=data, data_addr=base,
viewer="bbc", preview_rgb=preview, error=err,
meta={"palette": "bbc", "dither": dither_mode, "bbc_mode": bbc_mode,
"ncol": ncol, "physicals": physicals, "base": base},
)

View file

@ -0,0 +1,9 @@
"""BBC MODE 0: 640x256, 2-colour (black & white). 20K screen at &3000."""
from __future__ import annotations
from . import _common
WIDTH, HEIGHT = 640, 256
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
return _common.build(img_rgb, mode="mode0", bbc_mode=0, ncol=2, bpp=1,
width=WIDTH, height=HEIGHT, base=0x3000,
dither_mode=dither_mode, mono=True)

View file

@ -0,0 +1,8 @@
"""BBC MODE 1: 320x256, 4-colour (best 4 of 8). 20K screen at &3000."""
from __future__ import annotations
from . import _common
WIDTH, HEIGHT = 320, 256
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
return _common.build(img_rgb, mode="mode1", bbc_mode=1, ncol=4, bpp=2,
width=WIDTH, height=HEIGHT, base=0x3000, dither_mode=dither_mode)

View file

@ -0,0 +1,8 @@
"""BBC MODE 2: 160x256, 8-colour. 20K screen at &3000."""
from __future__ import annotations
from . import _common
WIDTH, HEIGHT = 160, 256
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
return _common.build(img_rgb, mode="mode2", bbc_mode=2, ncol=8, bpp=4,
width=WIDTH, height=HEIGHT, base=0x3000, dither_mode=dither_mode)

View file

@ -0,0 +1,8 @@
"""BBC MODE 5: 160x256, 4-colour (best 4 of 8). 10K screen at &5800 (fits a ROM)."""
from __future__ import annotations
from . import _common
WIDTH, HEIGHT = 160, 256
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
return _common.build(img_rgb, mode="mode5", bbc_mode=5, ncol=4, bpp=2,
width=WIDTH, height=HEIGHT, base=0x5800, dither_mode=dither_mode)

View file

@ -0,0 +1,15 @@
"""BBC monochrome -- MODE 0's 640x256 black & white, exposed as the standard
``mono`` mode for cross-platform parity (tone carried by dithering)."""
from __future__ import annotations
from . import mode0
WIDTH, HEIGHT, PIXEL_ASPECT = mode0.WIDTH, mode0.HEIGHT, mode0.PIXEL_ASPECT
def convert(img_rgb, palette_name="bbc", dither_mode="floyd",
intensive=False, base_color=None):
conv = mode0.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv

24
lenser/bbc/exporter.py Normal file
View file

@ -0,0 +1,24 @@
"""Build a BBC Micro DFS disk (.ssd) from a conversion."""
from __future__ import annotations
from . import ssd
from .viewer.assemble import LOAD_ADDR, build_viewer
def export_ssd(conv, output_path, source_path=None, display="forever",
seconds=0, video="pal") -> str:
"""Two DFS files: the !BOOT loader (sets mode + palette, *LOADs IMG) and the
IMG screen data. Boot option 2 (*RUN !BOOT) so SHIFT+BREAK autostarts it."""
if not output_path.lower().endswith(".ssd"):
output_path += ".ssd"
m = conv.meta
viewer = build_viewer(m["bbc_mode"], m["ncol"], m["physicals"], m["base"],
display=display, seconds=seconds, video=video)
# !BOOT autostarts on SHIFT+BREAK (real hardware); PIC is the same loader
# under a name with no '!' so it can be *RUN from a command line / emulator.
files = [
("!BOOT", LOAD_ADDR, LOAD_ADDR, viewer),
("PIC", LOAD_ADDR, LOAD_ADDR, viewer),
("IMG", m["base"], m["base"], conv.data),
]
return ssd.write_ssd(output_path, files, title="8BITLENSER", boot_option=2)

70
lenser/bbc/palette.py Normal file
View file

@ -0,0 +1,70 @@
"""BBC Micro (Video ULA + 6845) palette and screen packing.
8 physical colours -- pure digital RGB. Modes map 1/2/4 bits-per-pixel of
*logical* colour through a programmable palette (VDU 19) to these physicals.
Screen memory is character-cell interleaved: 8x8 cells ordered left-to-right then
top-to-bottom; within a cell the bytes go by scanline, and each scanline spans
1/2/4 bytes (2/4/8-colour). Pixel bits within a byte are interleaved, leftmost
pixel in the high bits.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# Physical colours 0..7.
PHYS = np.array([
(0, 0, 0), # 0 black
(255, 0, 0), # 1 red
(0, 255, 0), # 2 green
(255, 255, 0), # 3 yellow
(0, 0, 255), # 4 blue
(255, 0, 255), # 5 magenta
(0, 255, 255), # 6 cyan
(255, 255, 255), # 7 white
], dtype=np.float64)
def phys_lab() -> np.ndarray:
return srgb_to_lab(PHYS)
def mono_lab() -> np.ndarray:
return srgb_to_lab(PHYS[[0, 7]]) # black + white
def _byte_for_pixels(vals, bits_per_pixel):
"""Encode the pixels covering one byte into the BBC interleaved layout.
Leftmost pixel uses the highest bit of each bit-plane group."""
n = len(vals) # pixels per byte (8/4/2)
b = 0
for bit in range(bits_per_pixel - 1, -1, -1): # high plane first
for i, v in enumerate(vals): # left pixel first
b = (b << 1) | ((v >> bit) & 1)
return b
def pack(idx: np.ndarray, width: int, bits_per_pixel: int) -> bytes:
"""Pack a (height, width) logical-colour array into BBC screen bytes.
The BBC layout is universal: one byte holds ``ppb`` horizontally-adjacent
pixels, and 8 consecutive bytes step down the 8 raster lines of that
byte-column before moving one byte-column to the right; whole character rows
(8 raster lines) then follow top-to-bottom. So
addr = char_row*(num_byte_cols*8) + byte_col*8 + raster
"""
h, w = idx.shape
ppb = 8 // bits_per_pixel # pixels per byte
num_byte_cols = w // ppb
row_stride = num_byte_cols * 8 # bytes per character row
out = bytearray((w * h) // ppb)
for y in range(h):
base = (y // 8) * row_stride + (y % 8)
for bc in range(num_byte_cols):
x0 = bc * ppb
vals = [int(idx[y, x0 + p]) for p in range(ppb)]
out[base + bc * 8] = _byte_for_pixels(vals, bits_per_pixel)
return bytes(out)

72
lenser/bbc/ssd.py Normal file
View file

@ -0,0 +1,72 @@
"""Write an Acorn DFS single-sided disk image (.ssd) natively.
DFS layout: sectors 0-1 are the catalogue; files follow from sector 2, each
starting on a 256-byte sector boundary. Addresses are 18-bit (load/exec/length
high bits packed into the per-file flag byte). Boot option (*OPT 4,n) lives in
sector 1 byte 6 bits 4-5.
"""
from __future__ import annotations
SECTOR = 256
TRACKS = 80
SECTORS = TRACKS * 10 # 800 sectors = 200K, single sided
class DfsError(RuntimeError):
pass
def _name7(name: str) -> bytes:
n = "".join(c for c in name.upper() if 32 <= ord(c) < 127)[:7]
return n.ljust(7).encode("ascii")
def build_ssd(files, title="8BITLENSER", boot_option=0) -> bytes:
"""files: list of (name, load_addr, exec_addr, data). boot_option 0-3
(*OPT 4,n): 0 none, 1 *LOAD, 2 *RUN, 3 *EXEC of the first matching $.!BOOT."""
if len(files) > 31:
raise DfsError("DFS holds at most 31 files")
cat0 = bytearray(SECTOR)
cat1 = bytearray(SECTOR)
t = title.ljust(12)[:12].encode("ascii", "replace")
cat0[0:8] = t[0:8]
cat1[0:4] = t[8:12]
cat1[4] = 0 # cycle number
cat1[5] = len(files) * 8 # (#files) * 8
cat1[6] = ((SECTORS >> 8) & 0x03) | ((boot_option & 0x03) << 4)
cat1[7] = SECTORS & 0xFF
body = bytearray()
start_sector = 2
for i, (name, load, exec_, data) in enumerate(files):
e = 8 + i * 8
cat0[e:e + 7] = _name7(name)
cat0[e + 7] = ord("$") # directory '$' (unlocked)
length = len(data)
cat1[e + 0] = load & 0xFF
cat1[e + 1] = (load >> 8) & 0xFF
cat1[e + 2] = exec_ & 0xFF
cat1[e + 3] = (exec_ >> 8) & 0xFF
cat1[e + 4] = length & 0xFF
cat1[e + 5] = (length >> 8) & 0xFF
cat1[e + 6] = (((exec_ >> 16) & 0x03) << 6 |
((length >> 16) & 0x03) << 4 |
((load >> 16) & 0x03) << 2 |
((start_sector >> 8) & 0x03))
cat1[e + 7] = start_sector & 0xFF
nsec = (length + SECTOR - 1) // SECTOR
body += bytes(data) + bytes((-length) % SECTOR)
start_sector += nsec
img = bytes(cat0) + bytes(cat1) + bytes(body)
if len(img) > SECTORS * SECTOR:
raise DfsError("files exceed disk capacity")
return img + bytes(SECTORS * SECTOR - len(img))
def write_ssd(path: str, files, title="8BITLENSER", boot_option=0) -> str:
with open(path, "wb") as f:
f.write(build_ssd(files, title, boot_option))
return path

View file

View file

@ -0,0 +1,102 @@
"""Assemble the BBC 6502 viewer with `xa` and patch in per-image parameters."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
LOAD_ADDR = 0x1900 # DFS PAGE
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _xa(wrapper: str) -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "v.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:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
return f.read()
finally:
os.unlink(wrap)
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
def build_slideshow_viewer(bbc_mode: int, ncol: int, base: int, palettes,
advance: str = "both", seconds: int = 10,
loop: bool = True, video: str = "pal") -> bytes:
"""Return the multi-image BBC loader (origin $1900).
``palettes`` is one physical-colour list per image (each truncated/padded to
ncol) -- emitted as the ss_pal table the loader indexes by slide; the screen
base hex is patched into the OSCLI *LOAD string.
"""
if not palettes:
raise AssemblerError("a slideshow needs at least one image")
rate = 60 if video == "ntsc" else 50
flat = []
for p in palettes:
row = list(p[:ncol])
flat += row + [0] * (ncol - len(row))
table = ",".join(str(b & 0xFF) for b in flat)
wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f"#define NIMAGES {len(palettes)}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
f"#define MODE {bbc_mode}\n"
f"#define NCOL {ncol}\n"
'#include "slideshow.s"\n'
"ss_pal:\n"
f" .byte {table}\n")
raw = bytearray(_xa(wrapper))
off = raw.find(b"LOAD 00 ")
if off < 0:
raise AssemblerError("could not locate the OSCLI string to patch")
raw[off + 8:off + 12] = f"{base:04X}".encode("ascii") # screen-base hex
return bytes(raw)
def build_viewer(bbc_mode: int, ncol: int, physicals, base: int,
display: str = "forever", seconds: int = 0,
video: str = "pal") -> bytes:
"""Return the assembled loader (origin $1900) with the mode/palette/screen-base
parameters patched in."""
waitmode = WAIT_MODES.get(display, 0)
rate = 60 if video == "ntsc" else 50 # bbcb is PAL (50 Hz)
wrapper = (f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
'#include "bbc.s"\n')
raw = bytearray(_xa(wrapper))
off = raw.find(b"LOAD IMG ")
if off < 0:
raise AssemblerError("could not locate the OSCLI string to patch")
raw[off + 9:off + 13] = f"{base:04X}".encode("ascii") # screen-base hex
raw[off + 14] = bbc_mode & 0xFF # p_mode
raw[off + 15] = ncol & 0xFF # p_ncol
for i, p in enumerate(physicals[:8]): # p_pal
raw[off + 16 + i] = p & 0xFF
return bytes(raw)

97
lenser/bbc/viewer/bbc.s Normal file
View file

@ -0,0 +1,97 @@
; lenser -- BBC Micro viewer (6502), loaded at PAGE (&1900) and *RUN.
;
; Sets the screen MODE, programmes the logical->physical palette, then *LOADs the
; image file "IMG" straight into screen memory, and holds it per the display
; option. Parameters come from a fixed block the packager fills in (so the code
; is constant); see build_viewer() in assemble.py.
;
; #defines from the wrapper -- WAITMODE (0 forever, 1 key, 2 seconds), WAITSECS, RATE.
;
; assembled by bbc/viewer/assemble.py via xa
OSWRCH = $FFEE
OSRDCH = $FFE0
OSBYTE = $FFF4
OSCLI = $FFF7
* = $1900
start:
; ---- VDU 22, mode ----
lda #22
jsr OSWRCH
lda p_mode
jsr OSWRCH
; ---- palette via VDU 19, logical, physical, 0,0,0 for each logical ----
ldx #0
palloop:
cpx p_ncol
beq paldone
lda #19
jsr OSWRCH
txa
jsr OSWRCH ; logical colour = X
lda p_pal,x
jsr OSWRCH ; physical
lda #0
jsr OSWRCH
jsr OSWRCH
jsr OSWRCH
inx
bne palloop
paldone:
; ---- hide the text cursor (VDU 23,1,0;0;0;0;) ----
lda #23
jsr OSWRCH
lda #1
jsr OSWRCH
ldx #8
curz:
lda #0
jsr OSWRCH
dex
bne curz
; ---- *LOAD the image straight into screen memory ----
ldx #<cmd
ldy #>cmd
jsr OSCLI
; ---- hold the picture ----
#if WAITMODE == 0
hang:
jmp hang
#endif
#if WAITMODE == 1
jsr OSRDCH ; block until a key
rts ; back to BASIC
#endif
#if WAITMODE == 2
lda #<(WAITSECS*RATE)
sta cnt
lda #>(WAITSECS*RATE)
sta cnt+1
swait:
lda #19
jsr OSBYTE ; OSBYTE 19 = wait for vertical sync
lda cnt
bne sdec
dec cnt+1
sdec:
dec cnt
lda cnt
ora cnt+1
bne swait
rts ; back to BASIC
#endif
cnt: .byte 0,0
; OSCLI string -- the packager patches in the right screen-base hex
cmd: .byte "LOAD IMG ", "0000", 13
; parameter block -- the packager fills these in
p_mode: .byte 0
p_ncol: .byte 0
p_pal: .byte 0,0,0,0,0,0,0,0

View file

@ -0,0 +1,174 @@
; lenser -- BBC Micro slideshow loader (6502), loaded at PAGE (&1900) and *RUN.
;
; Sets the screen MODE once, then steps through NIMAGES pictures stored as DFS
; files "00".."NN". For each slide it programmes that image's logical->physical
; palette (from the ss_pal table), *LOADs the file straight into screen memory,
; and waits (key / seconds / both) before advancing.
;
; #defines from the build wrapper --
; WAITMODE 1 key / 2 seconds / 3 both WAITSECS timeout RATE 50/60 fps
; NIMAGES image count LOOPFLAG 1 wrap / 0 stop MODE screen mode NCOL colours
; The packager patches the screen-base hex into the OSCLI string and appends the
; ss_pal table (NCOL physical-colour bytes per image).
OSWRCH = $FFEE
OSRDCH = $FFE0
OSBYTE = $FFF4
OSCLI = $FFF7
palptr = $70
cnt = $72
* = $1900
start:
; ---- VDU 22, mode (once) ----
lda #22
jsr OSWRCH
lda #MODE
jsr OSWRCH
; ---- hide the text cursor (VDU 23,1,0;0;0;0;) ----
lda #23
jsr OSWRCH
lda #1
jsr OSWRCH
ldx #8
curz:
lda #0
jsr OSWRCH
dex
bne curz
lda #0
sta ssidx
ssmain:
; ---- palptr = ss_pal + ssidx*NCOL ----
lda #<ss_pal
sta palptr
lda #>ss_pal
sta palptr+1
ldx ssidx
beq setpal
pmul:
clc
lda palptr
adc #NCOL
sta palptr
bcc pm1
inc palptr+1
pm1:
dex
bne pmul
setpal:
; ---- VDU 19, logical, physical, 0,0,0 for each logical colour ----
ldx #0
pl:
cpx #NCOL
beq pldone
lda #19
jsr OSWRCH
txa
jsr OSWRCH ; logical colour = X
txa
tay
lda (palptr),y
jsr OSWRCH ; physical colour
lda #0
jsr OSWRCH
jsr OSWRCH
jsr OSWRCH
inx
bne pl
pldone:
; ---- build the filename digits "NN" into the OSCLI string ----
lda ssidx
ldx #$2f
sec
nten:
inx
sbc #10
bcs nten
adc #10
ora #$30
sta cmd+6 ; ones digit
txa
sta cmd+5 ; tens digit
; ---- *LOAD NN <base> straight into screen memory ----
ldx #<cmd
ldy #>cmd
jsr OSCLI
jsr sswait
inc ssidx
lda ssidx
cmp #NIMAGES
bcc ssmain
#if LOOPFLAG == 1
lda #0
sta ssidx
jmp ssmain
#else
rts ; back to BASIC
#endif
; ---- wait (returns), flushing the keyboard buffer first ----
sswait:
lda #15
ldx #1
jsr OSBYTE ; flush input buffers
#if WAITMODE == 1
jsr OSRDCH ; block until a key
rts
#endif
#if WAITMODE == 2
lda #<(WAITSECS*RATE)
sta cnt
lda #>(WAITSECS*RATE)
sta cnt+1
sw2:
lda #19
jsr OSBYTE ; wait for vertical sync (1 frame)
lda cnt
bne sd2
dec cnt+1
sd2:
dec cnt
lda cnt
ora cnt+1
bne sw2
rts
#endif
#if WAITMODE == 3
lda #<(WAITSECS*RATE)
sta cnt
lda #>(WAITSECS*RATE)
sta cnt+1
sw3:
lda #129
ldx #0
ldy #0
jsr OSBYTE ; INKEY(0) -- poll keyboard, no wait
cpy #$ff
bne sw3d ; Y != $FF -> a key was pressed
lda #19
jsr OSBYTE ; wait one frame
lda cnt
bne sd3
dec cnt+1
sd3:
dec cnt
lda cnt
ora cnt+1
bne sw3
sw3d:
rts
#endif
ssidx: .byte 0
; OSCLI string -- packager patches the 4-hex screen base; loader patches "NN"
cmd: .byte "LOAD 00 0000", 13
; ss_pal table (NCOL bytes per image) appended by the build wrapper