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

3
lenser/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""8 Bit Lenser -- convert modern images into Commodore 64 disk images with a viewer."""
__version__ = "0.1.0"

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

@ -0,0 +1 @@
"""Atari 2600 (VCS) image conversion and cartridge export."""

View file

@ -0,0 +1,19 @@
"""Atari 2600 conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import pf
# "pf" = flicker-free 3-colour/scanline; "pf_il" = temporal interlace (two
# frames at 60Hz blend to ~4-6 perceived colours/scanline, at the cost of flicker);
# "mono" = pf restricted to the TIA's greys (one hue's 8 luminances).
MODES = ["pf", "pf_il", "mono"]
def convert_image(path_or_img, mode="pf", palette_name="tia",
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
img_rgb = imageprep.prepare(path_or_img, pf.WIDTH, pf.HEIGHT,
pf.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return pf.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color, interlace=(mode == "pf_il"),
gray=(mode == "mono"))

144
lenser/a2600/convert/pf.py Normal file
View file

@ -0,0 +1,144 @@
"""Atari 2600 playfield image: 40x192, 3 colours per scanline.
No framebuffer -- the picture is a table the "racing the beam" kernel feeds to
the TIA one scanline at a time: a 40-pixel asymmetric playfield (left 20 + right
20) plus a shared playfield colour (COLUPF) and TWO background colours -- COLUBK
is rewritten mid-line, so the left and right 20px halves each get their own
background. Per line we jointly pick the shared foreground and the two
backgrounds that minimise dithered error, then dither each half between its two
colours.
"""
from __future__ import annotations
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 tpal
WIDTH, HEIGHT = 40, 192
HALF = WIDTH // 2
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) # very wide pixels
def _best_triples(img_lab, plab, split_gain=0.12, cand=None):
"""Per scanline choose a shared foreground + a background for each 20px half.
For a fixed foreground f, each half's best error is min over its background
bg of sum_px min(d[px,f], d[px,bg]); we pick the f minimising left+right.
To avoid a gratuitous vertical seam down the centre, the two halves keep a
UNIFIED background unless splitting it reduces the line's error by more than
``split_gain`` (so a seam only appears where the image really differs L/R).
``cand`` (palette indices) restricts the usable colours, e.g. to the greys.
Returns (fg, bgL, bgR) index arrays."""
fg = np.zeros(HEIGHT, np.int64)
bgL = np.zeros(HEIGHT, np.int64)
bgR = np.zeros(HEIGHT, np.int64)
forbid = None
if cand is not None:
forbid = np.full(plab.shape[0], np.inf)
forbid[np.asarray(cand)] = 0.0
for y in range(HEIGHT):
d = np.sum((img_lab[y][:, None, :] - plab[None, :, :]) ** 2, axis=-1) # (40,P)
if forbid is not None:
d = d + forbid[None, :] # forbid non-candidate colours
dl = d[:HALF].T # (P, 20)
dr = d[HALF:].T
# G?[f,bg] = sum_px min(d[f,px], d[bg,px])
GL = np.minimum(dl[:, None, :], dl[None, :, :]).sum(-1) # (P,P)
GR = np.minimum(dr[:, None, :], dr[None, :, :]).sum(-1)
total = GL.min(1) + GR.min(1)
f = int(total.argmin())
fg[y] = f
split_err = total[f]
uni = GL[f] + GR[f] # same bg for both halves
bg_uni = int(uni.argmin())
uni_err = float(uni[bg_uni])
if split_err < uni_err * (1.0 - split_gain):
bgL[y] = int(GL[f].argmin())
bgR[y] = int(GR[f].argmin())
else:
bgL[y] = bgR[y] = bg_uni # unified -> no seam on this line
return fg, bgL, bgR
def _encode_frame(img_rgb, plab, dither_mode, cand=None):
"""Encode one frame -> (9-table data bytes, idx image (192x40 palette indices))."""
img_lab = srgb_to_lab(img_rgb)
fg, bgL, bgR = _best_triples(img_lab, plab, cand=cand)
allowed = np.empty((HEIGHT, WIDTH, 2), np.int64)
allowed[:, :HALF, 0] = bgL[:, None]; allowed[:, :HALF, 1] = fg[:, None]
allowed[:, HALF:, 0] = bgR[:, None]; allowed[:, HALF:, 1] = fg[:, None]
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
bit = (idx == fg[:, None]).astype(np.uint8) # 1 -> playfield
pf0L = np.zeros(HEIGHT, np.uint8); pf1L = np.zeros(HEIGHT, np.uint8)
pf2L = np.zeros(HEIGHT, np.uint8); pf0R = np.zeros(HEIGHT, np.uint8)
pf1R = np.zeros(HEIGHT, np.uint8); pf2R = np.zeros(HEIGHT, np.uint8)
colubkL = np.zeros(HEIGHT, np.uint8); colubkR = np.zeros(HEIGHT, np.uint8)
colupf = np.zeros(HEIGHT, np.uint8)
for y in range(HEIGHT):
pf0L[y], pf1L[y], pf2L[y] = tpal.pack20(bit[y, :HALF])
pf0R[y], pf1R[y], pf2R[y] = tpal.pack20(bit[y, HALF:])
colubkL[y] = tpal.color_byte(int(bgL[y]))
colubkR[y] = tpal.color_byte(int(bgR[y]))
colupf[y] = tpal.color_byte(int(fg[y]))
data = b"".join(bytes(t) for t in
(pf0L, pf1L, pf2L, pf0R, pf1R, pf2R, colubkL, colubkR, colupf))
return data, idx
def _widen(rgb):
disp_w = int(round(WIDTH * PIXEL_ASPECT))
xs = (np.arange(disp_w) * WIDTH) // disp_w
return rgb[:, xs]
def convert(img_rgb, palette_name="tia", dither_mode="floyd",
intensive=False, base_color=None, interlace=False, gray=False):
plab = tpal.palette_lab()
pal = tpal.PALETTE.astype(np.float64)
img_lab = srgb_to_lab(img_rgb)
# mono mode restricts the TIA to one hue's 8 luminances (hue 0 = greys, or
# the hue of base_color for a tinted mono); colour index = hue*8 + lum.
cand = None
if gray:
hue = 0 if base_color is None else (int(base_color) // 8)
cand = list(range(hue * 8, hue * 8 + 8))
if interlace:
# Frame A approximates the image; frame B is encoded so the per-pixel
# blend (averaged in linear light, the way 60Hz flicker is perceived)
# lands on the target -- giving ~4-6 perceived colours per scanline.
from ...palette import srgb_to_linear, linear_to_srgb
dataA, idxA = _encode_frame(img_rgb, plab, dither_mode, cand=cand)
renA = pal[idxA]
linT, linA = srgb_to_linear(img_rgb.astype(np.float64)), srgb_to_linear(renA)
targetB = linear_to_srgb(np.clip(2 * linT - linA, 0, 1))
dataB, idxB = _encode_frame(targetB, plab, dither_mode, cand=cand)
renB = pal[idxB]
blend = np.clip(linear_to_srgb((srgb_to_linear(renA) +
srgb_to_linear(renB)) / 2), 0, 255)
blend_lab = srgb_to_lab(blend)
# perceptual (blurred) error -- credits the 60Hz flicker blend the eye averages
err = float(np.sqrt(((_box_blur(blend_lab) - _box_blur(img_lab)) ** 2)
.sum(-1)).mean())
return Conversion(
mode="pf_il", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idxA.astype(np.uint16), data=dataA + dataB, data_addr=0,
viewer="a2600", preview_rgb=_widen(blend.astype(np.uint8)),
error=err, meta={"palette": "tia", "dither": dither_mode, "interlace": True},
)
data, idx = _encode_frame(img_rgb, plab, dither_mode, cand=cand)
return Conversion(
mode="mono" if gray else "pf", width=WIDTH, height=HEIGHT,
pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="a2600", preview_rgb=_widen(tpal.PALETTE.astype(np.uint8)[idx]),
error=perceptual_error(idx, img_lab, plab),
meta={"palette": "tia", "dither": dither_mode},
)

12
lenser/a2600/exporter.py Normal file
View file

@ -0,0 +1,12 @@
"""Build an Atari 2600 cartridge (.a26) from a conversion."""
from __future__ import annotations
from .viewer.assemble import build_cart
def export_a26(conv, output_path, source_path=None, display="forever", seconds=0):
if not output_path.lower().endswith((".a26", ".bin")):
output_path += ".a26"
rom = build_cart(conv.data, display=display, seconds=seconds)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

81
lenser/a2600/palette.py Normal file
View file

@ -0,0 +1,81 @@
"""Atari 2600 TIA (NTSC) palette + playfield bit packing.
The TIA colour byte is (hue << 4) | (lum << 1): 16 hues x 8 luminances = 128
colours (bit 0 unused, so register values are even 0..254). We generate the
NTSC palette with a YIQ formula -- close enough to be recognisable; the encoder
matches against it in CIELAB.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
TIA_NTSC = [
(0,0,0), (44,44,44), (83,83,83), (119,119,119),
(154,154,154), (188,188,188), (222,222,222), (255,255,255),
(33,11,0), (73,53,0), (109,90,0), (145,126,0),
(179,161,44), (213,195,83), (246,229,119), (255,255,154),
(60,0,0), (97,35,0), (133,74,0), (168,110,26),
(202,146,66), (235,180,103), (255,214,139), (255,247,173),
(74,0,0), (111,18,0), (146,58,29), (181,96,68),
(215,132,105), (248,167,141), (255,201,175), (255,234,209),
(75,0,0), (112,5,43), (147,48,81), (182,86,118),
(215,122,153), (249,157,187), (255,191,221), (255,225,254),
(62,0,56), (99,1,93), (135,45,129), (170,83,164),
(204,119,198), (237,154,232), (255,189,255), (255,222,255),
(36,0,97), (75,7,133), (112,49,168), (147,87,202),
(182,123,235), (215,159,255), (248,193,255), (255,226,255),
(0,0,118), (43,21,153), (82,61,187), (118,99,221),
(153,134,254), (188,169,255), (221,203,255), (254,236,255),
(0,0,117), (10,38,152), (51,77,187), (89,113,220),
(125,149,253), (160,183,255), (195,217,255), (228,250,255),
(0,15,94), (0,56,130), (25,93,165), (65,129,199),
(102,164,233), (138,198,255), (173,232,255), (206,255,255),
(0,32,53), (0,71,91), (10,108,127), (52,143,162),
(90,178,196), (126,212,229), (161,245,255), (195,255,255),
(0,41,0), (0,80,38), (12,116,77), (53,152,114),
(91,186,149), (127,220,183), (162,253,217), (196,255,250),
(0,44,0), (0,82,0), (29,118,24), (69,154,64),
(106,188,102), (141,221,137), (176,255,172), (210,255,206),
(0,38,0), (14,77,0), (56,114,0), (93,149,24),
(129,183,64), (164,217,101), (198,250,137), (232,255,171),
(7,24,0), (50,64,0), (88,102,0), (124,137,0),
(159,172,43), (193,206,82), (226,239,118), (255,255,153),
(42,5,0), (80,48,0), (117,86,0), (152,122,6),
(186,157,48), (220,191,86), (253,225,122), (255,255,158),
]
PALETTE = np.array(TIA_NTSC, dtype=np.float64) # (128,3) sRGB, calibrated from MAME
def color_byte(index: int) -> int:
"""TIA register value (even 0..254) for palette index hue*8+lum."""
hue, lum = divmod(index, 8)
return (hue << 4) | (lum << 1)
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)
# ---- playfield packing -----------------------------------------------------
# The 20 playfield pixels (left to right) map to the PF registers in this order:
# px 0-3 PF0 bits 4,5,6,7
# px 4-11 PF1 bits 7,6,5,4,3,2,1,0
# px 12-19 PF2 bits 0,1,2,3,4,5,6,7
def pack20(bits) -> tuple[int, int, int]:
"""20 pixel on/off values (leftmost first) -> (PF0, PF1, PF2) bytes."""
pf0 = pf1 = pf2 = 0
for i in range(4):
if bits[i]:
pf0 |= 1 << (4 + i)
for i in range(8):
if bits[4 + i]:
pf1 |= 1 << (7 - i)
for i in range(8):
if bits[12 + i]:
pf2 |= 1 << i
return pf0, pf1, pf2

View file

@ -0,0 +1 @@

138
lenser/a2600/viewer/a2600.s Normal file
View file

@ -0,0 +1,138 @@
; lenser -- Atari 2600 (VCS) image viewer kernel (6502, "racing the beam").
;
; No framebuffer -- per visible scanline the kernel feeds the TIA a 40-pixel
; asymmetric playfield (left 20 + right 20, rewritten mid-line) plus a shared
; playfield colour (COLUPF) and two backgrounds -- COLUBK is set for the left
; half during HBLANK then rewritten mid-line for the right half, so each
; scanline shows 3 colours (left bg + right bg + foreground). The nine 192-byte
; data tables are page-aligned (set by the cartridge builder) so LDA tab,Y never
; crosses a page and the kernel stays cycle-exact.
;
; #defines from the wrapper -- PF0L,PF1L,PF2L,PF0R,PF1R,PF2R,BKL,BKR,PFT (table
; bases), WAITMODE (0 forever / 1 fire / 2 seconds), WAITSECS.
;
; assembled by a2600/viewer/assemble.py via xa
VSYNC = $00
VBLANK = $01
WSYNC = $02
NUSIZ0 = $04
COLUPF = $08
COLUBK = $09
CTRLPF = $0A
PF0 = $0D
PF1 = $0E
PF2 = $0F
INPT4 = $3C
FRLO = $80 ; frame counter (seconds mode)
FRHI = $81
PARITY = $82 ; interlace frame parity (IL mode)
* = $f000
start:
sei
cld
ldx #0
txa
clr:
sta $00,x
inx
bne clr ; clear $00-$FF (RAM + TIA)
ldx #$ff
txs
frame:
lda #2
sta VSYNC
sta WSYNC
sta WSYNC
sta WSYNC ; 3 VSYNC lines
lda #0
sta VSYNC
lda #2
sta VBLANK
sta CTRLPF ; reflect off (we draw both halves explicitly)
lda #0
sta CTRLPF
#if IL
; temporal interlace -- alternate the two 4K banks (frame A / frame B)
; each frame; the kernel is identical in both banks so execution
; continues seamlessly and only the data tables differ.
lda PARITY
eor #1
sta PARITY
beq selbank0
lda $1ff9 ; F8 hotspot -> select bank 1 (AD F9 1F)
jmp bankdone
selbank0:
lda $1ff8 ; F8 hotspot -> select bank 0 (AD F8 1F)
bankdone:
#endif
ldx #36
vbl:
sta WSYNC
dex
bne vbl
lda #0
sta VBLANK
; ---- visible image, 192 cycle-exact scanlines ----
ldy #0
kloop:
sta WSYNC
lda BKL,y ; left background (HBLANK)
sta COLUBK
lda PFT,y ; shared foreground
sta COLUPF
lda PF0L,y
sta PF0
lda PF1L,y
sta PF1
lda PF2L,y
sta PF2
lda PF0R,y ; right playfield PF0 (early, before mid-line)
sta PF0
lda BKR,y ; right background (lands at the half boundary)
sta COLUBK
lda PF1R,y
sta PF1
lda PF2R,y
sta PF2
iny
cpy #192
bne kloop
lda #0
sta PF0
sta PF1
sta PF2
sta COLUBK
; ---- hold / exit ----
#if WAITMODE == 1
lda INPT4
bmi over ; bit7 set = fire not pressed
jmp start ; pressed -> reset (re-display)
#endif
#if WAITMODE == 2
inc FRLO
bne fr_ok
inc FRHI
fr_ok:
lda FRHI
cmp #>(WAITSECS*60)
bcc over
lda FRLO
cmp #<(WAITSECS*60)
bcc over
jmp start
#endif
over:
lda #2
sta VBLANK
ldx #30
ovl:
sta WSYNC
dex
bne ovl
jmp frame

View file

@ -0,0 +1,92 @@
"""Assemble the Atari 2600 kernel with `xa` and lay out the cartridge.
A static image is a 4K cart (one kernel + nine 192-byte tables). An interlace
image is an 8K F8-bankswitch cart: two 4K banks, each holding the SAME kernel
plus one of the two table sets (frame A / frame B). The kernel toggles the bank
once per frame, so the two frames alternate at 60Hz and blend to ~4-6 perceived
colours per scanline.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
WAIT_MODES = {"forever": 0, "fire": 1, "key": 1, "seconds": 2}
# page-aligned data tables ($F100..$F900)
TABLES = {"PF0L": 0xF100, "PF1L": 0xF200, "PF2L": 0xF300, "PF0R": 0xF400,
"PF1R": 0xF500, "PF2R": 0xF600, "BKL": 0xF700, "BKR": 0xF800,
"PFT": 0xF900}
ORDER = ["PF0L", "PF1L", "PF2L", "PF0R", "PF1R", "PF2R", "BKL", "BKR", "PFT"]
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble_kernel(display: str, seconds: int, interlace: bool) -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
waitmode = WAIT_MODES.get(display, 0)
defs = "".join(f"#define {n} ${a:04X}\n" for n, a in TABLES.items())
wrapper = (defs +
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define IL {1 if interlace else 0}\n"
'#include "a2600.s"\n')
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "k.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:
kernel = f.read()
finally:
os.unlink(wrap)
if len(kernel) > 0x100:
raise AssemblerError(f"kernel is {len(kernel)} bytes, overruns the table "
"page at $F100")
return kernel
def _lay_bank(kernel: bytes, data: bytes) -> bytearray:
"""One 4096-byte bank: kernel at $F000, nine tables, reset/IRQ vectors."""
rom = bytearray(b"\x00" * 4096)
rom[0:len(kernel)] = kernel
tables = [data[i * 192:(i + 1) * 192] for i in range(9)]
for name, tab in zip(ORDER, tables):
off = TABLES[name] - 0xF000
rom[off:off + 192] = tab
rom[0xFFC] = 0x00 # reset vector lo -> $F000
rom[0xFFD] = 0xF0
rom[0xFFE] = 0x00 # IRQ/BRK vector
rom[0xFFF] = 0xF0
return rom
def build_cart(data: bytes, display: str = "forever", seconds: int = 0) -> bytes:
"""data = nine 192-byte tables (static, 4K cart) or eighteen (two sets ->
interlace 8K F8 cart). Returns the cart image with reset vectors set."""
if len(data) == 9 * 192:
kernel = _assemble_kernel(display, seconds, interlace=False)
return bytes(_lay_bank(kernel, data))
if len(data) == 18 * 192:
kernel = _assemble_kernel(display, seconds, interlace=True)
bank0 = _lay_bank(kernel, data[:9 * 192])
bank1 = _lay_bank(kernel, data[9 * 192:])
return bytes(bank0 + bank1)
raise ValueError(f"expected 1728 or 3456 bytes of tables, got {len(data)}")

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

@ -0,0 +1 @@
"""Atari 5200 SuperSystem target for lenser."""

View file

@ -0,0 +1,23 @@
"""Atari 5200 conversion dispatch.
The 5200 has the same ANTIC + GTIA graphics hardware (and the same 256-colour GTIA
palette) as the Atari 400/800, so it reuses the Atari 8-bit encoders unchanged.
GR.9 doubles as the monochrome / tinted-mono mode (16 real luminance shades of one
hue). GR.15+DLI is omitted: per-scanline DLI colour changes need OS NMI vectoring
the 5200 lacks.
"""
from __future__ import annotations
from ...atari.convert import convert_image as _atari_convert
MODES = ["gr15", "gr9", "gr8", "mono"]
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
if mode not in MODES:
mode = "gr15"
return _atari_convert(path_or_img, mode=mode, palette_name="ntsc",
dither_mode=dither_mode, intensive=intensive,
prep_opt=prep_opt, base_color=base_color)

17
lenser/a5200/exporter.py Normal file
View file

@ -0,0 +1,17 @@
"""Build an Atari 5200 cartridge (.a52) from a conversion."""
from __future__ import annotations
from .viewer import assemble
_EXTS = (".a52", ".bin", ".car", ".rom")
def export_a52(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".a52"
rom = assemble.build_cart(conv.mode, bytes(conv.data), display=display,
seconds=seconds, video=video)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

View file

@ -0,0 +1 @@
"""Atari 5200 6502 viewer (assembled by xa)."""

View file

@ -0,0 +1,131 @@
"""Assemble the Atari 5200 viewer with `xa` and lay out the 32K cartridge.
The cartridge fills $4000-$BFFF. The 5200 BIOS jumps to the address at $BFFE-F
(duplicated at $BFE8-9, with $BFFC=0 / $BFFD=$FF) -- verified in MAME a5200.
ANTIC DMAs the bitmap and display list straight from cartridge ROM, so we only
place them at fixed cart addresses and point ANTIC at them:
$4000 viewer code
$4100 GTIA register script (offset, value pairs, $FF-terminated)
$4200 display list (ANTIC reads it here)
$6000 bitmap (the converter's 4K-split blob -> $6000 / $7000)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
CART_BASE = 0x4000
CART_SIZE = 0x8000
SCRIPT_ADDR = 0x4100
DLIST_ADDR = 0x4200
BITMAP_ADDR = 0x6000 # 4K-aligned: blob $4000/$5000 -> $6000/$7000
SPLIT_LINE = 102 # matches atari _common.split_screen
LINES = 192
# ANTIC mode byte + GTIA register script per display mode. Script entries are
# (GTIA offset from $C000, colour value); colours come from the converter blob.
ANTIC_MODE = {"gr15": 0x0E, "gr8": 0x0F, "gr9": 0x0F}
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
def _gtia_script(mode: str, colors) -> bytes:
c = list(colors)
if mode == "gr15": # 4 colours: COLBK, COLPF0, COLPF1, COLPF2
regs = [(0x1A, c[0]), (0x16, c[1]), (0x17, c[2]), (0x18, c[3]), (0x1B, 0x00)]
elif mode == "gr8": # 2 colours: background (COLPF2+COLBK), fg (COLPF1)
regs = [(0x18, c[0]), (0x1A, c[0]), (0x17, c[1]), (0x1B, 0x00)]
else: # gr9: hue in COLBK, GTIA mode 9 via PRIOR
regs = [(0x1A, c[0]), (0x1B, 0x40)]
out = bytearray()
for off, val in regs:
out += bytes([off & 0xFF, val & 0xFF])
out.append(0xFF) # terminator
return bytes(out)
def _dlist(mode: str) -> bytes:
m = ANTIC_MODE[mode]
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank scan lines
dl += bytes([0x40 | m, 0x00, 0x60]) # LMS -> $6000
dl += bytes([m] * (SPLIT_LINE - 1)) # 102 lines from $6000
dl += bytes([0x40 | m, 0x00, 0x70]) # LMS -> $7000
dl += bytes([m] * (LINES - SPLIT_LINE - 1)) # 90 lines from $7000
dl += bytes([0x41, DLIST_ADDR & 0xFF, DLIST_ADDR >> 8]) # JVB -> dlist (loop)
return bytes(dl)
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble(display: str = "forever", seconds: int = 0,
video: str = "ntsc") -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
waitmode = WAIT_MODES.get(display, 0)
rate = 50 if video == "pal" else 60
wrapper = (f"#define SCRIPT ${SCRIPT_ADDR:04X}\n"
f"#define DLIST ${DLIST_ADDR:04X}\n"
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(1, int(seconds))}\n"
f"#define RATE {rate}\n"
'#include "viewer.s"\n')
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)
def build_cart(mode: str, data: bytes, display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
"""data = the atari converter blob (bitmap 4K-split at offset 0/0x1000, colour
register bytes from offset 0x2000)."""
if mode not in ANTIC_MODE:
raise ValueError(f"unsupported 5200 mode {mode}")
code = _assemble(display, seconds, video)
bitmap = data[:0x2000]
colors = data[0x2000:]
script = _gtia_script(mode, colors)
dlist = _dlist(mode)
rom = bytearray(b"\xff" * CART_SIZE)
def place(addr, blob):
off = addr - CART_BASE
rom[off:off + len(blob)] = blob
place(CART_BASE, code)
place(SCRIPT_ADDR, script)
place(DLIST_ADDR, dlist)
place(BITMAP_ADDR, bitmap)
# cartridge header (verified in MAME a5200)
def put16(addr, val):
off = addr - CART_BASE
rom[off] = val & 0xFF
rom[off + 1] = (val >> 8) & 0xFF
put16(0xBFFE, CART_BASE) # start address -- BIOS jumps here
put16(0xBFE8, CART_BASE) # duplicate (validation)
rom[0xBFFC - CART_BASE] = 0x00
rom[0xBFFD - CART_BASE] = 0xFF
return bytes(rom)

View file

@ -0,0 +1,53 @@
; Atari 5200 "how long to show the picture" epilogue (no OS, pure hardware).
; Selected at assembly time by WAITMODE (set by a5200/viewer/assemble.py):
; 0 forever -- just loop
; 1 until a button -- poll the keypad (POKEY) and triggers (GTIA), then reset
; 2 WAITSECS secs -- count frames via ANTIC VCOUNT, then reset
; "Exit" jumps through the BIOS reset vector, which re-runs the cartridge and so
; re-displays the picture (a cartridge console has no OS to return to).
#if WAITMODE == 0
awloop:
jmp awloop
#endif
#if WAITMODE == 1
lda #$03
sta $e80f ; POKEY SKCTL -- enable keypad scanning
awloop:
lda $c010 ; GTIA TRIG0 (fire button); 0 = pressed
and #$01
beq awexit
lda $e80f ; POKEY SKSTAT; bit2 = 0 while a keypad key is down
and #$04
bne awloop
awexit:
jmp ($fffc) ; BIOS reset -> re-display
#endif
#if WAITMODE == 2
lda #<(WAITSECS*RATE)
sta $80
lda #>(WAITSECS*RATE)
sta $81
awloop:
lda $80
ora $81
beq awexit ; counted all the frames
vw1:
lda $d40b ; ANTIC VCOUNT
bne vw1 ; wait for top of frame (VCOUNT = 0)
vw2:
lda $d40b
beq vw2 ; wait until it leaves the top -> one frame elapsed
lda $80
sec
sbc #$01
sta $80
lda $81
sbc #$00
sta $81
jmp awloop
awexit:
jmp ($fffc) ; BIOS reset -> re-display
#endif

View file

@ -0,0 +1,51 @@
; Atari 5200 image viewer -- self-contained, no OS.
;
; The 5200 has no operating system (only a small BIOS that jumps straight to the
; cartridge), so this code programmes ANTIC and GTIA hardware registers directly.
; ANTIC DMAs the bitmap and display list straight out of cartridge ROM, so nothing
; needs copying to RAM -- the viewer only applies the GTIA colour registers, points
; ANTIC at the display list, enables DMA, and holds.
;
; GTIA is at $C000 on the 5200 (not $D000 as on the 400/800); ANTIC is at $D400.
;
; #defines from viewer/assemble.py --
; DLIST cartridge address of the display list
; SCRIPT cartridge address of the GTIA register script -- (offset, value)
; byte pairs applied as $C000+offset = value, terminated by $FF
* = $4000
start:
sei
cld
ldx #$ff
txs
lda #$00
sta $d40e ; NMIEN = 0 -- no interrupts
sta $d400 ; DMACTL = 0 while we set up
; ---- apply the GTIA register script (offset, value pairs) ----
ldx #$00
sloop:
lda SCRIPT,x
cmp #$ff
beq sdone
tay ; Y = register offset within GTIA
inx
lda SCRIPT,x
sta $c000,y ; GTIA register = value
inx
jmp sloop
sdone:
; ---- point ANTIC at the display list ----
lda #<DLIST
sta $d402 ; DLISTL
lda #>DLIST
sta $d403 ; DLISTH
; ---- enable DMA -- normal playfield + display-list DMA ----
lda #$22
sta $d400 ; DMACTL
; ---- hold the picture (forever / until a button / N seconds) ----
#include "awyt5200.i"

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

@ -0,0 +1 @@
"""Atari 7800 ProSystem target for lenser (MARIA display processor)."""

View file

@ -0,0 +1,30 @@
"""Atari 7800 conversion dispatch.
The 7800's MARIA chip has its own architecture (display lists + zones + objects),
nothing like ANTIC/GTIA -- but its 160A graphics mode is 2 bits/pixel, 4 px/byte,
exactly the packing of ANTIC mode E (Atari GR.15). So the per-mode encoders pack
160x192 2bpp bitmaps the same way GR.15 does and reuse the Atari 256-colour NTSC
palette + dither-aware selection; the MARIA-specific display lists are built by
the cartridge packer (viewer/assemble.py).
Modes: ``c160`` = 25-colour 160A (8 MARIA palettes, per-segment palette choice);
``mono`` = luminance two-tone / tinted.
"""
from __future__ import annotations
from ... import imageprep
from . import c160, mono
_MODULES = {"c160": c160, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="c160", palette_name="ntsc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, c160)
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,127 @@
"""Atari 7800 MARIA 160A colour mode -- 160x192, 4 px/byte (2bpp).
MARIA gives 8 palettes of 3 colours each plus one shared background = up to 25
colours on screen. The line is split into 8 objects of 20 px (5 bytes) each, and
every object may use a different palette, so each 20 px segment can pick the
3-colour palette (+ shared background) that fits it best -- chosen globally by
clustering the segments into 8 palettes. The 2bpp bitmap packing is identical to
Atari GR.15, so the Atari encoder helpers are reused.
Conversion.data = bitmap(7680) + seg_palettes(192*8) + colours(25):
bitmap 192 lines x 40 bytes, 2bpp, value 0..3 per pixel
seg_palettes one palette index (0..7) per 20px segment (8 per line)
colours [BACKGRND, P0C1,P0C2,P0C3, P1C1..P7C3] (MARIA register order)
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert.base import Conversion, perceptual_error, DIFFUSION_DITHERS
from ...atari import palette as apal
from ...atari.convert import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
SEG_W = 40 # pixels per object (10 bytes, 2bpp)
N_SEG = WIDTH // SEG_W # 4 objects per line (MARIA DMA budget)
N_PAL = 8 # MARIA palettes
def _pack_bitmap(val_image):
return b"".join(bytes(b) for b in _common.pack_2bpp(val_image))
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None, candidates=None, _mode="c160"):
plab = apal.palette_lab("ntsc")
prgb = apal.get_palette("ntsc").astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
bg, palettes, seg_pal, idx = _solve(img_lab, plab, dither_mode, intensive,
candidates)
# value image (0..3) per pixel: 0 = background, 1..3 = the segment palette's
# three colours; build it from idx (palette indices) + the per-segment palette.
val = np.zeros((HEIGHT, WIDTH), np.uint8)
for row in range(HEIGHT):
for s in range(N_SEG):
x0 = s * SEG_W
pal = [bg] + palettes[seg_pal[row, s]]
lut = {c: v for v, c in enumerate(pal)}
block = idx[row, x0:x0 + SEG_W]
val[row, x0:x0 + SEG_W] = [lut[int(c)] for c in block]
bitmap = _pack_bitmap(val)
seg_bytes = bytes(seg_pal.reshape(-1).astype(np.uint8))
colours = bytearray([bg])
for p in range(N_PAL):
colours += bytes(palettes[p])
data = bitmap + seg_bytes + bytes(colours)
err = perceptual_error(idx, img_lab, plab)
return Conversion(
mode=_mode, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="a7800", preview_rgb=np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1),
error=err, meta={"palette": "ntsc", "dither": dither_mode, "bg": bg},
)
def _pick(pixels_lab, plab, k, diff, candidates):
"""Pick k palette colours best representing the given pixels."""
if diff:
return _common.choose_palette_dither(pixels_lab, plab, k=k,
candidates=candidates)
if candidates is not None:
sub = plab[candidates]
return [candidates[i] for i in _common.choose_palette(pixels_lab, sub, k=k)]
return _common.choose_palette(pixels_lab, plab, k=k)
def _solve(img_lab, plab, dither_mode, intensive, candidates=None):
"""Choose a shared background + 8 three-colour palettes by clustering the
image's 40px segments by colour (so each palette is tuned to a group of
similar segments), assign each segment its cluster's palette, and dither.
Returns (bg, palettes[8][3], seg_pal, idx)."""
from ... import dither as _dith
diff = dither_mode in DIFFUSION_DITHERS
H, W, _ = img_lab.shape
# shared background = darkest of a small global palette
gp = _pick(img_lab, plab, 8, diff, candidates)
bg = min(gp, key=lambda c: plab[c, 0])
# cluster the H*N_SEG segments by their mean colour into N_PAL groups
seg_means = img_lab.reshape(H, N_SEG, SEG_W, 3).mean(axis=2) # (H,N_SEG,3)
feats = seg_means.reshape(-1, 3)
rng = np.random.default_rng(0)
cent = feats[rng.choice(len(feats), N_PAL, replace=False)].copy()
labels = np.zeros(len(feats), np.int64)
for _ in range(12):
labels = ((feats[:, None] - cent[None]) ** 2).sum(-1).argmin(1)
for g in range(N_PAL):
m = labels == g
if m.any():
cent[g] = feats[m].mean(0)
seg_pal = labels.reshape(H, N_SEG)
# each palette = 3 colours tuned to the pixels of its cluster's segments
seg_px = img_lab.reshape(H, N_SEG, SEG_W, 3)
palettes = []
for g in range(N_PAL):
mask = seg_pal == g # (H,N_SEG)
if not mask.any():
palettes.append([bg, bg, bg])
continue
px = seg_px[mask].reshape(-1, 1, 3) # (Npix,1,3)
palettes.append(_pick(px, plab, 3, diff, candidates))
# dither the whole image with each segment restricted to {bg} + its palette
sets = [[bg] + palettes[g] for g in range(N_PAL)]
allowed = np.zeros((H, W, 4), np.int64)
for r in range(H):
for s in range(N_SEG):
allowed[r, s * SEG_W:s * SEG_W + SEG_W] = sets[seg_pal[r, s]]
idx = _dith.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
return bg, palettes, seg_pal, idx

View file

@ -0,0 +1,36 @@
"""Atari 7800 monochrome / tinted-mono (MARIA 160A restricted to one hue).
Reuses the c160 machinery but restricts the colour pool to the 16 luminances of a
single hue (hue 0 = greyscale), so the 8 palettes become up to ~16 grey levels and
the per-segment palette choice yields a smooth, detailed luminance image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert.base import DIFFUSION_DITHERS, perceptual_error
from ...atari import palette as apal
from . import c160
WIDTH, HEIGHT, PIXEL_ASPECT = c160.WIDTH, c160.HEIGHT, c160.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
# mono is carried by dithering -> needs error diffusion
if dither_mode not in DIFFUSION_DITHERS:
dither_mode = "floyd"
hue = 0 if base_color is None else (int(base_color) & 0x0F)
candidates = list(range(hue * 16, hue * 16 + 16)) # 16 lums of this hue
conv = c160.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color, candidates=candidates, _mode="mono")
# report error in LUMINANCE space (a greyscale image must not be scored
# against the colour original, as the colour modes are)
plab = apal.palette_lab("ntsc").copy()
plab[:, 1:] = 0.0
img_l = c64pal.srgb_to_lab(img_rgb)
img_l[..., 1:] = 0.0
conv.error = perceptual_error(np.asarray(conv.index_image), img_l, plab)
conv.meta["base_color"] = base_color
return conv

19
lenser/a7800/exporter.py Normal file
View file

@ -0,0 +1,19 @@
"""Build an Atari 7800 cartridge (.a78) from a conversion."""
from __future__ import annotations
import os
from .viewer import assemble
_EXTS = (".a78", ".bin")
def export_a78(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".a78"
title = os.path.splitext(os.path.basename(source_path or output_path))[0]
rom = assemble.build_cart(bytes(conv.data), title=title)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

View file

@ -0,0 +1 @@
"""Atari 7800 6502/MARIA viewer (assembled by xa)."""

View file

@ -0,0 +1,139 @@
"""Assemble the Atari 7800 viewer with `xa` and lay out the 48K .a78 cartridge.
MARIA reads the display-list list (DLL), the per-line display lists and the bitmap
by DMA straight from cartridge ROM, so the packer just places them at fixed cart
addresses and the viewer points MARIA at the DLL.
ROM layout ($4000-$FFFF, 48K):
$4000 viewer code
$4100 colour register script (reg, value pairs, $FF-terminated)
$8000 bitmap 192 lines x 40 bytes (2bpp)
$A000 display lists 192 x 34 bytes (8 objects + end marker per line)
$BA00 DLL 192 x 3 bytes (one 1-line zone per line)
$FFFA 6502 vectors (NMI/RESET/IRQ -> $4000)
"""
from __future__ import annotations
import os
import shutil
import struct
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
CART_BASE = 0x4000
CART_SIZE = 0xC000 # 48K
SCRIPT_ADDR = 0x4100
BITMAP_ADDR = 0x8000
DL_BASE = 0xA000
DLL_ADDR = 0xBA00
WIDTH, LINES = 160, 192
BYTES_PER_LINE = 40
SEG_W_BYTES = 10 # 10 bytes = 40 px per object
N_SEG = 4
SEG_W_PX = 40
DL_LEN = N_SEG * 4 + 2 # 4 objects (4 bytes) + 2-byte end = 18
# MARIA colour-register addresses in the order the converter emits them:
# BACKGRND, then P0C1..P0C3, P1C1.., ... P7C1..P7C3.
COLOR_REGS = [0x20]
for _p in range(8):
COLOR_REGS += [0x21 + 4 * _p, 0x22 + 4 * _p, 0x23 + 4 * _p]
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble() -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
wrapper = (f"#define SCRIPT ${SCRIPT_ADDR:04X}\n"
f"#define DLL ${DLL_ADDR:04X}\n"
'#include "viewer.s"\n')
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)
def _a78_header(rom_len: int, title: str) -> bytes:
h = bytearray(128)
h[0] = 1 # header version
h[1:1 + 9] = b"ATARI7800"
h[17:17 + 32] = title.encode("ascii", "replace")[:32].ljust(32, b"\x00")
h[49:53] = struct.pack(">I", rom_len) # ROM size (excl. header)
# cart type 0 = plain linear; controllers = joystick; NTSC
h[55] = 1
h[56] = 1
h[57] = 0
h[100:100 + 28] = b"ACTUAL CART DATA STARTS HERE"
return bytes(h)
def build_cart(data: bytes, title: str = "8bitlenser") -> bytes:
"""data = converter blob: bitmap(7680) + seg_palettes(192*8) + colours(25)."""
bitmap = data[:LINES * BYTES_PER_LINE]
seg_pal = data[LINES * BYTES_PER_LINE:LINES * BYTES_PER_LINE + LINES * N_SEG]
colours = data[LINES * BYTES_PER_LINE + LINES * N_SEG:]
code = _assemble()
rom = bytearray(b"\x00" * CART_SIZE)
def place(addr, blob):
off = addr - CART_BASE
rom[off:off + len(blob)] = blob
place(CART_BASE, code)
# colour register script: (reg, value) pairs, $FF terminator
script = bytearray()
for reg, val in zip(COLOR_REGS, colours):
script += bytes([reg, val])
script.append(0xFF)
place(SCRIPT_ADDR, script)
place(BITMAP_ADDR, bitmap)
# per-line display lists (8 objects of 5 bytes, each its own palette)
dls = bytearray()
for line in range(LINES):
for s in range(N_SEG):
gfx = BITMAP_ADDR + line * BYTES_PER_LINE + s * SEG_W_BYTES
pal = seg_pal[line * N_SEG + s] & 0x07
width = (-SEG_W_BYTES) & 0x1F # two's-complement byte count
dls += bytes([gfx & 0xFF, (pal << 5) | width, gfx >> 8, s * SEG_W_PX])
dls += bytes([0x00, 0x00]) # end of DL
place(DL_BASE, dls)
# display-list list: one 1-line zone per line
dll = bytearray()
for line in range(LINES):
dl = DL_BASE + line * DL_LEN
dll += bytes([0x00, dl >> 8, dl & 0xFF]) # offset 0 (1 line), DL hi, lo
place(DLL_ADDR, dll)
# 6502 vectors -> viewer entry ($4000)
for v in (0xFFFA, 0xFFFC, 0xFFFE):
off = v - CART_BASE
rom[off] = CART_BASE & 0xFF
rom[off + 1] = CART_BASE >> 8
return _a78_header(len(rom), title) + bytes(rom)

View file

@ -0,0 +1,47 @@
; Atari 7800 image viewer -- programs MARIA, then holds.
;
; MARIA DMAs the display-list list (DLL), the per-line display lists (DLs) and the
; bitmap straight out of cartridge ROM, so the viewer only loads MARIA's colour
; registers, points it at the DLL, turns DMA on, and loops. MARIA registers live
; in zero page ($20-$3F).
;
; #defines from viewer/assemble.py --
; SCRIPT address of the colour register script -- (reg, value) byte pairs
; written as $0000+reg = value, terminated by $FF
; DLL address of the display-list list
* = $4000
reset:
sei
cld
ldx #$ff
txs
lda #$00
sta $3c ; CTRL = 0 -- DMA off while we set up
; ---- load MARIA colour registers from the script ----
ldx #$00
sloop:
lda SCRIPT,x
cmp #$ff
beq sdone
tay ; Y = MARIA register ($20..$3F)
inx
lda SCRIPT,x
sta $0000,y
inx
jmp sloop
sdone:
; ---- point MARIA at the display-list list ----
lda #>DLL
sta $2c ; DPPH
lda #<DLL
sta $30 ; DPPL
; ---- enable DMA (160A, no DLI) ----
lda #$40
sta $3c ; CTRL
loop:
jmp loop ; the packer fills the reset vectors at $FFFA

0
lenser/amiga/__init__.py Normal file
View file

View file

@ -0,0 +1,23 @@
"""Commodore Amiga conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import lowres, mono
# NOTE: HAM6 (4096 colours) was implemented and looks superb, but MAME's
# preliminary Amiga can't render 6-bitplane/HAM modes cleanly (fixed-position
# black bands at the screen edges), so only the MAME-verified 5-plane low-res
# (32 colours) and mono modes are shipped.
_MODULES = {"lowres": lowres, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="lowres", palette_name="amiga",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, lowres)
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,130 @@
"""Amiga encoders: HAM6 (4096 colours), flat low-res (<=32 of 4096), greyscale.
HAM (Hold-And-Modify) is the showpiece: 6 bitplanes where the top 2 bits choose
whether a pixel is one of 16 base palette colours or modifies one R/G/B channel
of the pixel to its left -- giving up to 4096 colours on screen. We pick 16 base
colours, then walk each scanline left-to-right choosing per pixel the option
(set / modify R / modify G / modify B) closest to the target.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import _box_blur
from .. import palette as apal
W, H = 320, 200
def _perr(final_rgb, img_rgb):
a = _box_blur(c64pal.srgb_to_lab(final_rgb.astype(np.float64)))
b = _box_blur(c64pal.srgb_to_lab(img_rgb.astype(np.float64)))
return float(np.sqrt(((a - b) ** 2).sum(-1)).mean())
def planar_split(codes, nplanes):
"""codes (H,W) -> nplanes contiguous bitplanes (40 bytes/line, MSB left)."""
out = bytearray()
for p in range(nplanes):
bit = ((codes >> p) & 1).astype(np.uint8)
out += np.packbits(bit, axis=1).reshape(-1).tobytes() # (H,40)
return bytes(out)
def _kmeans_keys(img_lab, plab, k, iters=12):
rng = np.random.default_rng(0)
flat = img_lab.reshape(-1, 3)
pts = flat[rng.choice(len(flat), min(6000, len(flat)), replace=False)]
# k-means++ style seeding so every centroid is a real, distinct colour
cen = pts[rng.integers(len(pts))][None].copy()
for _ in range(k - 1):
d = np.min(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1)
cen = np.vstack([cen, pts[int(d.argmax())]]) # farthest-point seed
for _ in range(iters):
lab = np.argmin(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1)
for j in range(k):
m = pts[lab == j]
cen[j] = m.mean(0) if len(m) else pts[rng.integers(len(pts))]
# snap each centroid to the nearest 4096 palette key
keys = [int(np.argmin(((plab - c) ** 2).sum(1))) for c in cen]
return keys
def ham_encode(img_rgb, dither_mode):
plab = apal.palette_lab() # (4096,3)
img_lab = c64pal.srgb_to_lab(img_rgb)
base_keys = _kmeans_keys(img_lab, plab, 16)
base_lab = plab[base_keys]
base_rgb4 = [(k >> 8 & 15, k >> 4 & 15, k & 15) for k in base_keys]
t4 = np.clip(np.rint(img_rgb / 17.0), 0, 15).astype(np.int64) # 4-bit targets
# base option (independent of the held colour) precomputed for all pixels
bd = ((img_lab[:, :, None, :] - base_lab[None, None]) ** 2).sum(3) # (H,W,16)
base_best = bd.argmin(2)
base_cost = bd.min(2)
codes = np.zeros((H, W), np.uint8)
final = np.zeros((H, W, 3), np.uint8)
P = plab
for y in range(H):
tl = img_lab[y]; t4y = t4[y]
bb = base_best[y]; bc = base_cost[y]
pr = pg = pb = 0 # hardware holds black at line start
for x in range(W):
t0, t1, t2 = tl[x, 0], tl[x, 1], tl[x, 2]
bi = int(bb[x]); best = bc[x]
ctrl = 0; data = bi; nr, ng, nb = base_rgb4[bi]
# force an absolute "set" on the first pixel so the line establishes a
# colour regardless of the held-colour start (an all-modify run from
# the wrong start would otherwise stay dark -- HAM left-edge bug).
if x > 0:
tr, tg, tb = int(t4y[x, 0]), int(t4y[x, 1]), int(t4y[x, 2])
for c_ctrl, c_data, kr, kg, kb in (
(2, tr, tr, pg, pb), (3, tg, pr, tg, pb), (1, tb, pr, pg, tb)):
pk = P[(kr << 8) | (kg << 4) | kb]
dr = pk[0] - t0; dg = pk[1] - t1; db = pk[2] - t2
cc = dr * dr + dg * dg + db * db
if cc < best:
best = cc; ctrl = c_ctrl; data = c_data; nr, ng, nb = kr, kg, kb
codes[y, x] = (ctrl << 4) | data
pr, pg, pb = nr, ng, nb
final[y, x, 0] = pr * 17; final[y, x, 1] = pg * 17; final[y, x, 2] = pb * 17
planes = planar_split(codes, 6)
colors = [apal.color_word(k) for k in base_keys] # 16 base registers
return planes, colors, final, _perr(final, img_rgb)
def flat_encode(img_rgb, n_colors, dither_mode, mono=False, base_color=None):
plab = apal.palette_lab()
prgb = apal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
nplanes = (n_colors - 1).bit_length() # 32->5, 16->4
if mono:
keys = list(apal.GREYS) # 16 greys
if base_color in range(4096):
keys = sorted({keys[0], int(base_color), keys[-1]}, key=lambda i: plab[i, 0])
keys = (keys + keys)[:n_colors]
work = np.zeros_like(img_lab); work[..., 0] = img_lab[..., 0]
pw = np.zeros_like(plab); pw[:, 0] = plab[:, 0]
else:
keys = _kmeans_keys(img_lab, plab, n_colors)
work, pw = img_lab, plab
allowed = np.tile(np.array(keys[:n_colors]), (H, W, 1))
qidx = dither.quantize(work, allowed, pw, dither_mode).astype(np.int64)
# map palette key -> pen index
lut = {k: i for i, k in enumerate(keys[:n_colors])}
pen = np.vectorize(lut.get)(qidx).astype(np.uint8)
planes = planar_split(pen, nplanes)
colors = [apal.color_word(k) for k in keys[:n_colors]]
final = prgb[qidx]
if mono: # measure greyscale against luminance
g = img_rgb.mean(2, keepdims=True).repeat(3, 2)
err = _perr(final, g.astype(np.uint8))
else:
err = _perr(final, img_rgb)
return planes, colors, final, err

View file

@ -0,0 +1,21 @@
"""Amiga low resolution: 320x200, 32 colours from the 4096-colour palette."""
from __future__ import annotations
from ...convert.base import Conversion
from . import _common
WIDTH, HEIGHT = 320, 200
PIXEL_ASPECT = 1.0
NCOL = 32
def convert(img_rgb, palette_name="amiga", dither_mode="floyd",
intensive=False, base_color=None):
planes, colors, preview, err = _common.flat_encode(img_rgb, NCOL, dither_mode)
return Conversion(
mode="lowres", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=None,
data={"planes": planes, "colors": colors, "nplanes": 5, "ham": False},
data_addr=0, viewer="amiga", preview_rgb=preview, error=err,
meta={"palette": "amiga", "dither": dither_mode},
)

View file

@ -0,0 +1,22 @@
"""Amiga monochrome: 320x200, 16 grey levels (the Amiga has true 16 greys)."""
from __future__ import annotations
from ...convert.base import Conversion
from . import _common
WIDTH, HEIGHT = 320, 200
PIXEL_ASPECT = 1.0
NCOL = 16
def convert(img_rgb, palette_name="amiga", dither_mode="floyd",
intensive=False, base_color=None):
planes, colors, preview, err = _common.flat_encode(
img_rgb, NCOL, dither_mode, mono=True, base_color=base_color)
return Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=None,
data={"planes": planes, "colors": colors, "nplanes": 4, "ham": False},
data_addr=0, viewer="amiga", preview_rgb=preview, error=err,
meta={"palette": "amiga", "dither": dither_mode},
)

41
lenser/amiga/copper.py Normal file
View file

@ -0,0 +1,41 @@
"""Build an Amiga Copper list that displays a static 320x200 low-res screen.
The Copper runs every frame and re-loads the bitplane pointers + registers, so
the picture stays stable. Each instruction is a MOVE (register offset, value);
the list ends with WAIT $FFFF,$FFFE.
"""
from __future__ import annotations
import struct
DIWSTRT, DIWSTOP, DDFSTRT, DDFSTOP = 0x08E, 0x090, 0x092, 0x094
BPLCON0, BPLCON1, BPLCON2 = 0x100, 0x102, 0x104
BPL1MOD, BPL2MOD = 0x108, 0x10A
BPLPT = 0x0E0 # BPL1PTH; +4 per plane
COLOR0 = 0x180
def list_len(nplanes, ncolors) -> int:
pairs = 9 + nplanes * 2 + ncolors # DIW*2, DDF*2, BPLCON0/1/2, BPLMOD*2 = 9
return pairs * 4 + 4 # + end WAIT
def build(nplanes, ham, plane_addrs, colors) -> bytes:
bplcon0 = (nplanes << 12) | (0x0800 if ham else 0) | 0x0200
moves = [
(DIWSTRT, 0x2C81), (DIWSTOP, 0xF4C1),
(DDFSTRT, 0x0038), (DDFSTOP, 0x00D0),
(BPLCON0, bplcon0), (BPLCON1, 0x0000), (BPLCON2, 0x0024),
(BPL1MOD, 0x0000), (BPL2MOD, 0x0000),
]
for p, addr in enumerate(plane_addrs):
moves.append((BPLPT + p * 4, (addr >> 16) & 0xFFFF))
moves.append((BPLPT + p * 4 + 2, addr & 0xFFFF))
for i, c in enumerate(colors):
moves.append((COLOR0 + i * 2, c & 0x0FFF))
out = bytearray()
for reg, val in moves:
out += struct.pack(">HH", reg & 0x1FE, val & 0xFFFF)
out += struct.pack(">HH", 0xFFFF, 0xFFFE) # end of copper list
return bytes(out)

13
lenser/amiga/exporter.py Normal file
View file

@ -0,0 +1,13 @@
"""Build a bootable Amiga .adf floppy from a conversion."""
from __future__ import annotations
from . import viewer
def export_adf(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(".adf"):
output_path += ".adf"
d = conv.data
adf = viewer.build_adf(d["planes"], d["colors"], d["nplanes"], d["ham"])
return viewer.write_adf(adf, output_path)

30
lenser/amiga/palette.py Normal file
View file

@ -0,0 +1,30 @@
"""Commodore Amiga (OCS/ECS) colour palette.
12-bit colour: 4 bits per channel (x17 -> 0..255), 4096 colours. A colour
register value is %0000 RRRR GGGG BBBB. We index the master palette by the
packed 12-bit key (R<<8)|(G<<4)|B, which is also the register word.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
_r = (np.arange(4096) >> 8) & 0xF
_g = (np.arange(4096) >> 4) & 0xF
_b = np.arange(4096) & 0xF
PALETTE = (np.stack([_r, _g, _b], axis=1) * 17).astype(np.float64) # 4096 x 3
GREYS = [(v << 8) | (v << 4) | v for v in range(16)] # 16 grey keys
def color_word(key: int) -> int:
return key & 0x0FFF
def get_palette() -> np.ndarray:
return PALETTE
def palette_lab() -> np.ndarray:
return srgb_to_lab(PALETTE)

242
lenser/amiga/viewer.py Normal file
View file

@ -0,0 +1,242 @@
"""Build a bootable Amiga .adf that displays a low-res / HAM image.
The boot block (first 1024 bytes) holds a 68000 routine that Kickstart runs at
boot: it reuses the boot trackdisk IORequest to read the Copper list + bitplanes
from the floppy into chip RAM at $20000, points the Copper there, enables
bitplane DMA, kills interrupts (so the OS can't reclaim the display), and idles.
"""
from __future__ import annotations
import struct
from . import copper
LOAD = 0x20000 # chip-RAM load address for copper list + bitplanes
PLANE_SIZE = 40 * 200 # 8000 bytes per bitplane
ADF_SIZE = 901120 # 880K (80*2*11*512)
def _boot_code(datalen: int) -> bytes:
"""68000 boot routine (entry: a1 = trackdisk IORequest)."""
c = bytearray()
c += bytes.fromhex("2C780004") # movea.l $4.w,a6 (ExecBase)
c += bytes.fromhex("337C0002001C") # move.w #2,$1C(a1) CMD_READ
c += b"\x23\x7C" + struct.pack(">I", datalen) + b"\x00\x24" # move.l #len,$24(a1)
c += b"\x23\x7C" + struct.pack(">I", LOAD) + b"\x00\x28" # move.l #LOAD,$28(a1)
c += b"\x23\x7C" + struct.pack(">I", 1024) + b"\x00\x2C" # move.l #1024,$2C(a1)
c += bytes.fromhex("4EAEFE38") # jsr -456(a6) DoIO
c += bytes.fromhex("4BF900DFF000") # lea $dff000,a5
c += bytes.fromhex("3B7C7FFF009A") # move.w #$7FFF,$9A(a5) INTENA off
c += bytes.fromhex("3B7C7FFF009C") # move.w #$7FFF,$9C(a5) INTREQ clr
c += bytes.fromhex("3B7C7FFF0096") # move.w #$7FFF,$96(a5) DMACON off
c += b"\x2B\x7C" + struct.pack(">I", LOAD) + b"\x00\x80" # move.l #LOAD,$80(a5) COP1LC
c += bytes.fromhex("3B7C83800096") # move.w #$8380,$96(a5) DMACON on
c += bytes.fromhex("3B7C00000088") # move.w #0,$88(a5) COPJMP1 strobe
c += bytes.fromhex("60FE") # bra *
return bytes(c)
def _bootsum(block: bytes) -> int:
s = 0
for i in range(0, 1024, 4):
s += int.from_bytes(block[i:i + 4], "big")
if s > 0xFFFFFFFF:
s = (s + 1) & 0xFFFFFFFF
return (~s) & 0xFFFFFFFF
def build_adf(planes: bytes, colors, nplanes: int, ham: bool) -> bytes:
clen = copper.list_len(nplanes, len(colors))
plane_addrs = [LOAD + clen + p * PLANE_SIZE for p in range(nplanes)]
cop = copper.build(nplanes, ham, plane_addrs, colors)
assert len(cop) == clen
blob = cop + planes
datalen = (len(blob) + 511) // 512 * 512 # whole sectors for trackdisk
code = _boot_code(datalen)
boot = bytearray(1024)
boot[0:4] = b"DOS\x00"
boot[12:12 + len(code)] = code # Kickstart jumps to offset 12
boot[4:8] = b"\x00\x00\x00\x00"
struct.pack_into(">I", boot, 4, _bootsum(bytes(boot)))
adf = bytearray(ADF_SIZE)
adf[0:1024] = boot
adf[1024:1024 + len(blob)] = blob # copper + bitplanes follow
return bytes(adf)
def write_adf(adf: bytes, path: str) -> str:
with open(path, "wb") as f:
f.write(adf)
return path
# --------------------------------------------------------------------------- #
# slideshow
# --------------------------------------------------------------------------- #
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
class _M68k:
"""Tiny 68000 emitter with label/branch fixups (hand-encoded opcodes, but the
displacements are computed, not counted by hand)."""
def __init__(self):
self.code = bytearray()
self.labels: dict[str, int] = {}
self.fixups: list[tuple[int, str]] = [] # (disp-word position, label)
def hexs(self, h): self.code.extend(bytes.fromhex(h)); return self
def w(self, v): self.code.extend(struct.pack(">H", v & 0xFFFF)); return self
def l(self, v): self.code.extend(struct.pack(">I", v & 0xFFFFFFFF)); return self
def label(self, name): self.labels[name] = len(self.code); return self
def br(self, opc_hex, name): # Bcc.w / BSR.w / DBcc (opcode + disp16)
self.code.extend(bytes.fromhex(opc_hex))
self.fixups.append((len(self.code), name))
self.w(0)
return self
def resolve(self) -> bytes:
for pos, name in self.fixups:
disp = self.labels[name] - pos # PC base = the displacement word
struct.pack_into(">h", self.code, pos, disp)
return bytes(self.code)
def _ss_boot_code(slot: int, n_images: int, waitmode: int, waitsecs: int,
speed: int, loop: bool) -> bytes:
"""68000 slideshow boot routine (entry: a1 = boot trackdisk IORequest).
Reads image i (slot bytes from floppy offset 1024 + i*slot) into chip RAM at
$20000, points the Copper there and strobes it, waits, then advances.
"""
m = _M68k()
m.hexs("2C780004") # movea.l $4.w,a6 (ExecBase)
m.hexs("4BF900DFF000") # lea $dff000,a5
# ---- read ALL images into chip RAM (interrupts still on, so DoIO works) ----
# image i: slot bytes from floppy offset 1024+i*slot -> RAM $20000 + i*slot
m.hexs("7E00") # moveq #0,d7 (image index)
m.label("read")
m.hexs("2007") # move.l d7,d0
m.hexs("C0FC").w(slot) # mulu #slot,d0 d0 = i*slot
m.hexs("2A00") # move.l d0,d5 save i*slot
m.hexs("0680").l(LOAD) # add.l #$20000,d0
m.hexs("23400028") # move.l d0,$28(a1) dest = $20000+i*slot
m.hexs("2005") # move.l d5,d0
m.hexs("0680").l(1024) # add.l #1024,d0
m.hexs("2340002C") # move.l d0,$2C(a1) offset = 1024+i*slot
m.hexs("337C0002001C") # move.w #2,$1C(a1) CMD_READ
m.hexs("237C").l(slot).hexs("0024") # move.l #slot,$24(a1) length
m.hexs("4EAEFE38") # jsr -456(a6) DoIO
m.hexs("5287") # addq.l #1,d7
m.hexs("0C87").l(n_images) # cmpi.l #n,d7
m.br("6D00", "read") # blt.w read
# ---- take over the display -- kill interrupts/DMA so the OS can't reclaim it
m.hexs("3B7C7FFF009A") # INTENA off
m.hexs("3B7C7FFF009C") # INTREQ clear
m.hexs("3B7C7FFF0096") # DMACON off
m.hexs("7E00") # moveq #0,d7
m.label("main")
m.hexs("2007") # move.l d7,d0
m.hexs("C0FC").w(slot) # mulu #slot,d0
m.hexs("0680").l(LOAD) # add.l #$20000,d0 COP list = $20000+i*slot
m.hexs("2B400080") # move.l d0,$80(a5) COP1LC
m.hexs("3B7C83800096") # move.w #$8380,$96(a5) DMACON on
m.hexs("3B7C00000088") # move.w #0,$88(a5) COPJMP1 strobe
m.br("6100", "wait") # bsr.w wait
m.hexs("5287") # addq.l #1,d7
m.hexs("0C87").l(n_images) # cmpi.l #n,d7
m.br("6D00", "main") # blt.w main
if loop:
m.hexs("7E00") # moveq #0,d7
m.br("6000", "main") # bra.w main
else:
m.label("idle")
m.br("6000", "idle") # bra *
# ---- wait ----
m.label("wait")
if waitmode == 1: # key = left mouse button ($BFE001 bit6, 0=down)
m.label("wk")
m.hexs("123900BFE001") # move.b $bfe001,d1
m.hexs("08010006") # btst #6,d1
m.br("6600", "wk") # bne.w wk (loop while not pressed)
m.hexs("4E75") # rts
elif waitmode == 2: # seconds = delay loop
m.hexs("243C").l(waitsecs) # move.l #secs,d2
m.label("wso")
m.hexs("363C").w(speed) # move.w #speed,d3
m.label("wsm")
m.hexs("323CFFFF") # move.w #$FFFF,d1
m.label("wsi")
m.br("51C9", "wsi") # dbra d1,wsi
m.br("51CB", "wsm") # dbra d3,wsm
m.hexs("5382") # subq.l #1,d2
m.br("6600", "wso") # bne.w wso
m.hexs("4E75") # rts
else: # both = delay loop + mouse poll
m.hexs("243C").l(waitsecs)
m.label("wbo")
m.hexs("363C").w(speed)
m.label("wbm")
m.hexs("123900BFE001") # move.b $bfe001,d1
m.hexs("08010006") # btst #6,d1
m.br("6700", "wbd") # beq.w wbd (pressed -> done)
m.hexs("323CFFFF") # move.w #$FFFF,d1
m.label("wbi")
m.br("51C9", "wbi") # dbra d1,wbi
m.br("51CB", "wbm") # dbra d3,wbm
m.hexs("5382") # subq.l #1,d2
m.br("6600", "wbo") # bne.w wbo
m.label("wbd")
m.hexs("4E75") # rts
return m.resolve()
def image_blob(planes: bytes, colors, nplanes: int, ham: bool,
base: int = LOAD) -> bytes:
"""The data for one image: copper list (with bitplane pointers and colours)
followed by the bitplanes -- loaded verbatim to ``base``. The bitplane
pointers in the copper are absolute, so each slide is built for the RAM
address it will live at."""
clen = copper.list_len(nplanes, len(colors))
plane_addrs = [base + clen + p * PLANE_SIZE for p in range(nplanes)]
return copper.build(nplanes, ham, plane_addrs, colors) + planes
def build_slideshow_adf(images, advance="both", seconds=10, loop=True,
video="ntsc") -> bytes:
"""Build a bootable slideshow ADF. ``images`` is a list of conv.data dicts
(planes/colors/nplanes/ham); each is laid out in its own sector-aligned slot
so the boot can read image i from a fixed floppy offset."""
# blob length is base-independent, so size first, then rebuild each blob for
# the RAM address ($20000 + i*slot) it will be loaded to and cycled from.
sizes = [len(image_blob(im["planes"], im["colors"], im["nplanes"], im["ham"]))
for im in images]
slot = (max(sizes) + 511) // 512 * 512
if 1024 + len(images) * slot > ADF_SIZE:
raise ValueError("slideshow exceeds the 880K floppy")
if LOAD + len(images) * slot > 0x80000:
raise ValueError("slideshow exceeds Amiga chip RAM")
blobs = [image_blob(im["planes"], im["colors"], im["nplanes"], im["ham"],
base=LOAD + i * slot)
for i, im in enumerate(images)]
speed = 11 if video != "pal" else 13 # delay outer ~1s at 7MHz
code = _ss_boot_code(slot, len(blobs), SS_WAITMODE[advance],
max(0, int(seconds)), speed, loop)
if len(code) > 1024 - 12:
raise ValueError("slideshow boot code overruns the 1024-byte boot block")
boot = bytearray(1024)
boot[0:4] = b"DOS\x00"
boot[12:12 + len(code)] = code # Kickstart jumps to offset 12
struct.pack_into(">I", boot, 4, _bootsum(bytes(boot)))
adf = bytearray(ADF_SIZE)
adf[0:1024] = boot
for i, b in enumerate(blobs):
off = 1024 + i * slot
adf[off:off + len(b)] = b
return bytes(adf)

10
lenser/ansi/__init__.py Normal file
View file

@ -0,0 +1,10 @@
"""ANSI / CP437 "BBS art" output.
Not a real machine -- this renders the image as classic 16-colour ANSI text art
suitable for display on a bulletin board system (or any ANSI/CP437 viewer). Each
character cell is the CP437 upper-half-block (``0xDF``) with the foreground colour
painting the top pixel and the background colour the bottom pixel, so one 80-column
row of text is two rows of freely-coloured pixels. Sixteen EGA/VGA colours are
available for both halves (bright backgrounds use "iCE colours"), so the picture is
just a free 16-colour image at 80 x (2*rows).
"""

307
lenser/ansi/convert.py Normal file
View file

@ -0,0 +1,307 @@
"""Convert an image to 16-colour ANSI/CP437 BBS art.
Two encoders share this module:
* **half-block** (fast, the default when ``intensive`` is off) -- every cell is the
CP437 upper-half-block ``0xDF`` with fg = top pixel, bg = bottom pixel, so the
picture is a free 16-colour dither on an 80 x (2*rows) grid (no cell clash).
* **full glyph** (``intensive`` on) -- every 8x16 cell is matched to the best of the
whole CP437 repertoire (letters, punctuation, line- and block-drawing) together
with an optimal foreground/background colour pair, minimising perceptual (CIELAB)
reproduction error. Using the actual glyph shapes -- not just blocks -- reproduces
edges, texture and gradients far better, at the cost of a per-cell search.
Both choose their two colours per cell from the 16 EGA/VGA colours (bright
backgrounds via iCE colours); "mono" restricts them to a grey/tinted ramp.
"""
from __future__ import annotations
import os
import numpy as np
from .. import dither, imageprep, palette as pal
from ..convert import base
# Standard 16-colour EGA/VGA text palette (indices 0..15 = the ANSI colour order:
# black, blue, green, cyan, red, magenta, brown, light-grey, then their bright
# variants). Foreground index -> SGR 30..37 (+bold for 8..15); background index ->
# SGR 40..47 (+"iCE" blink for 8..15).
VGA = np.array([
(0x00, 0x00, 0x00), (0x00, 0x00, 0xAA), (0x00, 0xAA, 0x00), (0x00, 0xAA, 0xAA),
(0xAA, 0x00, 0x00), (0xAA, 0x00, 0xAA), (0xAA, 0x55, 0x00), (0xAA, 0xAA, 0xAA),
(0x55, 0x55, 0x55), (0x55, 0x55, 0xFF), (0x55, 0xFF, 0x55), (0x55, 0xFF, 0xFF),
(0xFF, 0x55, 0x55), (0xFF, 0x55, 0xFF), (0xFF, 0xFF, 0x55), (0xFF, 0xFF, 0xFF),
], dtype=np.uint8)
# Canvas sizes, keyed "COLSxROWS" (character cells). 80x25 is the classic one-screen
# BBS canvas, 80x50 is a taller (scrolling) canvas with twice the vertical detail.
# "mono" is a greyscale (or hue-tinted) 80x25 canvas -- the monochrome mode every
# platform provides.
_DIMS = {"80x25": (80, 25), "80x50": (80, 50), "mono": (80, 25)}
MODES = list(_DIMS.keys())
# The four VGA greys, darkest-to-lightest, used as the monochrome ramp.
_GREY_RAMP = [0, 8, 7, 15] # black, dark grey, light grey, white
UPPER_HALF_BLOCK = 0xDF # CP437 "▀": top half = fg colour, bottom half = bg
PREVIEW_ZOOM = 4 # widen the 80-wide half-block preview for the GUI
GLYPH_W, GLYPH_H = 8, 16 # CP437 text cell (pixels)
_FONT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cp437_8x16.bin")
def _sgr(fg: int, bg: int) -> bytes:
"""SGR escape selecting foreground ``fg`` and background ``bg`` (0..15 each).
Bright foregrounds (8..15) use bold (``1``); bright backgrounds use the blink
bit (``5``) interpreted as high-intensity ("iCE colours"), which every modern
ANSI viewer and most BBS terminals honour.
"""
parts = ["0"]
if fg >= 8:
parts.append("1")
if bg >= 8:
parts.append("5")
parts.append(str(30 + (fg & 7)))
parts.append(str(40 + (bg & 7)))
return b"\x1b[" + ";".join(parts).encode("ascii") + b"m"
def _mono_ramp(base_color) -> list[int]:
"""Luminance-sorted VGA indices for the mono ramp. With no base colour this is
the four greys; with one it is black + the nearest VGA hue (+ white), so the
picture becomes that colour's shades -- the app's tinted-mono behaviour."""
plab = pal.srgb_to_lab(VGA)
if base_color is None:
ramp = set(_GREY_RAMP)
else:
# base_color is a colodore palette index; tint toward its nearest VGA hue.
rgb = pal.get_palette("colodore")[int(base_color)].astype(np.int64)
hue = int(np.argmin(((VGA.astype(np.int64) - rgb) ** 2).sum(axis=1)))
ramp = {0, hue, 15}
return sorted(ramp, key=lambda i: plab[i, 0])
# --------------------------------------------------------------------------- #
# half-block encoder (fast path)
# --------------------------------------------------------------------------- #
def encode_ansi(index_image: np.ndarray, cols: int, rows: int) -> bytes:
"""Encode a (2*rows, cols) index image as a CP437 half-block ANSI byte stream.
Each output row pairs two pixel rows into one text row of ``0xDF`` cells,
emitting a colour escape only when the (fg, bg) pair changes, and resetting at
each line end so a coloured background never bleeds past column 80. Lines end
in CRLF -- on the deferred-wrap ("magic margin") terminals BBSes use, an exact
80-column line plus CRLF advances one row with no blank line.
"""
out = bytearray(b"\x1b[0m")
for r in range(rows):
top = index_image[2 * r]
bot = index_image[2 * r + 1]
last = None
for c in range(cols):
pair = (int(top[c]), int(bot[c]))
if pair != last:
out += _sgr(*pair)
last = pair
out.append(UPPER_HALF_BLOCK)
out += b"\x1b[0m\r\n"
return bytes(out)
def _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp):
W, H = cols, rows * 2
img_lab = pal.srgb_to_lab(rgb)
plab = pal.srgb_to_lab(VGA)
allowed = np.tile(np.asarray(ramp, np.int64), (H, W, 1))
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
preview = np.repeat(np.repeat(VGA[idx], PREVIEW_ZOOM, 0), PREVIEW_ZOOM, 1)
return base.Conversion(
mode=mode, width=W, height=H, pixel_aspect=1.0, index_image=idx,
data=encode_ansi(idx, cols, rows), data_addr=0, preview_rgb=preview,
viewer="", error=base.mean_error(idx, img_lab, plab),
meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows,
"encoding": "halfblock"})
# --------------------------------------------------------------------------- #
# full-glyph encoder (intensive path)
# --------------------------------------------------------------------------- #
_glyph_cache = None
def _glyphs():
"""Load the bundled CP437 8x16 font once and return (masks, codes).
``masks`` is (Ng, 128) float32 -- one row per usable, de-duplicated glyph, a
pixel being 1.0 where the glyph is foreground (pixel order y*8+x, x MSB-first,
matching how cells are flattened). ``codes`` is the CP437 byte to emit for each.
Control bytes (0x00-0x1F, 0x7F) are excluded so the stream is always safe to
send to a terminal, and glyphs with identical bitmaps collapse to one entry.
"""
global _glyph_cache
if _glyph_cache is not None:
return _glyph_cache
font = np.frombuffer(open(_FONT_PATH, "rb").read(), np.uint8).reshape(256, GLYPH_H)
bits = np.unpackbits(font, axis=1).astype(np.float32) # (256, 128)
masks, codes, seen = [], [], set()
for code in range(0x20, 0x100):
if code == 0x7F:
continue
key = bits[code].tobytes()
if key in seen:
continue
seen.add(key)
masks.append(bits[code])
codes.append(code)
_glyph_cache = (np.stack(masks), np.array(codes, np.uint8))
return _glyph_cache
def _match_cells(cells_lab, csub_lab, csub_idx):
"""Best (glyph, fg, bg) per cell by minimum summed CIELAB error.
``cells_lab`` is (n, 128, 3); ``csub_lab``/``csub_idx`` are the allowed colours
in CIELAB and as VGA indices. For a glyph's fg (bg) pixel set the optimal
palette colour is the one nearest that set's mean, whose summed squared error is
``k*|c|^2 - 2 c.sum + sum|p|^2`` -- computed here for every glyph and colour at
once, then reduced. Returns (glyph_row, fg_idx, bg_idx, err) arrays, length n.
"""
G, _ = _glyphs()
Ng = G.shape[0]
k1 = G.sum(1) # (Ng,) fg pixel counts
k0 = float(G.shape[1]) - k1
Csq = (csub_lab ** 2).sum(1) # (m,)
grow = np.empty(len(cells_lab), np.int64)
fgi = np.empty(len(cells_lab), np.int64)
bgi = np.empty(len(cells_lab), np.int64)
err = np.empty(len(cells_lab), np.float64)
# Batch cells so the (Ng, batch, m) error tensors stay modest in memory.
step = max(1, 2_000_000 // (Ng * max(1, len(csub_idx))))
for s in range(0, len(cells_lab), step):
P = cells_lab[s:s + step] # (nb, 128, 3)
nb = P.shape[0]
sq = (P ** 2).sum(2) # (nb, 128)
tot = P.sum(1) # (nb, 3)
totsq = sq.sum(1) # (nb,)
sum_fg = np.einsum("gp,npc->gnc", G, P) # (Ng, nb, 3)
msq_fg = G @ sq.T # (Ng, nb)
# error of assigning each glyph's fg pixels to each candidate colour
efg = (k1[:, None, None] * Csq[None, None, :]
- 2 * np.einsum("gnc,mc->gnm", sum_fg, csub_lab)
+ msq_fg[:, :, None]) # (Ng, nb, m)
best_fg = efg.min(2)
sel_fg = efg.argmin(2)
ebg = (k0[:, None, None] * Csq[None, None, :]
- 2 * np.einsum("gnc,mc->gnm", tot[None] - sum_fg, csub_lab)
+ (totsq[None, :] - msq_fg)[:, :, None])
best_bg = ebg.min(2)
sel_bg = ebg.argmin(2)
total = best_fg + best_bg # (Ng, nb)
g = total.argmin(0) # (nb,)
r = np.arange(nb)
grow[s:s + nb] = g
fgi[s:s + nb] = csub_idx[sel_fg[g, r]]
bgi[s:s + nb] = csub_idx[sel_bg[g, r]]
err[s:s + nb] = total[g, r]
return grow, fgi, bgi, err
def encode_ansi_glyph(codes, fg, bg, cols, rows):
"""Encode per-cell CP437 ``codes`` with ``fg``/``bg`` (all (rows, cols)) as ANSI.
Same colour-run and line-reset discipline as the half-block encoder, but each
cell emits its matched glyph byte instead of a fixed half-block.
"""
out = bytearray(b"\x1b[0m")
for r in range(rows):
last = None
for c in range(cols):
pair = (int(fg[r, c]), int(bg[r, c]))
if pair != last:
out += _sgr(*pair)
last = pair
out.append(int(codes[r, c]))
out += b"\x1b[0m\r\n"
return bytes(out)
def _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode):
G, gcodes = _glyphs()
img_lab = pal.srgb_to_lab(rgb) # (rows*16, cols*8, 3)
plab = pal.srgb_to_lab(VGA)
csub_idx = np.asarray(ramp, np.int64)
csub_lab = plab[csub_idx]
# Match against a pre-dithered copy so smooth gradients become shade characters
# (two blended colours) instead of banding into flat cells. A FAST vectorised
# ordered dither is used -- error-diffusion dithers are far too slow at
# 8x16-per-cell resolution and the glyph matcher re-approximates the local mix
# anyway. "none" matches the continuous image (crispest flats, but visible
# gradient bands); every diffusion choice maps to blue-noise (the best-looking).
pd = {"none": None, "bayer": "bayer", "bluenoise": "bluenoise"}.get(
dither_mode, "bluenoise")
if pd is not None:
allowed = np.tile(csub_idx, (img_lab.shape[0], img_lab.shape[1], 1))
target = plab[dither.quantize(img_lab, allowed, plab, pd)]
else:
target = img_lab
cells = (target.reshape(rows, GLYPH_H, cols, GLYPH_W, 3)
.transpose(0, 2, 1, 3, 4).reshape(rows * cols, GLYPH_H * GLYPH_W, 3))
grow, fg, bg, _ = _match_cells(cells, csub_lab, csub_idx)
# render the matched glyphs to an RGB preview at full 8x16 cell resolution
masks = G[grow] # (n, 128)
fg_rgb = VGA[fg].astype(np.float32)
bg_rgb = VGA[bg].astype(np.float32)
cellpix = masks[:, :, None] * fg_rgb[:, None, :] + (1 - masks[:, :, None]) * bg_rgb[:, None, :]
preview = (cellpix.reshape(rows, cols, GLYPH_H, GLYPH_W, 3)
.transpose(0, 2, 1, 3, 4).reshape(rows * GLYPH_H, cols * GLYPH_W, 3)
.astype(np.uint8))
codes = gcodes[grow].reshape(rows, cols)
fg2d, bg2d = fg.reshape(rows, cols), bg.reshape(rows, cols)
# perceptual error of the rendered result against the ORIGINAL (undithered) image
rms = float(np.sqrt(((pal.srgb_to_lab(preview) - img_lab) ** 2).sum(-1)).mean())
return base.Conversion(
mode=mode, width=cols * GLYPH_W, height=rows * GLYPH_H, pixel_aspect=1.0,
index_image=None, data=encode_ansi_glyph(codes, fg2d, bg2d, cols, rows),
data_addr=0, preview_rgb=preview, viewer="", error=rms,
meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows,
"encoding": "glyph"})
# --------------------------------------------------------------------------- #
def convert_image(path_or_img, mode="80x25", palette_name="vga",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
"""Convert an image to ANSI BBS art.
``mode`` picks the canvas ("80x25" / "80x50", full 16-colour) or "mono"
(greyscale, or a hue tint via ``base_color``). With ``intensive`` set, every
8x16 cell is matched to the best CP437 glyph + colour pair (highest quality);
otherwise the fast half-block encoder runs and ``dither_mode`` chooses its
dither. The returned Conversion's ``data`` is the ready-to-write ``.ANS`` byte
stream and ``preview_rgb`` the rendered picture for the GUI.
"""
if prep_opt is None:
prep_opt = imageprep.PrepOptions()
cols, rows = _DIMS.get(mode, _DIMS["80x25"])
ramp = _mono_ramp(base_color) if mode == "mono" else list(range(16))
if intensive:
rgb = imageprep.prepare(path_or_img, cols * GLYPH_W, rows * GLYPH_H, 1.0,
prep_opt, border_rgb=(0, 0, 0))
return _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode)
rgb = imageprep.prepare(path_or_img, cols, rows * 2, 1.0, prep_opt,
border_rgb=(0, 0, 0))
return _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp)

BIN
lenser/ansi/cp437_8x16.bin Normal file

Binary file not shown.

19
lenser/ansi/exporter.py Normal file
View file

@ -0,0 +1,19 @@
"""Write an ANSI/CP437 conversion to a ``.ANS`` file."""
from __future__ import annotations
import os
def export_ans(conv, path, source_path=None, display="key", seconds=0,
video="pal", layout="unified"):
"""Write the conversion's ANSI byte stream to ``path`` (forcing a .ans suffix).
The extra keyword arguments (display / seconds / video / layout) exist only so
ANSI shares the platform export interface; a static text file ignores them.
"""
if not str(path).lower().endswith(".ans"):
path = os.path.splitext(path)[0] + ".ans"
with open(path, "wb") as f:
f.write(conv.data)
return path

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

@ -0,0 +1 @@
"""Apple II (Apple II+/IIe) image conversion and bootable disk export."""

View file

@ -0,0 +1,20 @@
"""Apple II conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import hgr_mono
_MODULES = {"hgr_mono": hgr_mono}
for _name in ("dhgr", "hgr_color", "mono"):
try:
_MODULES[_name] = __import__(f"lenser.apple.convert.{_name}", fromlist=[_name])
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="hgr_mono", palette_name="mono",
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,60 @@
"""Apple //e Double Hi-Res: 140x192, 16 colours (no per-cell limit).
Each line is 560 bits = 140 four-bit colour groups, stored 7 bits per byte with
the bytes interleaved between auxiliary and main memory (aux holds the even display
bytes, main the odd). Needs a //e (auxiliary memory).
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
WIDTH, HEIGHT = 140, 192
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x2000
def convert(img_rgb, palette_name="dhgr", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.dhgr_lab()
prgb = apal.DHGR16.astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
aux = bytearray(0x2000)
main = bytearray(0x2000)
for y in range(HEIGHT):
stream = np.zeros(560, dtype=np.uint8)
row = idx[y]
for n in range(WIDTH):
c = int(row[n])
stream[4 * n + 0] = c & 1
stream[4 * n + 1] = (c >> 1) & 1
stream[4 * n + 2] = (c >> 2) & 1
stream[4 * n + 3] = (c >> 3) & 1
base = apal.hgr_row_addr(y)
for col in range(40):
ab = 0
mb = 0
for i in range(7):
ab |= int(stream[7 * (2 * col) + i]) << i
mb |= int(stream[7 * (2 * col + 1) + i]) << i
aux[base + col] = ab
main[base + col] = mb
data = bytes(main) + bytes(aux) # main half at $2000, aux at $4000
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="dhgr", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
viewer="dhgr", preview_rgb=preview,
error=perceptual_error(idx, img_lab, plab),
meta={"palette": "dhgr", "dither": dither_mode},
)

View file

@ -0,0 +1,72 @@
"""Apple II HGR artifact colour: 140x192 colour pixels (280x192 mono), ~6 colours.
Each 2-mono-pixel "colour pixel" can be black, white, or one of two chroma colours
set by its byte's palette bit (violet/green for palette 0, blue/orange for palette 1).
We pick the palette bit per byte, then dither each colour pixel to its 4 reachable
colours. Works on the II+ and //e, and reuses the HGR boot loader.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
WIDTH, HEIGHT = 140, 192 # colour-pixel resolution
PIXEL_ASPECT = 2.0
DATA_ADDR = 0x2000
N_BYTES = 40
# index sets reachable in a byte for each palette bit: {black, even-chroma, odd-chroma, white}
_SET = {
0: [apal.HGR_BLACK, apal.HGR_VIOLET, apal.HGR_GREEN, apal.HGR_WHITE],
1: [apal.HGR_BLACK, apal.HGR_BLUE, apal.HGR_ORANGE, apal.HGR_WHITE],
}
def convert(img_rgb, palette_name="hgr", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.hgr6_lab()
prgb = apal.HGR6.astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
# choose a palette bit per byte (per line), by nearest-colour error.
pal_byte = np.zeros((HEIGHT, N_BYTES), dtype=np.uint8)
allowed = np.zeros((HEIGHT, WIDTH, 4), dtype=np.int64)
for y in range(HEIGHT):
for b in range(N_BYTES):
ks = [k for k in range(WIDTH) if (2 * k) // 7 == b]
if not ks:
continue
best_p, best_e = 0, None
for p in (0, 1):
cols = np.array(_SET[p])
d = np.sum((img_lab[y, ks][:, None, :] - plab[cols][None, :, :]) ** 2, axis=-1)
e = d.min(axis=1).sum()
if best_e is None or e < best_e:
best_e, best_p = e, p
pal_byte[y, b] = best_p
for k in ks:
allowed[y, k] = _SET[best_p]
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
# colour-pixel index -> two mono bits at (2k, 2k+1)
bits = np.zeros((HEIGHT, WIDTH * 2), dtype=np.uint8)
even_on = np.isin(idx, [apal.HGR_VIOLET, apal.HGR_BLUE, apal.HGR_WHITE])
odd_on = np.isin(idx, [apal.HGR_GREEN, apal.HGR_ORANGE, apal.HGR_WHITE])
bits[:, 0::2] = even_on
bits[:, 1::2] = odd_on
data = apal.pack_hgr_color(bits, pal_byte)
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="hgr_color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
viewer="hgr", preview_rgb=preview,
error=perceptual_error(idx, img_lab, plab),
meta={"palette": "hgr", "dither": dither_mode},
)

View file

@ -0,0 +1,41 @@
"""Apple II HGR monochrome: 280x192, 1 bit/pixel, black & white.
Universal across the Apple II+ and //e. Tone is carried entirely by dithering.
"""
from __future__ import annotations
import numpy as np
from ... import dither
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
WIDTH, HEIGHT = 280, 192
PIXEL_ASPECT = 1.0
DATA_ADDR = 0x2000 # HGR page 1
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
intensive=False, base_color=None):
from ...palette import srgb_to_lab
plab = apal.mono_lab() # 2 entries: black, white
L = srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros((HEIGHT, WIDTH, 3))
img_mono[..., 0] = L
plab_mono = np.zeros((2, 3))
plab_mono[:, 0] = plab[:, 0]
allowed = np.tile(np.array([0, 1]), (HEIGHT, WIDTH, 1))
idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
data = apal.pack_hgr_mono(idx) # 8192-byte HGR buffer
preview = (apal.MONO.astype(np.uint8))[idx]
return Conversion(
mode="hgr_mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
viewer="hgr", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": "mono", "dither": dither_mode},
)

View file

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

53
lenser/apple/dsk.py Normal file
View file

@ -0,0 +1,53 @@
"""Write a bootable Apple II .dsk (DOS 3.3 sector order, 143360 bytes).
The .dsk stores sectors in DOS *logical* order, but the Disk II boot ROM reads
*physical* sectors, so we place each chunk of the bitmap at the logical slot that
maps to the physical sector our loader will read -- making the loader's
physical-sequential reads come out contiguous in memory.
"""
from __future__ import annotations
# DOS 3.3 physical-sector -> logical-sector (the .dsk read interleave).
PHYS2LOG = [0, 7, 14, 6, 13, 5, 12, 4, 11, 3, 10, 2, 9, 1, 8, 15]
SECTOR = 256
SPT = 16
TRACKS = 35
DISK_SIZE = TRACKS * SPT * SECTOR # 143360
class DskError(RuntimeError):
pass
def _offset(track: int, phys_sector: int) -> int:
return (track * SPT + PHYS2LOG[phys_sector]) * SECTOR
def build_boot_dsk(boot: bytes, payload: bytes) -> bytes:
"""boot = assembled boot0 (origin $0800); payload = data pages the loader reads
in order: track 0 sectors 1-15, then whole tracks 1, 2, ... (physical order)."""
if len(payload) % SECTOR:
raise DskError("payload must be a whole number of 256-byte sectors")
npages = len(payload) // SECTOR
disk = bytearray(DISK_SIZE)
disk[0:SECTOR] = (bytes(boot) + bytes(SECTOR))[:SECTOR] # boot0 at T0 phys 0
assigns = [(0, p) for p in range(1, 16)] # track 0 (skip boot)
track = 1
while len(assigns) < npages:
assigns += [(track, p) for p in range(16)]
track += 1
if track > TRACKS:
raise DskError("payload exceeds disk capacity")
for page, (trk, phys) in enumerate(assigns[:npages]):
off = _offset(trk, phys)
disk[off:off + SECTOR] = payload[page * SECTOR:(page + 1) * SECTOR]
return bytes(disk)
def write_dsk(path: str, data: bytes) -> str:
with open(path, "wb") as f:
f.write(data)
return path

10
lenser/apple/exporter.py Normal file
View file

@ -0,0 +1,10 @@
"""Build a bootable Apple II .dsk from a conversion."""
from __future__ import annotations
from . import dsk
from .viewer.assemble import assemble_stub
def export_dsk(conv, output_path, source_path=None, display="forever", seconds=0):
if not output_path.lower().endswith((".dsk", ".do")):
output_path += ".dsk"
boot = assemble_stub(conv.viewer, display=display, seconds=seconds)
return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, conv.data))

107
lenser/apple/palette.py Normal file
View file

@ -0,0 +1,107 @@
"""Apple II colour palettes and the HGR memory-layout helper.
- HGR mono: black/white (the 280x192 1-bit bitmap displayed as monochrome).
- HGR colour: 6 NTSC "artifact" colours (added later).
- DHGR: 16 colours (added later).
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# Monochrome (white phosphor) pair.
MONO = np.array([(0, 0, 0), (255, 255, 255)], dtype=np.float64)
# Double-Hi-Res 16-colour palette, indexed by the 4-bit value -- measured from
# MAME's apple2ee DHGR output so the encoder's nibble values map to exactly what
# the //e displays.
DHGR16 = np.array([
(0x00, 0x00, 0x00), # 0 black
(0x40, 0x1c, 0xf7), # 1 blue
(0x00, 0x74, 0x40), # 2 dark green
(0x19, 0x90, 0xff), # 3 medium blue
(0x40, 0x63, 0x00), # 4 olive / dark green
(0x80, 0x80, 0x80), # 5 grey
(0x19, 0xd7, 0x00), # 6 green
(0x58, 0xf4, 0xbf), # 7 aqua
(0xa7, 0x0b, 0x40), # 8 dark red / magenta
(0xe6, 0x28, 0xff), # 9 magenta / violet
(0x80, 0x80, 0x80), # 10 grey
(0xbf, 0x9c, 0xff), # 11 lavender
(0xe6, 0x6f, 0x00), # 12 orange
(0xff, 0x8b, 0xbf), # 13 pink
(0xbf, 0xe3, 0x08), # 14 yellow-green
(0xff, 0xff, 0xff), # 15 white
], dtype=np.float64)
# HGR NTSC "artifact" colours. Per 7-pixel byte a palette bit selects one of two
# colour pairs; the displayed colour of an "on" pixel also depends on its column
# parity (and two adjacent on-pixels read as white).
# palette 0: even column -> violet, odd column -> green
# palette 1: even column -> blue, odd column -> orange
HGR_BLACK, HGR_VIOLET, HGR_GREEN, HGR_WHITE, HGR_BLUE, HGR_ORANGE = 0, 1, 2, 3, 4, 5
HGR6 = np.array([
(0x00, 0x00, 0x00), # black
(0xd0, 0x3a, 0xff), # violet
(0x20, 0xc8, 0x00), # green
(0xff, 0xff, 0xff), # white
(0x20, 0x9a, 0xff), # blue
(0xff, 0x6a, 0x20), # orange
], dtype=np.float64)
def mono_lab() -> np.ndarray:
return srgb_to_lab(MONO)
def hgr6_lab() -> np.ndarray:
return srgb_to_lab(HGR6)
def pack_hgr_color(bits280: np.ndarray, pal_byte: np.ndarray) -> bytes:
"""280x192 mono bits + (192x40) per-byte palette bit -> 8192 HGR buffer."""
buf = bytearray(0x2000)
H = bits280.shape[0]
for y in range(H):
base = hgr_row_addr(y)
row = bits280[y]
for bx in range(40):
b = 0
for i in range(7):
if row[bx * 7 + i]:
b |= (1 << i)
if pal_byte[y, bx]:
b |= 0x80
buf[base + bx] = b
return bytes(buf)
def dhgr_lab() -> np.ndarray:
return srgb_to_lab(DHGR16)
def hgr_row_addr(y: int) -> int:
"""Offset (from $2000) of HGR row ``y`` (0..191) in the interleaved layout."""
return (y & 7) * 0x400 + ((y >> 3) & 7) * 0x80 + (y >> 6) * 0x28
def pack_hgr_mono(val_image: np.ndarray) -> bytes:
"""280x192 1-bit image -> 8192-byte HGR page 1 buffer.
7 pixels per byte, bit 0 = leftmost, bit 7 (palette bit) = 0 for mono.
"""
buf = bytearray(0x2000)
H, W = val_image.shape
for y in range(H):
base = hgr_row_addr(y)
row = val_image[y]
for bx in range(40):
b = 0
for i in range(7):
if row[bx * 7 + i]:
b |= (1 << i)
buf[base + bx] = b
return bytes(buf)

View file

@ -0,0 +1 @@
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401

View file

@ -0,0 +1,97 @@
"""Assemble the Apple II boot/viewer with `xa` (origin $0800, raw bytes)."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCES = {"hgr": "hgr.s", "dhgr": "dhgr.s"}
_cache: dict[tuple, bytes] = {}
# How long the viewer holds the picture (see apple/viewer/awyt.i).
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
def build_slideshow_stub(n_images: int, advance: str = "both", seconds: int = 10,
loop: bool = True) -> bytes:
"""Assemble the Apple HGR slideshow boot loader (one 256-byte boot sector).
Reads NIMAGES * 32 sectors into the $4000 buffer and cycles them; must fit a
single boot sector since the Disk II ROM only loads sector 0.
"""
import shutil as _sh
if not _sh.which("xa"):
raise AssemblerError("The 'xa' assembler was not found on PATH.")
end_page = 0x40 + n_images * 0x20
wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, min(255, int(seconds)))}\n"
f"#define NIMAGES {n_images}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
f"#define ENDPAGE ${end_page:02X}\n"
'#include "slideshow.s"\n')
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:
raw = f.read()
finally:
os.unlink(wrap)
if len(raw) > 256:
raise AssemblerError(
f"Apple slideshow boot loader is {len(raw)} bytes, over the 256-byte "
"boot sector")
return raw
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def assemble_stub(viewer_key: str, display: str = "forever", seconds: int = 0) -> bytes:
waitmode = WAIT_MODES.get(display, 0)
secs = max(0, min(255, int(seconds))) # 8-bit delay counter
key = (viewer_key, waitmode, secs)
if key in _cache:
return _cache[key]
if not have_xa():
raise AssemblerError("The 'xa' assembler was not found on PATH.")
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
# Wrapper sets options then includes the real source; run from VIEWER_DIR so
# the source's #include "awyt.i" resolves (xa looks relative to cwd).
wrapper = (f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {secs}\n"
f'#include "{SOURCES[viewer_key]}"\n')
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 for {viewer_key}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
raw = f.read()
finally:
os.unlink(wrap)
_cache[key] = raw
return raw

View file

@ -0,0 +1,44 @@
; Shared display-duration epilogue for the Apple II viewers (HGR already on).
; WAITMODE 0 forever, 1 until a key, 2 about WAITSECS seconds.
; key and seconds exit to Applesoft BASIC ($E000 cold start), a real prompt with
; no DOS needed. The II and II+ have no timer, so seconds is a calibrated delay
; loop near 1 MHz. WAITSECS is clamped to 255 by the assembler.
#if WAITMODE == 0
awhang:
jmp awhang
#endif
#if WAITMODE == 1
awhang:
lda $c000 ; keyboard; bit7 set = a key is down
bpl awhang
bit $c010 ; clear strobe
lda $c051 ; switch to text so the BASIC prompt is visible
lda $c054 ; page 1
jmp $e000 ; Applesoft cold start
#endif
#if WAITMODE == 2
lda #WAITSECS
sta $fd ; seconds remaining
aw_o:
lda #$03 ; ~1 second = 3 * (256*256 inner) at ~1 MHz
sta $fc
aw_m:
ldx #$00
aw_x:
ldy #$00
aw_y:
dey
bne aw_y
dex
bne aw_x
dec $fc
bne aw_m
dec $fd
bne aw_o
lda $c051 ; switch to text so the BASIC prompt is visible
lda $c054 ; page 1
jmp $e000 ; Applesoft cold start
#endif

128
lenser/apple/viewer/dhgr.s Normal file
View file

@ -0,0 +1,128 @@
; lenser -- Apple //e Double Hi-Res boot loader + viewer (self-contained)
;
; Loads 16K to main $2000-$5FFF with ordinary reads (main half at $2000, aux half
; at $4000), then block-copies $4000-$5FFF into auxiliary $2000-$3FFF (RAMWRT on
; only for that clean copy, never during the boot-ROM reads), then turns on DHGR.
; ROM read at $C65C reads sector $3D into page $27 and re-enters $0801.
;
; assembled by apple/viewer/assemble.py via xa
* = $0800
.byte $01
entry: ; $0801, re-entered after each ROM read
lda dpage
cmp #$60 ; loaded $2000-$5FFF (64 pages)?
bcs done
lda psec
cmp #$10
bcc readit
jsr seeknext
lda #$00
sta psec
readit:
lda psec
sta $3d ; desired sector
lda curtrk
sta $41 ; desired track (the ROM read verifies BOTH)
lda #$00
sta $26
lda dpage
sta $27
inc psec
inc dpage
ldx $2b
jmp $c65c
done:
; copy main $4000-$5FFF -> aux $2000-$3FFF
lda #$00
sta $06
lda #$40
sta $07 ; src = $4000
lda #$00
sta $08
lda #$20
sta $09 ; dst = $2000
sta $c005 ; RAMWRT on (writes go to aux)
ldx #$20 ; 32 pages
cpl:
ldy #$00
cp1:
lda ($06),y
sta ($08),y
iny
bne cp1
inc $07
inc $09
dex
bne cpl
sta $c004 ; RAMWRT off
; turn on Double Hi-Res
lda $c050 ; graphics
lda $c052 ; full screen
lda $c054 ; page 1
lda $c057 ; hi-res
sta $c00d ; SET80VID (write-triggered switch -- must STA)
sta $c05e ; SETDHIRES (write-triggered)
#include "awyt.i"
; advance the head one track (two half-steps) with the standard phase-overlap
; seek. energize the NEXT phase while the current one is still on (this pulls
; the head smoothly), then release the old phase, using on/off settle delays
; from an acceleration table indexed by step number ($0a). the final phase is
; released before reading.
seeknext:
inc curtrk ; now on the next track
lda #$00
sta $0a ; step index for the timing table
jsr onestep
jsr onestep
lda halftrk ; release final phase before the read
and #$03
asl
ora $2b
tax
lda $c080,x
rts
onestep:
lda halftrk ; energize NEXT phase (halftrk+1), old still on
clc
adc #$01
and #$03
asl
ora $2b
tax
lda $c081,x
ldx $0a
lda ontable,x
jsr wait
lda halftrk ; release OLD phase (halftrk)
and #$03
asl
ora $2b
tax
lda $c080,x
ldx $0a
lda offtable,x
jsr wait
inc halftrk
inc $0a
rts
; Apple seek timing tables (head accelerates over a move, slow first step).
ontable: .byte $13,$0a,$08,$06,$05,$04,$04,$03
offtable: .byte $46,$1a,$10,$0c,$0a,$09,$08,$08
wait: ; delay loop ~ proportional to A
tay
w1:
ldx #$00
w2:
dex
bne w2
dey
bne w1
rts
psec: .byte $01
dpage: .byte $20
halftrk: .byte $00
curtrk: .byte $00

102
lenser/apple/viewer/hgr.s Normal file
View file

@ -0,0 +1,102 @@
; lenser -- Apple II HGR boot loader + viewer (self-contained, no DOS)
;
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
; re-entrant loader reads the 32 sectors of the 8K HGR bitmap into $2000-$3FFF
; (track 0 sectors 1-15, then tracks 1 and 2), seeking the head between tracks
; with the standard phase-overlap step, then switches on HGR and loops. The ROM
; read at $Cn5C verifies the address field against BOTH the sector ($3D) and the
; track ($41), then reads into page $27 and JMPs back to $0801.
;
; assembled by apple/viewer/assemble.py via xa
* = $0800
.byte $01 ; ROM scratch (boot sector byte 0)
entry: ; $0801, (re)entered after every ROM read
lda dpage
cmp #$40
bcs done ; loaded $2000..$3FFF -> show it
lda psec
cmp #$10
bcc readit ; still sectors left on this track
jsr seeknext ; finished a track -> step to the next
lda #$00
sta psec
readit:
lda psec
sta $3d ; desired sector
lda curtrk
sta $41 ; desired track (ROM read verifies both)
lda #$00
sta $26 ; buffer lo
lda dpage
sta $27 ; buffer hi
inc psec
inc dpage
ldx $2b ; slot*16 (set by boot ROM)
jmp $c65c ; slot 6 ROM read; reads sector then JMP $0801
done:
lda $c050 ; graphics
lda $c052 ; full screen (not mixed)
lda $c054 ; page 1
lda $c057 ; hi-res
#include "awyt.i"
; advance the head one track (two half-steps) with the standard phase-overlap
; step. energize the next phase while the current one is still on, then release
; the old phase, using on/off settle delays from an acceleration table.
seeknext:
inc curtrk
lda #$00
sta $0a
jsr onestep
jsr onestep
lda halftrk ; release final phase before reading
and #$03
asl
ora $2b
tax
lda $c080,x
rts
onestep:
lda halftrk ; energize NEXT phase (old still on -> overlap)
clc
adc #$01
and #$03
asl
ora $2b
tax
lda $c081,x
ldx $0a
lda ontable,x
jsr wait
lda halftrk ; release OLD phase
and #$03
asl
ora $2b
tax
lda $c080,x
ldx $0a
lda offtable,x
jsr wait
inc halftrk
inc $0a
rts
ontable: .byte $13,$0a,$08,$06
offtable: .byte $46,$1a,$10,$0c
wait:
tay
w1:
ldx #$00
w2:
dex
bne w2
dey
bne w1
rts
; ---- loader state (initial values; updated in place during the load) ----
psec: .byte $01 ; next physical sector (track 0 starts at 1)
dpage: .byte $20 ; next destination page ($2000)
halftrk: .byte $00 ; current half-track
curtrk: .byte $00 ; current track

View file

@ -0,0 +1,204 @@
; lenser -- Apple II HGR slideshow loader + viewer (self-contained, no DOS).
;
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
; loader reads ALL the slideshow's HGR images (NIMAGES * 32 sectors) contiguously
; into a RAM buffer from $4000 up (image i at $4000 + i*$2000), then cycles --
; copying each image into HGR page 1 ($2000), switching on graphics, and waiting
; (key / seconds / both) before the next. Looping is RAM-only (no re-seek).
;
; #defines from the wrapper --
; WAITMODE 1 key / 2 seconds / 3 both WAITSECS seconds (~) NIMAGES count
; LOOPFLAG 1 wrap / 0 stop ENDPAGE one past the last buffer page ($40+N*$20)
;
; assembled by apple/viewer/assemble.py via xa
src = $06 ; zero-page copy pointers
dst = $08
ssidx = $19
* = $0800
.byte $01 ; ROM scratch (boot sector byte 0)
entry: ; $0801, (re)entered after every ROM read
lda dpage
cmp #ENDPAGE
bcs loaded ; whole buffer read -> start the show
lda psec
cmp #$10
bcc readit
jsr seeknext
lda #$00
sta psec
readit:
lda psec
sta $3d
lda curtrk
sta $41
lda #$00
sta $26
lda dpage
sta $27
inc psec
inc dpage
ldx $2b
jmp $c65c ; slot-6 ROM read; reads a sector then JMP $0801
loaded:
lda #$00
sta ssidx
cyc:
; ---- copy image ssidx ($4000 + ssidx*$2000) -> HGR page 1 ($2000) ----
lda ssidx
asl
asl
asl
asl
asl ; ssidx * $20 pages (carry clear, <=4 images)
adc #$40
sta src+1 ; source hi = $40 + ssidx*$20
lda #$00
sta src
sta dst
lda #$20
sta dst+1 ; dest = $2000
ldx #$20 ; 32 pages
ldy #$00
cpyl:
lda (src),y
sta (dst),y
iny
bne cpyl
inc src+1
inc dst+1
dex
bne cpyl
lda $c050 ; graphics
lda $c054 ; page 1
lda $c057 ; hi-res
jsr sswait
inc ssidx
lda ssidx
cmp #NIMAGES
bcc cyc
#if LOOPFLAG == 1
jmp loaded ; wrap (re-uses the ssidx=0 init at loaded)
#else
lda $c051 ; text
lda $c054
jmp $e000 ; Applesoft cold start
#endif
; ---- wait (returns); clears the key strobe first ----
sswait:
bit $c010
#if WAITMODE == 1
swk:
lda $c000
bpl swk
bit $c010
rts
#endif
#if WAITMODE == 2
lda #WAITSECS
sta $fd
so:
lda #$03
sta $fc
sm:
ldx #$00
sx:
ldy #$00
sy:
dey
bne sy
dex
bne sx
dec $fc
bne sm
dec $fd
bne so
rts
#endif
#if WAITMODE == 3
lda #WAITSECS
sta $fd
bo:
lda #$03
sta $fc
bm:
ldx #$00
bx:
lda $c000
bmi bdone ; a key ends the slide
ldy #$00
by:
dey
bne by
dex
bne bx
dec $fc
bne bm
dec $fd
bne bo
bdone:
bit $c010 ; (also harmless on timeout)
rts
#endif
; advance the head one track (two half-steps), phase-overlap step (from hgr.s)
seeknext:
inc curtrk
lda #$00
sta $0a
jsr onestep
jsr onestep
lda halftrk
and #$03
asl
ora $2b
tax
lda $c080,x
rts
onestep:
lda halftrk
clc
adc #$01
and #$03
asl
ora $2b
tax
lda $c081,x
ldx $0a
lda ontable,x
jsr wait
lda halftrk
and #$03
asl
ora $2b
tax
lda $c080,x
ldx $0a
lda offtable,x
jsr wait
inc halftrk
inc $0a
rts
ontable: .byte $13,$0a,$08,$06
offtable: .byte $46,$1a,$10,$0c
wait:
tay
w1:
ldx #$00
w2:
dex
bne w2
dey
bne w1
rts
psec: .byte $01 ; next physical sector (track 0 starts at 1)
dpage: .byte $40 ; next destination page ($4000)
halftrk: .byte $00
curtrk: .byte $00

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

@ -0,0 +1 @@
"""Atari 8-bit (Atari 400/800/XL/XE) image conversion and bootable disk export."""

101
lenser/atari/atr.py Normal file
View file

@ -0,0 +1,101 @@
"""Write a bootable Atari ``.atr`` disk image natively (no external tools).
A self-booting Atari disk needs no DOS: sector 1 begins with a 6-byte boot header
(flags, sector-count, load-address, init-address); the OS loads ``count`` 128-byte
sectors to the load address and JSRs ``load+6``. We pack the whole viewer+picture
blob that way, so inserting the disk shows the picture.
"""
from __future__ import annotations
SECTOR_SIZE = 128
TOTAL_SECTORS = 720 # single density, ~90K
LOAD_ADDR = 0x2000 # boot load address (blob origin)
DATA_ADDR = 0x4000 # where the bitmap must land (4K-aligned for ANTIC)
class AtrError(RuntimeError):
pass
def build_blob(stub: bytes, data: bytes) -> bytes:
"""Combine the assembled viewer ``stub`` (origin $2000, starts with the 6-byte
boot header) + padding + picture ``data`` (which must reside from $4000)."""
pad = (DATA_ADDR - LOAD_ADDR) - len(stub)
if pad < 0:
raise AtrError(f"viewer stub {len(stub)} bytes exceeds "
f"{DATA_ADDR - LOAD_ADDR} before $4000")
return stub + bytes(pad) + bytes(data)
def _write_atr_sectors(path: str, data: bytes) -> str:
"""Write ``data`` (already laid out as raw sectors) as a single-density ATR,
padded to the full 720-sector disk with the 16-byte ATR header."""
data = bytearray(data)
if len(data) > TOTAL_SECTORS * SECTOR_SIZE:
raise AtrError("slideshow exceeds the 720-sector disk capacity")
data += bytes(TOTAL_SECTORS * SECTOR_SIZE - len(data))
paragraphs = (TOTAL_SECTORS * SECTOR_SIZE) // 16
header = bytes([
0x96, 0x02,
paragraphs & 0xFF, (paragraphs >> 8) & 0xFF,
SECTOR_SIZE & 0xFF, (SECTOR_SIZE >> 8) & 0xFF,
(paragraphs >> 16) & 0xFF, (paragraphs >> 24) & 0xFF,
0, 0, 0, 0, 0, 0, 0, 0,
])
with open(path, "wb") as f:
f.write(header)
f.write(data)
return path
def write_slideshow_atr(path: str, stub: bytes, images: list[bytes],
boot_sectors: int, spi: int) -> str:
"""Write a self-booting slideshow ATR.
Sectors 1..boot_sectors hold ``stub`` (boot header byte 1 patched to
boot_sectors so the OS loads it all); each image then occupies ``spi``
consecutive 128-byte sectors (image i at sector boot_sectors + 1 + i*spi),
matching what the viewer SIO-reads.
"""
if len(stub) > boot_sectors * SECTOR_SIZE:
raise AtrError(f"slideshow viewer {len(stub)} bytes exceeds "
f"{boot_sectors} boot sectors")
blob = bytearray(stub)
blob[1] = boot_sectors # OS loads this many sectors
blob += bytes(boot_sectors * SECTOR_SIZE - len(blob))
for img in images:
if len(img) > spi * SECTOR_SIZE:
raise AtrError("image larger than its sector allotment")
blob += bytes(img) + bytes(spi * SECTOR_SIZE - len(img))
return _write_atr_sectors(path, bytes(blob))
def write_boot_atr(path: str, blob: bytes) -> str:
"""Write ``blob`` as a bootable single-density ATR. Patches the boot sector
count (byte 1) from the blob length."""
nsec = (len(blob) + SECTOR_SIZE - 1) // SECTOR_SIZE
if nsec > 255:
raise AtrError(f"boot blob needs {nsec} sectors (max 255)")
if nsec > TOTAL_SECTORS:
raise AtrError("boot blob exceeds disk capacity")
blob = bytearray(blob)
blob[1] = nsec # boot header sector count
blob += bytes((-len(blob)) % SECTOR_SIZE) # pad to whole sectors
data = bytearray(blob)
data += bytes(TOTAL_SECTORS * SECTOR_SIZE - len(data)) # pad disk to 720 sectors
paragraphs = (TOTAL_SECTORS * SECTOR_SIZE) // 16
header = bytes([
0x96, 0x02, # magic
paragraphs & 0xFF, (paragraphs >> 8) & 0xFF, # size (paragraphs) low
SECTOR_SIZE & 0xFF, (SECTOR_SIZE >> 8) & 0xFF,
(paragraphs >> 16) & 0xFF, (paragraphs >> 24) & 0xFF,
0, 0, 0, 0, 0, 0, 0, 0,
])
with open(path, "wb") as f:
f.write(header)
f.write(data)
return path

20
lenser/atari/car.py Normal file
View file

@ -0,0 +1,20 @@
"""Write an Atari .car cartridge image (CART header + ROM)."""
from __future__ import annotations
import struct
TYPE_STD_16K = 2 # "Standard 16 KB cartridge"
def write_car(rom: bytes, path: str, cart_type: int = TYPE_STD_16K) -> str:
"""Wrap a cart `rom` in the .car container (16-byte CART header + ROM).
The header holds the cartridge type and a checksum (sum of all ROM bytes)."""
header = bytearray(16)
header[0:4] = b"CART"
struct.pack_into(">I", header, 4, cart_type)
struct.pack_into(">I", header, 8, sum(rom) & 0xFFFFFFFF)
with open(path, "wb") as f:
f.write(header)
f.write(rom)
return path

View file

@ -0,0 +1,31 @@
"""Atari conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from .. import palette as apal
from . import gr15
_MODULES = {"gr15": gr15}
for _name in ("gr9", "gr8", "gr15dli", "mono"):
try:
_mod = __import__(f"lenser.atari.convert.{_name}", fromlist=[_name])
_MODULES[_name] = _mod
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
dither_mode="floyd", intensive=False,
prep_opt: imageprep.PrepOptions | None = None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES[mode]
border_rgb = apal.get_palette(palette_name)[0]
img_rgb = imageprep.prepare(
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
prep_opt, border_rgb=border_rgb,
)
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,176 @@
"""Shared helpers for the Atari encoders."""
from __future__ import annotations
import numpy as np
DATA_ADDR = 0x4000 # bitmap base
COLOR_ADDR = 0x6000 # colour data base (fixed, after the bitmap)
SPLIT_LINE = 102 # lines that fit in the first 4K ($4000-$4FEF)
BYTES_PER_LINE = 40
LINES = 192
def split_screen(line_bytes: list[bytes]) -> bytes:
"""Lay out 192 screen lines with the 16-byte gap that pushes line 102 onto
the $5000 boundary (so no ANTIC line crosses a 4K boundary), then pad up to
COLOR_ADDR so colour data can follow at a fixed address."""
first = b"".join(line_bytes[:SPLIT_LINE]) # 4080 bytes -> $4000
second = b"".join(line_bytes[SPLIT_LINE:]) # 3600 bytes -> $5000
body = first + bytes(0x1000 - len(first)) + second # gap fills to $5000
pad = (COLOR_ADDR - DATA_ADDR) - len(body)
return body + bytes(pad)
def luminance_lab(img_rgb, plab):
"""Return (image, palette) recast into luminance-only CIELAB (L, 0, 0), so
matching is by brightness alone -- used by the single-hue modes."""
from ...palette import srgb_to_lab
L = srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros(img_rgb.shape[:2] + (3,))
img_mono[..., 0] = L
plab_mono = np.zeros_like(plab)
plab_mono[:, 0] = plab[:, 0]
return img_mono, plab_mono
def choose_palette(img_lab: np.ndarray, plab: np.ndarray, k: int,
iters: int = 12) -> list[int]:
"""Pick the ``k`` palette register values (0..255) that best represent the
image, by palette-constrained k-means in CIELAB."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
D = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1) # (N,256)
# k-means++-ish greedy init.
chosen = [int(np.argmin(np.sum((plab - flat.mean(0)) ** 2, axis=-1)))]
for _ in range(k - 1):
md = D[:, chosen].min(axis=1)
improv = np.maximum(0.0, md[:, None] - D).sum(axis=0)
improv[chosen] = -1.0
chosen.append(int(np.argmax(improv)))
# Lloyd refinement, each centroid snapped to its best palette colour.
for _ in range(iters):
assign = np.argmin(D[:, chosen], axis=1)
new = []
for j in range(k):
mask = assign == j
if not mask.any():
new.append(chosen[j])
else:
new.append(int(np.argmin(D[mask].sum(axis=0))))
# keep distinct where possible
if new == chosen:
break
chosen = new
return chosen
def _seg_all(sub, c1all, c2):
"""Distance from each ``sub`` pixel to the segment between every palette colour
(c1all, shape (256,3)) and a fixed endpoint c2. Returns (256, Nsub)."""
seg = c2 - c1all # (256,3)
L = np.sum(seg * seg, axis=1) + 1e-9 # (256,)
rel = sub[None, :, :] - c1all[:, None, :] # (256,Nsub,3)
t = np.clip(np.sum(rel * seg[:, None, :], axis=2) / L[:, None], 0.0, 1.0)
proj = c1all[:, None, :] + t[:, :, None] * seg[:, None, :]
return np.sum((sub[None, :, :] - proj) ** 2, axis=2)
def relevant_candidates(img_lab, plab):
"""Palette colours that are the nearest match to some image pixel -- a small
set (the image's own gamut) to restrict the dither-aware search to."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
if len(flat) > 4000:
flat = flat[::len(flat) // 4000]
d = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1)
return np.unique(np.argmin(d, axis=1)).astype(np.int64)
def choose_palette_dither(img_lab, plab, k, init=None, n_sample=900, iters=5,
candidates=None):
"""Dither-aware palette: pick the ``k`` colours whose pairwise *segment* blends
(what error diffusion can reproduce) best cover the image -- so the colours
span the gamut instead of sitting at k-means centroids. Vectorised local
search (all candidates per slot at once) from a k-means start."""
from itertools import combinations
flat = img_lab.reshape(-1, 3)
sub = flat[::max(1, len(flat) // n_sample)] if len(flat) > n_sample else flat
colors = list(init) if init is not None else choose_palette(img_lab, plab, k)
cand = np.asarray(candidates if candidates is not None else range(256), np.int64)
cand_lab = plab[cand].astype(np.float64) # (C,3)
for _ in range(iters):
changed = False
for i in range(k):
others = [colors[j] for j in range(k) if j != i]
fixed = None
for x, y in combinations(others, 2):
s = _seg_all(sub, plab[x][None], plab[y])[0]
fixed = s if fixed is None else np.minimum(fixed, s)
m = None
for o in others:
d = _seg_all(sub, cand_lab, plab[o]) # (C, Nsub)
m = d if m is None else np.minimum(m, d)
if fixed is not None:
m = np.minimum(m, fixed[None, :])
err = m.sum(axis=1) # (C,)
for ci, c in enumerate(cand):
if c in others:
err[ci] = np.inf # avoid duplicate colours
best = int(cand[np.argmin(err)])
if best != colors[i]:
colors[i] = best
changed = True
if not changed:
break
return colors
def quantize_global(img_lab, plab, colors, dither_mode):
"""Dither the whole image to a fixed global set of palette indices."""
from ... import dither
H, W, _ = img_lab.shape
allowed = np.tile(np.array(colors, dtype=np.int64), (H, W, 1))
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
def pack_2bpp(val_image: np.ndarray) -> list[bytes]:
"""160-wide 2-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 4):
out.append((row[x] << 6) | (row[x + 1] << 4) | (row[x + 2] << 2) | row[x + 3])
lines.append(bytes(out))
return lines
def pack_4bpp(val_image: np.ndarray) -> list[bytes]:
"""80-wide 4-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 2):
out.append((row[x] << 4) | row[x + 1])
lines.append(bytes(out))
return lines
def pack_1bpp(val_image: np.ndarray) -> list[bytes]:
"""320-wide 1-bit-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 8):
b = 0
for i in range(8):
b = (b << 1) | int(row[x + i])
out.append(b)
lines.append(bytes(out))
return lines

View file

@ -0,0 +1,51 @@
"""Atari GR.15 (ANTIC mode E): 160x192, 4 colours chosen globally from 256.
No per-cell colour limit, so this is a clean 4-colour dithered image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal # for srgb_to_lab (shared)
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
# Dither-aware palette for error-diffusion modes: pick 4 colours whose blends
# span the image gamut (so dithering reproduces saturated/intermediate shades)
# instead of k-means centroids the dither can't reach.
if dither_mode in DIFFUSION_DITHERS:
colors = _common.choose_palette_dither(img_lab, plab, k=4)
else:
colors = _common.choose_palette(img_lab, plab, k=4)
colors.sort(key=lambda c: plab[c, 0]) # value 0 = darkest (background)
idx = _common.quantize_global(img_lab, plab, colors, dither_mode)
value_of = {c: v for v, c in enumerate(colors)}
val_image = np.vectorize(value_of.get)(idx).astype(np.uint8)
lines = _common.pack_2bpp(val_image)
data = _common.split_screen(lines) + bytes(colors) # 4 colour regs at $6000
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
err = (perceptual_error if dither_mode in DIFFUSION_DITHERS else mean_error)
return Conversion(
mode="gr15", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15", preview_rgb=preview,
error=err(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "colors": colors},
)

View file

@ -0,0 +1,84 @@
"""Atari GR.15 + DLI: 160x192, a fresh set of 4 colours every 2 scanlines.
A display-list interrupt rewrites the four colour registers for each 2-line band
(96 bands). Every *single* scanline is impossible -- four register writes don't
fit the inter-DLI window -- but every 2 lines leaves a comfortable budget, and
96x4 colours is still far beyond flat GR.15. The display list (with DLI bits on
the right lines) is generated here and shipped in the data block.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
BAND_H = 2
N_BANDS = HEIGHT // BAND_H # 96
COLOR_ADDR = 0x6000
DL_ADDR = 0x6400 # display list, after the colour table
def make_dlist() -> bytes:
"""ANTIC mode-E display list, 4K-split, DLI bit on the last line of each
2-line band (odd lines 1..189) so the handler sets up the next band."""
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank lines
dl += bytes([0x4e, 0x00, 0x40]) # line 0: LMS $4000 (no DLI)
for ln in range(1, 102): # lines 1..101
dl.append(0x8e if ln % 2 == 1 else 0x0e)
dl += bytes([0x4e, 0x00, 0x50]) # line 102: LMS $5000 (no DLI)
for ln in range(103, 192): # lines 103..191
dl.append(0x8e if (ln % 2 == 1 and ln != 191) else 0x0e)
dl += bytes([0x41, DL_ADDR & 0xFF, DL_ADDR >> 8]) # JVB -> start
return bytes(dl)
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
band_sets = np.zeros((N_BANDS, 4), dtype=np.int64)
aware = dither_mode in DIFFUSION_DITHERS
iters = 10 if intensive else 5
# restrict the per-band dither-aware search to the image's own gamut (fast).
cand = _common.relevant_candidates(img_lab, plab) if aware else None
for b in range(N_BANDS):
block = img_lab[b * BAND_H:(b + 1) * BAND_H].reshape(-1, 3)
cols = _common.choose_palette(block, plab, k=4, iters=iters)
if aware: # span each band's gamut so dithering blends to the true shade
cols = _common.choose_palette_dither(block, plab, k=4, init=cols,
iters=4 if intensive else 3,
candidates=cand)
cols.sort(key=lambda c: plab[c, 0])
band_sets[b] = cols
allowed = np.repeat((band_sets[np.arange(HEIGHT) // BAND_H])[:, None, :],
WIDTH, axis=1)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
val = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
for y in range(HEIGHT):
lut = {int(c): v for v, c in enumerate(band_sets[y // BAND_H])}
val[y] = [lut.get(int(p), 0) for p in idx[y]]
bitmap = _common.split_screen(_common.pack_2bpp(val)) # 8192 -> $4000..$5FFF
coltab = band_sets.astype(np.uint8).tobytes() # 384 -> $6000
region = coltab + bytes((DL_ADDR - COLOR_ADDR) - len(coltab)) + make_dlist()
data = bitmap + region
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr15dli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15dli", preview_rgb=preview,
error=(perceptual_error if aware else mean_error)(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode},
)

View file

@ -0,0 +1,40 @@
"""Atari GR.8 (ANTIC mode F): 320x192 hi-res, two tones of one hue.
Highest spatial resolution; carries tone by dithering between background and a
foreground luminance. ``base_color`` picks the hue (None = greyscale).
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 320, 192
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
bg_reg = (hue << 4) | 0x00 # darkest of the hue
fg_reg = (hue << 4) | 0x0E # brightest of the hue
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
idx = _common.quantize_global(img_mono, plab_mono, [bg_reg, fg_reg], dither_mode)
val = (idx == fg_reg).astype(np.uint8)
data = _common.split_screen(_common.pack_1bpp(val)) + bytes([bg_reg, fg_reg])
preview = prgb[idx] # already 320 wide
return Conversion(
mode="gr8", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr8", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,48 @@
"""Atari GR.9 (GTIA): 80x192, 16 luminance shades of one hue.
Excellent greyscale (hue 0) or tinted monochrome (any of 16 hues) -- 16 real
shades, not just dithered. ``base_color`` selects the hue (0..15); None = grey.
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 80, 192
PIXEL_ASPECT = 4.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
ramp = apal.hue_ramp(hue) # 16 register values of this hue
idx = _common.quantize_global(img_mono, plab_mono, ramp, dither_mode)
val = (idx & 0x0F).astype(np.uint8) # GR.9 pixel = 4-bit luminance
# GTIA mode 9 takes the hue from COLBK and the luminance from each pixel. A
# COLBK of exactly $00, though, blanks the whole playfield to black -- the
# register must be non-zero to enable the display. For a tinted hue that is
# automatic ((hue<<4) != 0); for greyscale (hue 0) force a non-zero luminance
# nibble, which the mode ignores for output (luminance still comes from the
# pixels) but which switches the 16-shade display on.
colbk = (hue & 0x0F) << 4
if colbk == 0:
colbk = 0x0E
data = _common.split_screen(_common.pack_4bpp(val)) + bytes([colbk])
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr9", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr9", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,16 @@
"""Atari monochrome -- GR.9's 16 luminance shades, exposed as the standard
``mono`` mode for cross-platform parity. Greyscale by default; ``--mono-base``
tints it to one of the GTIA hues (16 real shades of that hue)."""
from __future__ import annotations
from . import gr9
WIDTH, HEIGHT, PIXEL_ASPECT = gr9.WIDTH, gr9.HEIGHT, gr9.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
conv = gr9.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv

32
lenser/atari/exporter.py Normal file
View file

@ -0,0 +1,32 @@
"""Build a bootable Atari .atr from a conversion."""
from __future__ import annotations
from ..convert.base import Conversion
from . import atr, car
from .viewer.assemble import assemble_stub, build_cart_rom
def export_atr(conv: Conversion, output_path: str, source_path: str | None = None,
display: str = "forever", seconds: int = 0, video: str = "ntsc") -> str:
"""Write ``conv`` as a self-booting .atr at ``output_path``.
``display`` (forever/key/seconds) + ``seconds`` choose how long the viewer
holds the picture; on key/seconds it warm-starts the OS. ``video`` sets the
frame rate the seconds timer counts (50 PAL / 60 NTSC)."""
if not output_path.lower().endswith(".atr"):
output_path += ".atr"
stub = assemble_stub(conv.viewer, display=display, seconds=seconds, video=video)
blob = atr.build_blob(stub, conv.data)
return atr.write_boot_atr(output_path, blob)
def export_car(conv: Conversion, output_path: str, source_path: str | None = None,
display: str = "forever", seconds: int = 0, video: str = "ntsc") -> str:
"""Write ``conv`` as an autostarting 16K Atari .car cartridge (reuses the
disk viewer, so display-duration works the same)."""
if not output_path.lower().endswith(".car"):
output_path += ".car"
rom = build_cart_rom(conv.viewer, conv.data, display=display,
seconds=seconds, video=video)
return car.write_car(rom, output_path)

96
lenser/atari/palette.py Normal file
View file

@ -0,0 +1,96 @@
"""Atari 8-bit (GTIA) colour palette.
The GTIA produces 256 colour-register values: the high nibble is the hue (0 =
grey, 1..15 = colours around the NTSC wheel) and the low nibble is the luminance.
We generate an NTSC palette with a standard YIQ formula, but if the atari800
emulator's palette file is present we load that instead so the preview matches
exactly what the emulator displays.
"""
from __future__ import annotations
import math
import os
import numpy as np
from ..palette import srgb_to_lab # reuse the CIELAB conversion
# Candidate locations for atari800's bundled NTSC palette (768 raw RGB bytes).
_PAL_FILES = [
"/usr/share/atari800/Palettes/Real.act",
"/usr/share/atari800/default.pal",
"/usr/local/share/atari800/default.pal",
]
def _generate_ntsc() -> np.ndarray:
"""Generate a 256x3 (uint8) NTSC palette via a YIQ approximation."""
pal = np.zeros((256, 3), dtype=np.float64)
# Calibration roughly matching the common Atari NTSC look.
sat = 0.30
hue0 = -58.0 # phase of hue 1, degrees
for reg in range(256):
hue = (reg >> 4) & 0x0F
lum = reg & 0x0F
y = lum / 15.0
if hue == 0:
i = q = 0.0
else:
angle = math.radians(hue0 + (hue - 1) * (360.0 / 15.0))
i = sat * math.cos(angle)
q = sat * math.sin(angle)
r = y + 0.956 * i + 0.621 * q
g = y - 0.272 * i - 0.647 * q
b = y - 1.106 * i + 1.703 * q
pal[reg] = [r, g, b]
return np.clip(pal * 255.0 + 0.5, 0, 255).astype(np.uint8).astype(np.float64)
def _load_pal_file() -> np.ndarray | None:
for path in _PAL_FILES:
try:
with open(path, "rb") as f:
data = f.read()
if len(data) >= 768:
return np.frombuffer(data[:768], dtype=np.uint8).reshape(256, 3).astype(np.float64)
except OSError:
continue
return None
_CACHE: dict[str, np.ndarray] = {}
def get_palette(name: str = "ntsc") -> np.ndarray:
"""Return the 256x3 sRGB palette (float64, 0..255)."""
if "rgb" not in _CACHE:
_CACHE["rgb"] = _load_pal_file()
if _CACHE["rgb"] is None:
_CACHE["rgb"] = _generate_ntsc()
return _CACHE["rgb"]
def palette_lab(name: str = "ntsc") -> np.ndarray:
"""Return the 256 palette colours in CIELAB (256x3)."""
if "lab" not in _CACHE:
_CACHE["lab"] = srgb_to_lab(get_palette(name))
return _CACHE["lab"]
def hue_ramp(hue: int) -> list[int]:
"""The 16 register values of one hue (luminance 0..15) -- for GR.9 / mono."""
return [((hue & 0x0F) << 4) | lum for lum in range(16)]
def nearest_hue(rgb) -> int:
"""Atari hue (0..15) whose mid-luminance colour best matches ``rgb``."""
from ..palette import srgb_to_lab
lab = srgb_to_lab(np.asarray(rgb, dtype=np.float64))
pl = palette_lab()
best, best_d = 0, float("inf")
for h in range(16):
d = float(np.sum((pl[(h << 4) | 8] - lab) ** 2))
if d < best_d:
best, best_d = h, d
return best

View file

@ -0,0 +1 @@
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401

View file

@ -0,0 +1,156 @@
"""Assemble the Atari 6502 boot viewers with `xa` (origin $2000, no load prefix)."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCES = {
"gr15": "gr15.s",
"gr9": "gr9.s",
"gr8": "gr8.s",
"gr15dli": "gr15dli.s",
}
_cache: dict[tuple, bytes] = {}
# How long the viewer holds the picture (see atari/viewer/awyt.i).
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
# Slideshow advance behaviour and per-mode multi-image viewer parameters
# (source, ANTIC mode byte, GPRIOR, colour-register layout).
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
SLIDESHOW_PARAMS = {
"gr15": (0x0E, 0x00, 0),
"gr9": (0x0F, 0x40, 1),
"gr8": (0x0F, 0x00, 2),
}
SLIDESHOW_SOURCES = dict.fromkeys(SLIDESHOW_PARAMS, "slideshow_static.s")
SLIDESHOW_SOURCES["gr15dli"] = "slideshow_dli.s" # DLI mode, its own engine
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def assemble_stub(viewer_key: str, display: str = "forever", seconds: int = 0,
video: str = "ntsc") -> bytes:
waitmode = WAIT_MODES.get(display, 0)
rate = 50 if video == "pal" else 60
key = (viewer_key, waitmode, int(seconds), rate)
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")
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
# Wrapper sets the options then includes the real source; runs from VIEWER_DIR
# so the source's #include "awyt.i" resolves (xa looks relative to cwd).
wrapper = (
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f'#include "{SOURCES[viewer_key]}"\n')
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 for {viewer_key}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
raw = f.read()
finally:
os.unlink(wrap)
_cache[key] = raw
return raw
def _xa(wrapper: str, what: str) -> bytes:
"""Assemble a generated wrapper with xa (run from VIEWER_DIR so #includes
resolve); return raw bytes."""
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 for {what}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
return f.read()
finally:
os.unlink(wrap)
def build_slideshow_stub(viewer_key: str, n_images: int, base_sec: int, spi: int,
advance: str = "both", seconds: int = 10,
loop: bool = True, video: str = "ntsc") -> bytes:
"""Assemble the multi-image slideshow viewer (origin $2000, no load prefix).
``base_sec`` is the disk sector of image 0 and ``spi`` the sectors per image
(both fixed by the ATR layout); the viewer SIO-reads image i from
base_sec + i*spi. ``advance``/``seconds`` set the per-slide dwell, ``loop``
whether it wraps.
"""
if viewer_key not in SLIDESHOW_SOURCES:
raise AssemblerError(f"no Atari slideshow viewer for mode {viewer_key}")
rate = 50 if video == "pal" else 60
common = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f"#define NIMAGES {n_images}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
f"#define BASESEC {base_sec}\n"
f"#define SPI {spi}\n")
if viewer_key in SLIDESHOW_PARAMS: # static gr15/gr9/gr8
dlmode, gprior, colormode = SLIDESHOW_PARAMS[viewer_key]
common += (f"#define DLMODE ${dlmode:02X}\n"
f"#define GPRIOR ${gprior:02X}\n"
f"#define COLORMODE {colormode}\n")
return _xa(common + f'#include "{SLIDESHOW_SOURCES[viewer_key]}"\n',
f"slideshow_{viewer_key}")
CART_SIZE = 0x4000 # 16K Atari cartridge ROM at $8000-$BFFF
def build_cart_rom(viewer_key: str, data: bytes, display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
"""Assemble the loader + the disk viewer stub + picture data into a 16K
cartridge ROM with the Atari run/init footer at $BFFA."""
stub = assemble_stub(viewer_key, display, seconds, video)
wrapper = (
f"#define STUB_PAGES {(len(stub) + 255) // 256}\n"
f"#define DATA_PAGES {(len(data) + 255) // 256}\n"
f"#define STUB_LEN {len(stub)}\n"
'#include "cart.s"\n')
loader = _xa(wrapper, "cart")
rom = bytearray(loader + stub + bytes(data))
if len(rom) > CART_SIZE:
raise AssemblerError(
f"viewer + image = {len(rom)} bytes, over the 16K cartridge limit")
rom += bytes(CART_SIZE - len(rom))
# Atari cartridge footer at $BFFA (ROM offset $3FFA).
rom[0x3FFA] = 0x00; rom[0x3FFB] = 0x80 # CARTCS run address = $8000
rom[0x3FFC] = 0x00 # cart present
rom[0x3FFD] = 0x04 # option byte: start the cartridge
rom[0x3FFE] = 0x00; rom[0x3FFF] = 0x80 # CARTAD init address = $8000
return bytes(rom)

View file

@ -0,0 +1,48 @@
; Shared "how long to show the picture" epilogue for the Atari viewers.
; Selected at assembly time by WAITMODE (set by atari/viewer/assemble.py):
; 0 forever -- loop, just defeating attract mode
; 1 until a key -- poll CH ($2FC), then warm-start the OS
; 2 WAITSECS secs -- count RTCLOK frames (RATE per second), then warm-start
; "Exit" warm-starts the OS ($E474); on the XL that brings back a usable system.
#if WAITMODE == 0
awloop:
lda #$00
sta $4d ; defeat attract mode
jmp awloop
#endif
#if WAITMODE == 1
lda #$ff
sta $2fc ; clear CH (last-key register; $FF = no key)
awloop:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq awloop
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC, not re-boot
jmp $e474 ; warm-start
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14 ; reset RTCLOK frame counter (16-bit in $13,$14)
awloop:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc awloop
bne awdone
lda $14
cmp #<(WAITSECS*RATE)
bcc awloop
awdone:
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC, not re-boot
jmp $e474 ; warm-start
#endif

View file

@ -0,0 +1,67 @@
; lenser -- Atari 16K cartridge loader ($8000-$BFFF).
;
; Reuses the disk viewer unchanged. The disk viewer "stub" (origin $2000, which
; begins with the 6-byte boot header and contains cont + the display list) and
; the picture data (origin $4000) are stored back-to-back in the cartridge ROM
; after this loader. We copy the stub to $2000 and the data to $4000, then jump
; to cont ($2006) -- exactly the state a disk boot would have produced.
;
; #defines set by viewer/assemble.py --
; STUB_PAGES / DATA_PAGES 256-byte page counts to copy
; STUB_LEN exact stub length (data follows it in ROM)
;
; assembled by atari/viewer/assemble.py via xa
* = $8000
S = $80
D = $82
entry:
; ---- copy the viewer stub to $2000 ----
lda #<stubsrc
sta S
lda #>stubsrc
sta S+1
lda #$00
sta D
lda #$20
sta D+1
ldx #STUB_PAGES
ldy #$00
sc:
lda (S),y
sta (D),y
iny
bne sc
inc S+1
inc D+1
dex
bne sc
; ---- copy the picture data to $4000 ----
lda #<datasrc
sta S
lda #>datasrc
sta S+1
lda #$00
sta D
lda #$40
sta D+1
ldx #DATA_PAGES
ldy #$00
dc:
lda (S),y
sta (D),y
iny
bne dc
inc S+1
inc D+1
dex
bne dc
jmp $2006 ; enter the disk viewer (cont)
stubsrc:
; viewer stub + picture data appended here by the packager
datasrc = stubsrc + STUB_LEN

View file

@ -0,0 +1,50 @@
; lenser -- Atari GR.15 (ANTIC mode E) viewer, self-booting
;
; 160x192, 4 colours chosen globally (no per-cell limit). Boots from sector 1:
; the OS loads this blob to $2000 and JSRs $2006. Appended data (from $4000):
; $4000 bitmap lines 0-101 (4080 bytes)
; $5000 bitmap lines 102-191 (3600 bytes) [split at the 4K ANTIC boundary]
; $6000 4 colour register values (value 0..3)
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched by the ATR writer)
.word $2000 ; load address
.word binit ; init address (DOSINI)
cont: ; $2006 -- OS JSRs here after loading
lda #$22
sta $22f ; SDMCTL = normal playfield + DL DMA
lda #<dlist
sta $230 ; SDLSTL
lda #>dlist
sta $231 ; SDLSTH
lda #$00
sta $26f ; GPRIOR = 0 (no GTIA mode)
; copy the 4 colour registers from $6000
lda $6000
sta $2c8 ; COLBAK (pixel value 0)
lda $6001
sta $2c4 ; COLPF0 (value 1)
lda $6002
sta $2c5 ; COLPF1 (value 2)
lda $6003
sta $2c6 ; COLPF2 (value 3)
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70 ; 24 blank scan lines
.byte $4e ; LMS + mode E
.word $4000
.dsb 101,$0e ; 101 more mode E lines (102 total from $4000)
.byte $4e ; LMS + mode E
.word $5000
.dsb 89,$0e ; 89 more mode E lines (90 total from $5000)
.byte $41 ; JVB
.word dlist

View file

@ -0,0 +1,100 @@
; lenser -- Atari GR.15 + DLI viewer, self-booting
; 160x192, 4 colours rewritten every 2 scanlines by a display-list interrupt.
; Appended data from $4000 ...
; $4000/$5000 bitmap (2 bits per pixel, 4K split)
; $6000 colour table, 96 bands x 4 register values
; $6400 display list (DLI bit on the last line of each 2-line band)
;
; assembled by atari/viewer/assemble.py via xa
CP = $cb ; zero-page pointer into the colour table
* = $2000
boot:
.byte 0
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont:
sei
lda #$22
sta $22f ; SDMCTL
lda #$00
sta $26f ; GPRIOR = 0
lda #$00
sta $230 ; SDLSTL = $6400 (DL shipped in the data)
lda #$64
sta $231
; DLI vector
lda #<dli
sta $200
lda #>dli
sta $201
; colour pointer starts at band 1 (band 0 set by the VBI)
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $2c8
lda $6001
sta $2c4
lda $6002
sta $2c5
lda $6003
sta $2c6
ldy #<vbi
ldx #>vbi
lda #$07
jsr $e45c ; SETVBV (deferred)
lda #$c0
sta $d40e ; NMIEN = DLI + VBI
cli
#include "awyt.i"
binit:
rts
vbi:
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $d01a
lda $6001
sta $d016
lda $6002
sta $d017
lda $6003
sta $d018
jmp $e462 ; XITVBV
dli:
pha
tya
pha
ldy #$00
sta $d40a ; WSYNC
lda (CP),y
sta $d01a
iny
lda (CP),y
sta $d016
iny
lda (CP),y
sta $d017
iny
lda (CP),y
sta $d018
lda CP
clc
adc #$04
sta CP
bcc nocarry
inc CP+1
nocarry:
pla
tay
pla
rti

42
lenser/atari/viewer/gr8.s Normal file
View file

@ -0,0 +1,42 @@
; lenser -- Atari GR.8 (ANTIC mode F) hi-res viewer, self-booting
; 320x192, two tones. Appended data from $4000 ...
; $4000/$5000 bitmap (1 bit per pixel, 4K split)
; $6000 two bytes, background reg then foreground reg
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont:
lda #$22
sta $22f ; SDMCTL
lda #<dlist
sta $230
lda #>dlist
sta $231
lda #$00
sta $26f ; GPRIOR = 0
lda $6000
sta $2c6 ; COLPF2 (background)
sta $2c8 ; COLBAK (border) = background
lda $6001
sta $2c5 ; COLPF1 (foreground luminance)
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70
.byte $4f ; LMS + mode F
.word $4000
.dsb 101,$0f
.byte $4f
.word $5000
.dsb 89,$0f
.byte $41
.word dlist

39
lenser/atari/viewer/gr9.s Normal file
View file

@ -0,0 +1,39 @@
; lenser -- Atari GR.9 (GTIA 16-luminance) viewer, self-booting
; 80x192, 16 shades of one hue. Appended data from $4000 ...
; $4000/$5000 bitmap (4 bits per pixel, 4K split)
; $6000 one byte, COLBAK = hue times 16
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont:
lda #$22
sta $22f ; SDMCTL
lda #<dlist
sta $230
lda #>dlist
sta $231
lda #$40
sta $26f ; GPRIOR = GTIA mode 1 (GR.9)
lda $6000
sta $2c8 ; COLBAK sets the hue
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70
.byte $4f ; LMS + mode F
.word $4000
.dsb 101,$0f
.byte $4f
.word $5000
.dsb 89,$0f
.byte $41
.word dlist

View file

@ -0,0 +1,248 @@
; lenser -- Atari GR.15 + DLI slideshow viewer (per-line colour).
;
; Self-booting (OS loads this to $2000, JSRs $2006). Steps through NIMAGES
; pictures stored as raw sectors -- image i at sectors BASESEC + i*SPI -- SIO-
; reading SPI sectors into $4000 (bitmap $4000/$5000, colour table $6000 of
; 96 bands x 4, display list $6400) and showing each with a display-list
; interrupt that rewrites the four colour registers every two scanlines.
;
; build-time #defines -- WAITMODE WAITSECS RATE NIMAGES LOOPFLAG BASESEC SPI.
CP = $cb ; zero-page pointer into the colour table
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont: ; $2006 -- OS JSRs here after loading the stub
sei
lda #$00
sta $26f ; GPRIOR = 0
lda #$00
sta $230 ; SDLSTL = $6400 (the loaded display list)
lda #$64
sta $231
lda #<dli
sta $200
lda #>dli
sta $201
ldy #<vbi
ldx #>vbi
lda #$07
jsr $e45c ; SETVBV (deferred VBI)
lda #$00
sta ssidx
cli
ssmain:
lda #$00
sta $d40e ; NMIEN off (no DLI/VBI while loading)
sta $22f ; SDMCTL off (blank)
jsr readimg ; SIO-load image ssidx (IRQ stays on for SIOV)
; re-init the colour pointer + band-0 colours for the new image
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $2c8
lda $6001
sta $2c4
lda $6002
sta $2c5
lda $6003
sta $2c6
lda #$c0
sta $d40e ; NMIEN = DLI + VBI
lda #$22
sta $22f ; SDMCTL = playfield + DL DMA
jsr sswait
inc ssidx
lda ssidx
cmp #NIMAGES
bcc ssmain
#if LOOPFLAG == 1
lda #$00
sta ssidx
jmp ssmain
#else
sei
lda #$00
sta $d40e ; NMIEN off
lda #$40
sta $d40e ; restore VBI only (OS housekeeping)
lda #$00
sta $09
cli
jmp $e474 ; warm-start (exit)
#endif
binit:
rts
; ---- SIO read SPI sectors of image ssidx into $4000 ----
readimg:
lda #<BASESEC
sta secn
lda #>BASESEC
sta secn+1
ldx ssidx
beq rsbuf
radd:
clc
lda secn
adc #SPI
sta secn
bcc ra1
inc secn+1
ra1:
dex
bne radd
rsbuf:
lda #$00
sta $0304
lda #$40
sta $0305
lda #SPI
sta cnt
rloop:
lda #$31
sta $0300
lda #$01
sta $0301
lda #$52
sta $0302
lda #$40
sta $0303
lda #$1f
sta $0306
lda #$80
sta $0308
lda #$00
sta $0309
lda secn
sta $030a
lda secn+1
sta $030b
jsr $e459 ; SIOV
clc
lda $0304
adc #$80
sta $0304
bcc rb1
inc $0305
rb1:
inc secn
bne rb2
inc secn+1
rb2:
dec cnt
bne rloop
rts
; ---- wait (returns; defeats attract mode via $4d) ----
sswait:
#if WAITMODE == 1
lda #$ff
sta $2fc
sw1:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq sw1
rts
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14
sw2:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc sw2
bne sw2d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw2
sw2d:
rts
#endif
#if WAITMODE == 3
lda #$ff
sta $2fc
lda #$00
sta $12
sta $13
sta $14
sw3:
lda #$00
sta $4d
lda $2fc
cmp #$ff
bne sw3d
lda $13
cmp #>(WAITSECS*RATE)
bcc sw3
bne sw3d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw3
sw3d:
rts
#endif
ssidx: .byte 0
secn: .word 0
cnt: .byte 0
; reset colour pointer + band-0 colours each frame (from the loaded $6000 table)
vbi:
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $d01a
lda $6001
sta $d016
lda $6002
sta $d017
lda $6003
sta $d018
jmp $e462 ; XITVBV
dli:
pha
tya
pha
ldy #$00
sta $d40a ; WSYNC
lda (CP),y
sta $d01a
iny
lda (CP),y
sta $d016
iny
lda (CP),y
sta $d017
iny
lda (CP),y
sta $d018
lda CP
clc
adc #$04
sta CP
bcc nocarry
inc CP+1
nocarry:
pla
tay
pla
rti

View file

@ -0,0 +1,272 @@
; lenser -- Atari static-playfield slideshow viewer (GR.15 / GR.9 / GR.8).
;
; Self-booting (OS loads this to $2000, JSRs $2006). Steps through NIMAGES
; pictures stored as raw sectors -- image i at sectors BASESEC + i*SPI -- SIO-
; reading SPI sectors and showing each before advancing (key / seconds / both).
;
; DOUBLE BUFFERED so the previous slide stays on screen while the next loads (no
; blank between slides). Two RAM buffers alternate as front/back --
; buffer 0 -- bitmap $4000/$5000, colour bytes $6000
; buffer 1 -- bitmap $7000/$8000, colour bytes $9000
; both kept below $A000 so they are RAM even when the BASIC ROM is enabled. The
; display list's two LMS addresses point at the front buffer; each slide is SIO-
; read into the *back* buffer while the front (the previous picture) keeps
; displaying, then -- during a vertical blank so the change isn't torn -- the LMS
; addresses and colour registers are switched to it. ss_hi = ssbuf*$30 is the
; high-byte offset ($00 buffer 0, $30 buffer 1) added to every buffer address.
;
; The mode is fixed for the whole slideshow, chosen by build-time #defines --
; DLMODE ANTIC mode byte ($0e GR.15, $0f GR.9/GR.8)
; GPRIOR GTIA priority/mode ($00, or $40 for GR.9)
; COLORMODE colour-register layout (0 GR.15 / 1 GR.9 / 2 GR.8)
; plus WAITMODE/WAITSECS/RATE, NIMAGES, LOOPFLAG, BASESEC, SPI (see assemble.py).
colptr = $cb ; zero-page pointer to the back buffer's colours
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched by the ATR writer)
.word $2000 ; load address
.word binit ; init address (DOSINI)
cont: ; $2006 -- OS JSRs here after loading the stub
lda #<dlist
sta $230 ; SDLSTL
lda #>dlist
sta $231 ; SDLSTH
lda #GPRIOR
sta $26f ; GPRIOR
lda #$00
sta $22f ; SDMCTL = 0 (blank before the first image only)
sta ssidx
sta ssbuf ; first slide loads into buffer 0
ssmain:
jsr sethi ; ss_hi = ssbuf * $30
jsr readimg ; SIO-load image ssidx into the back buffer
; ---- wait for a vertical blank, then flip to the back buffer ----
; (the front buffer -- the previous slide -- has stayed on screen)
lda $14
vbwait:
cmp $14
beq vbwait ; RTCLOK ticked -> we are in the vertical blank
; display-list LMS -- region 1 = base, region 2 = base + $1000
lda #$00
sta lms1
sta lms2
lda #$40
clc
adc ss_hi
sta lms1+1 ; $40 / $70
lda #$50
clc
adc ss_hi
sta lms2+1 ; $50 / $80
; colour bytes are at base + $2000
lda #$00
sta colptr
lda #$60
clc
adc ss_hi
sta colptr+1 ; $60 / $90
#if COLORMODE == 0
ldy #$00
lda (colptr),y
sta $2c8 ; COLBAK (value 0)
ldy #$01
lda (colptr),y
sta $2c4 ; COLPF0 (value 1)
ldy #$02
lda (colptr),y
sta $2c5 ; COLPF1 (value 2)
ldy #$03
lda (colptr),y
sta $2c6 ; COLPF2 (value 3)
#endif
#if COLORMODE == 1
ldy #$00
lda (colptr),y
sta $2c8 ; COLBAK = hue (GR.9)
#endif
#if COLORMODE == 2
ldy #$00
lda (colptr),y
sta $2c6 ; COLPF2 background
sta $2c8 ; COLBAK border = background
ldy #$01
lda (colptr),y
sta $2c5 ; COLPF1 foreground
#endif
lda #$22
sta $22f ; SDMCTL on (a no-op after the first slide)
jsr sswait
lda ssbuf
eor #$01
sta ssbuf ; next slide loads into the other buffer
inc ssidx
lda ssidx
cmp #NIMAGES
bcc ssmain
#if LOOPFLAG == 1
lda #$00
sta ssidx
jmp ssmain
#else
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC
jmp $e474 ; warm-start (exit)
#endif
binit:
rts
; ss_hi = ssbuf * $30 (back-buffer high-byte offset -- $00 buffer 0, $30 buffer 1)
sethi:
ldx #$00
lda ssbuf
beq sh0
ldx #$30
sh0:
stx ss_hi
rts
; ---- SIO read SPI sectors of image ssidx into the back buffer (base+$0000) ----
readimg:
lda #<BASESEC
sta secn
lda #>BASESEC
sta secn+1
ldx ssidx
beq rsbuf
radd:
clc
lda secn
adc #SPI
sta secn
bcc ra1
inc secn+1
ra1:
dex
bne radd
rsbuf:
lda #$00
sta $0304 ; DBUFLO = $00
lda #$40
clc
adc ss_hi
sta $0305 ; DBUFHI = $40 / $70 (back buffer base)
lda #SPI
sta cnt
rloop:
lda #$31
sta $0300 ; DDEVIC = disk
lda #$01
sta $0301 ; DUNIT 1
lda #$52
sta $0302 ; DCOMND R (read)
lda #$40
sta $0303 ; DSTATS = read direction
lda #$1f
sta $0306 ; DTIMLO
lda #$80
sta $0308 ; DBYTLO = 128
lda #$00
sta $0309 ; DBYTHI
lda secn
sta $030a ; DAUX1 sector low
lda secn+1
sta $030b ; DAUX2 sector high
jsr $e459 ; SIOV
clc
lda $0304
adc #$80
sta $0304 ; buffer += 128
bcc rb1
inc $0305
rb1:
inc secn
bne rb2
inc secn+1
rb2:
dec cnt
bne rloop
rts
; ---- wait (returns; defeats attract mode via $4d) ----
sswait:
#if WAITMODE == 1
lda #$ff
sta $2fc ; clear CH
sw1:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq sw1
rts
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14 ; reset RTCLOK
sw2:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc sw2
bne sw2d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw2
sw2d:
rts
#endif
#if WAITMODE == 3
lda #$ff
sta $2fc
lda #$00
sta $12
sta $13
sta $14
sw3:
lda #$00
sta $4d
lda $2fc
cmp #$ff
bne sw3d ; any key ends the slide
lda $13
cmp #>(WAITSECS*RATE)
bcc sw3
bne sw3d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw3
sw3d:
rts
#endif
ssidx: .byte 0
ssbuf: .byte 0 ; 0 or 1 -- which buffer the next slide loads into
ss_hi: .byte 0 ; ssbuf * $30 (back-buffer high-byte offset)
secn: .word 0
cnt: .byte 0
dlist:
.byte $70,$70,$70 ; 24 blank scan lines
.byte DLMODE+$40 ; LMS + mode
lms1:
.word $4000 ; region 1 base (patched to the front buffer)
.dsb 101,DLMODE
.byte DLMODE+$40 ; LMS + mode
lms2:
.word $5000 ; region 2 base (patched to the front buffer)
.dsb 89,DLMODE
.byte $41 ; JVB
.word dlist

120
lenser/basicgen.py Normal file
View file

@ -0,0 +1,120 @@
"""Generate a small, colourful Commodore 64 BASIC program (tokenised .PRG) that
prints image metadata. We emit the tokenised bytes directly -- token bytes for
the few keywords used (PRINT, POKE) and PETSCII for everything else -- so there
is no dependency on an external BASIC tokeniser.
"""
from __future__ import annotations
# BASIC V2 keyword tokens.
_PRINT = 0x99
_POKE = 0x97
_CHR = 0xC7 # CHR$( function token
_QUOTE = 0x22
# PETSCII control codes.
CLR = 0x93
RVON = 0x12
RVOFF = 0x92
# colours
WHITE, RED, GREEN, BLUE = 0x05, 0x1c, 0x1e, 0x1f
BLACK, ORANGE, BROWN = 0x90, 0x81, 0x95
LT_RED, DK_GREY, GREY, LT_GREEN = 0x96, 0x97, 0x98, 0x99
LT_BLUE, LT_GREY, PURPLE, YELLOW, CYAN = 0x9a, 0x9b, 0x9c, 0x9e, 0x9f
# label colours cycled down the screen (none black, all readable on black).
_LABEL_COLOURS = [CYAN, YELLOW, LT_GREEN, LT_RED, LT_BLUE, PURPLE, ORANGE, GREEN]
# per-character rainbow for the title (bright, distinct, no black).
_RAINBOW = [RED, ORANGE, YELLOW, LT_GREEN, GREEN, CYAN, LT_BLUE, BLUE, PURPLE,
LT_RED, WHITE]
_SCREEN_W = 40
_VALUE_COL = 11 # column where a value starts (= label field width)
_LINE_MAX = _SCREEN_W - 1 # keep lines < 40 so the screen never auto-wraps
_VALUE_W = _LINE_MAX - _VALUE_COL # printable value chars per line
def _petscii(text: str) -> bytes:
"""Map an ASCII string to printable PETSCII (upper-case glyph range)."""
out = bytearray()
for ch in str(text).upper():
b = ord(ch)
if b == _QUOTE:
b = 0x27 # avoid closing the BASIC string
if 0x20 <= b <= 0x5F:
out.append(b)
else:
out.append(0x2E) # '.'
return bytes(out)
def _print_str(inner: bytes) -> bytes:
return bytes([_PRINT, _QUOTE]) + inner + bytes([_QUOTE])
def _assemble(lines: list[tuple[int, bytes]]) -> bytes:
"""Link tokenised lines into a PRG (load address $0801)."""
cur = 0x0801
pieces = []
for num, toks in lines:
body = bytes([num & 0xFF, (num >> 8) & 0xFF]) + toks + b"\x00"
nxt = cur + 2 + len(body)
pieces.append(bytes([nxt & 0xFF, (nxt >> 8) & 0xFF]) + body)
cur = nxt
return bytes([0x01, 0x08]) + b"".join(pieces) + b"\x00\x00"
def _rainbow_title(text: str) -> bytes:
"""Centred, per-character rainbow title (control codes don't take columns)."""
pad = max(0, (_SCREEN_W - len(text)) // 2)
out = bytes([CLR]) + _petscii(" " * pad)
for k, ch in enumerate(text):
out += bytes([_RAINBOW[k % len(_RAINBOW)]]) + _petscii(ch)
return bytes([_PRINT, _QUOTE]) + out + bytes([_QUOTE])
def _field_lines(label: str, value: str, colour: int) -> list[bytes]:
"""Word-/width-wrapped PRINT lines for one field; continuations are indented
to the value's start column so a long value lines up under itself."""
label_p = _petscii((label + ":").ljust(_VALUE_COL))
value = str(value)[:_VALUE_W * 4] # cap at four screen lines
chunks = [value[i:i + _VALUE_W] for i in range(0, len(value), _VALUE_W)] or [""]
out = [_print_str(bytes([colour]) + label_p + bytes([WHITE]) + _petscii(chunks[0]))]
indent = _petscii(" " * _VALUE_COL)
for chunk in chunks[1:]:
out.append(_print_str(bytes([WHITE]) + indent + _petscii(chunk)))
return out
def build_info_prg(fields: list[tuple[str, str]]) -> bytes:
"""Return a tokenised BASIC PRG that prints ``fields`` (label, value)."""
lines: list[tuple[int, bytes]] = []
num = 0
def add(toks: bytes):
nonlocal num
num += 10
lines.append((num, toks))
# border and background both black
add(bytes([_POKE]) + b"53280,0:" + bytes([_POKE]) + b"53281,0")
add(_rainbow_title("8 Bit Lenser picture info"))
add(bytes([_PRINT])) # blank line
for i, (label, value) in enumerate(fields):
col = _LABEL_COLOURS[i % len(_LABEL_COLOURS)]
for line in _field_lines(label, value, col):
add(line)
add(bytes([_PRINT]))
# PRINT " load "CHR$(34)"*"CHR$(34)",8,1 to view picture" -- CHR$(34) is the
# double-quote, which can't appear literally inside a BASIC string.
q = bytes([_CHR]) + _petscii("(34)")
add(bytes([_PRINT])
+ bytes([_QUOTE]) + bytes([GREY]) + _petscii(" load ") + bytes([_QUOTE])
+ q
+ bytes([_QUOTE]) + _petscii("*") + bytes([_QUOTE])
+ q
+ bytes([_QUOTE]) + _petscii(",8,1 to view picture") + bytes([_QUOTE]))
return _assemble(lines)

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

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

@ -0,0 +1 @@
"""Commodore 128 target for lenser (VDC 8563 80-column display)."""

View file

@ -0,0 +1,21 @@
"""Commodore 128 conversion dispatch (VDC 80-column)."""
from __future__ import annotations
from ... import imageprep
from . import mono
from . import color
from . import hicolor
_MODULES = {"mono": mono, "color": color, "hicolor": hicolor}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="mono", palette_name="vdc",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, mono)
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,44 @@
"""C128 VDC chunky 80x100 16-colour mode.
MAME's 8563 draws the "bitmap" through the character/font path, so true
per-pixel bitmap colour isn't possible -- but each 8x2 cell can carry its own
colour via the attribute byte (fg = high nibble). Filling the character matrix
with a solid glyph turns that into a per-cell SOLID colour image: a chunky
80x100 picture using all 16 VDC colours. Lower resolution than `mono`'s
640x200, but full colour (or smooth multi-level greyscale on the four greys).
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import Conversion, perceptual_error
from .. import palette as vdcpal
# 8x2 device-pixel cells over the 640x200 display = 80x100 colour cells. Each
# cell is ~8*0.42 wide x 2 tall, so the 80x100 grid is ~4:3 -> aspect ~1.68.
WIDTH, HEIGHT = 80, 100
PIXEL_ASPECT = 1.68
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
intensive=False, base_color=None):
prgb = vdcpal.get_palette().astype(np.uint8)
plab = vdcpal.palette_lab()
lab = c64pal.srgb_to_lab(img_rgb)
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8)
# one attribute byte per cell; colour in the HIGH nibble (VDC fg = attr>>4),
# low nibble (bg) left 0 -- the solid glyph means only fg shows.
attr = ((idx.reshape(-1).astype(np.uint8) & 0x0F) << 4)
data = bytes(attr.tolist()) # 80*100 = 8000
return Conversion(
mode="color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0, viewer="c128",
preview_rgb=prgb[idx], error=perceptual_error(idx, lab, plab),
meta={"palette": "vdc", "dither": dither_mode, "fgbg": 0x0F,
"vdc_mode": "color"},
)

View file

@ -0,0 +1,142 @@
"""C128 VDC high-resolution colour mode (640x200 via a custom character set).
MAME's 8563 (and real hardware) renders 80-column *character* mode with a custom
font + per-cell attributes. That gives genuine per-pixel detail (8x8 glyph per
cell) at 640x200, with one freely chosen INK colour per cell (attribute low
nibble) over a single GLOBAL background (VDC register 26) -- a ZX-Spectrum-like
colour model, but at double the Spectrum's horizontal resolution.
The picture is built by, for each 8x8 cell, choosing the ink colour that best
represents it against the global background, dithering the cell to ink/background
(a 1bpp glyph), then vector-quantising the ~2000 cell glyphs down to a 512-entry
character set. The VDC addresses 256 glyphs per bank; a second bank is selected
per cell by the attribute's ALTERNATE_CHARSET bit (bit 7), giving 512 glyphs.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import Conversion, perceptual_error
from .. import palette as vdcpal
WIDTH, HEIGHT = 640, 200
PIXEL_ASPECT = 0.42 # same fine pixels as the mono bitmap
CW, CH = 8, 8 # 8x8 character cells
COLS, ROWS = WIDTH // CW, HEIGHT // CH # 80 x 25 = 2000 cells
BANK = 256 # glyphs per VDC charset bank
NGLYPH = 512 # two banks (selected per cell by attr bit 7)
# VDC RAM layout (matches the C128 default so registers stay default).
CODES_ADDR = 0x0000
ATTR_ADDR = 0x0800
CHAR_ADDR = 0x2000 # bank 0 at $2000, bank 1 at $3000 (+$1000)
VDC_LEN = 0x4000 # full 16K, copied to VDC RAM by the viewer
def _cell_view(a):
"""(ROWS*CH, COLS*CW, ...) -> (ROWS, COLS, CH, CW, ...) cell blocks."""
r = a.reshape(ROWS, CH, COLS, CW, *a.shape[2:])
return r.transpose(0, 2, 1, 3, *range(4, a.ndim + 2))
def _kmeans_binary(patterns, k, iters=10, seed=0):
"""Cluster 0/1 patterns (N, D) into k binary centroids (Hamming / majority)."""
n = len(patterns)
rng = np.random.default_rng(seed)
uniq = np.unique(patterns, axis=0)
if len(uniq) <= k:
cb = np.zeros((k, patterns.shape[1]), np.uint8)
cb[:len(uniq)] = uniq
d = (patterns[:, None, :] != cb[None, :, :]).sum(2)
return cb, d.argmin(1)
cb = uniq[rng.choice(len(uniq), k, replace=False)].astype(np.uint8)
assign = np.zeros(n, np.int32)
for _ in range(iters):
# nearest centroid by Hamming distance, in chunks to bound memory
for s in range(0, n, 4096):
blk = patterns[s:s + 4096]
d = (blk[:, None, :] != cb[None, :, :]).sum(2)
assign[s:s + 4096] = d.argmin(1)
for j in range(k):
members = patterns[assign == j]
if len(members):
cb[j] = (members.mean(0) >= 0.5).astype(np.uint8)
return cb, assign
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
intensive=False, base_color=None):
"""Full 16-colour high-resolution mode."""
return build(img_rgb, dither_mode, inks=list(range(16)),
bg_list=list(range(16)), mode_name="hicolor")
def build(img_rgb, dither_mode, inks, bg_list, mode_name):
"""Shared 640x200 custom-charset encoder.
inks palette indices a cell's foreground may use (per-cell ink choice)
bg_list palette indices to try as the single global background
mode_name value for Conversion.mode (the VDC viewer is the same either way)
"""
prgb = vdcpal.get_palette().astype(np.uint8)
plab = vdcpal.palette_lab()
lab = c64pal.srgb_to_lab(img_rgb) # (H, W, 3)
inks = np.asarray(inks)
# distance from every pixel to every palette colour
dist = np.linalg.norm(lab[:, :, None, :] - plab[None, None, :, :], axis=3)
dist_cells = _cell_view(dist) # (R,C,CH,CW,16)
dist_inks = dist_cells[..., inks] # (R,C,CH,CW,len)
# choose the global background: the colour that, as the shared second tone,
# lets each cell's best ink reproduce the image with least total error.
best_bg, best_err, best_ink = bg_list[0], None, None
for bg in bg_list:
d_bg = dist_cells[..., bg:bg + 1] # (R,C,CH,CW,1)
per_ink = np.minimum(d_bg, dist_inks).sum((2, 3)) # (R,C,len)
sel = per_ink.argmin(2) # (R,C) index into inks
err = np.take_along_axis(per_ink, sel[..., None], 2).sum()
if best_err is None or err < best_err:
best_bg, best_err, best_ink = bg, err, inks[sel]
bg, ink_cell = best_bg, best_ink # ink_cell (R,C) palette
# dither each pixel between the global bg and its cell's ink
ink_px = np.repeat(np.repeat(ink_cell, CH, 0), CW, 1) # (H,W)
allowed = np.stack([np.full((HEIGHT, WIDTH), bg), ink_px], axis=2)
idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8)
# 1bpp glyph per cell (1 = ink), then vector-quantise to a 256-glyph charset
bits = (idx == ink_px).astype(np.uint8) # (H,W)
glyph_cells = _cell_view(bits).reshape(ROWS * COLS, CH * CW)
codebook, assign = _kmeans_binary(glyph_cells, NGLYPH) # assign 0..NGLYPH-1
code_map = assign.reshape(ROWS, COLS)
# rebuild the actually-displayed image from the quantised glyphs + colours
glyphs_img = codebook[assign].reshape(ROWS, COLS, CH, CW)
shown_ink = (glyphs_img == 1)
final_idx = np.where(
shown_ink, ink_cell[:, :, None, None], bg).astype(np.uint16)
final_idx = final_idx.transpose(0, 2, 1, 3).reshape(HEIGHT, WIDTH)
# ---- assemble the VDC RAM image ----
vdc = bytearray(VDC_LEN)
glyph = code_map.reshape(-1)
codes = (glyph & 0xFF).astype(np.uint8) # char code within bank
# ink in the low nibble; bit 7 (ALTERNATE_CHARSET) selects bank 1
attr = ((ink_cell.reshape(-1) & 0x0F) | ((glyph >> 8) << 7)).astype(np.uint8)
vdc[CODES_ADDR:CODES_ADDR + codes.size] = codes.tobytes()
vdc[ATTR_ADDR:ATTR_ADDR + attr.size] = attr.tobytes()
for g in range(NGLYPH):
rows = np.packbits(codebook[g].reshape(CH, CW), axis=1).reshape(-1)
off = CHAR_ADDR + (g >> 8) * 0x1000 + ((g & 0xFF) << 4)
vdc[off:off + CH] = rows.tobytes()
return Conversion(
mode=mode_name, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=final_idx, data=bytes(vdc), data_addr=0, viewer="c128",
preview_rgb=prgb[final_idx],
error=perceptual_error(final_idx, lab, plab),
meta={"palette": "vdc", "dither": dither_mode, "fgbg": bg & 0x0F,
"vdc_mode": "hicolor", "bg": bg},
)

View file

@ -0,0 +1,29 @@
"""C128 VDC high-resolution greyscale / black-and-white (640x200).
MAME's 8563 has no true linear bitmap renderer (its R25-bit7 "bitmap" path only
emits one bit per 8-pixel cell), so genuine 640x200 detail has to come through
the character/font path -- exactly as the `hicolor` mode does. This mode reuses
that machinery restricted to the VDC's four greys (black, dark grey, light grey,
white): each 8x8 cell picks the grey that best matches it over a global grey
background and dithers to a 1bpp glyph, giving smooth multi-level greyscale at
full resolution. `--mono-base` swaps the grey ramp for a black->colour->white
ramp to tint the result.
"""
from __future__ import annotations
from . import hicolor
WIDTH, HEIGHT = hicolor.WIDTH, hicolor.HEIGHT
PIXEL_ASPECT = hicolor.PIXEL_ASPECT
GREYS = [0, 1, 14, 15] # VDC black, dark grey, light grey, white
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
intensive=False, base_color=None):
if base_color in range(1, 16):
inks = sorted({0, int(base_color), 15}) # black -> tint -> white
else:
inks = GREYS
return hicolor.build(img_rgb, dither_mode, inks=inks, bg_list=inks,
mode_name="mono")

23
lenser/c128/exporter.py Normal file
View file

@ -0,0 +1,23 @@
"""Build a C128 autobooting .d64 (the VDC viewer PRG, loaded with RUN"PIC")."""
from __future__ import annotations
import os
from .. import diskimage
from .viewer import assemble
def export_d64(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(".d64"):
output_path += ".d64"
# "color" = 80x100 chunky solid cells; "hicolor" (also used by mono) =
# 640x200 custom-charset font mode.
if conv.meta.get("vdc_mode") == "color":
prg = assemble.build_prg_color(bytes(conv.data), conv.meta.get("fgbg", 0x0F))
else:
prg = assemble.build_prg_hicolor(bytes(conv.data), conv.meta.get("fgbg", 0x00))
name = os.path.splitext(os.path.basename(source_path or output_path))[0]
diskimage.build_disk(output_path, "d64", name[:16] or "8bitlenser", "cv",
[("pic", prg)])
return output_path

38
lenser/c128/palette.py Normal file
View file

@ -0,0 +1,38 @@
"""Commodore 128 VDC (8563) 16-colour RGBI palette.
The VDC outputs digital RGBI, giving a CGA-like 16-colour set (quite different
from the VIC-II's colours). Index 0 = black, 15 = white -- the only two the mono
mode needs; the rest are provided for tinted-mono and future colour modes.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
VDC = np.array([
(0x00, 0x00, 0x00), # 0 black
(0x55, 0x55, 0x55), # 1 dark grey
(0x00, 0x00, 0xAA), # 2 blue
(0x55, 0x55, 0xFF), # 3 light blue
(0x00, 0xAA, 0x00), # 4 green
(0x55, 0xFF, 0x55), # 5 light green
(0x00, 0xAA, 0xAA), # 6 cyan
(0x55, 0xFF, 0xFF), # 7 light cyan
(0xAA, 0x00, 0x00), # 8 red
(0xFF, 0x55, 0x55), # 9 light red
(0xAA, 0x00, 0xAA), # 10 purple
(0xFF, 0x55, 0xFF), # 11 light purple
(0xAA, 0x55, 0x00), # 12 brown
(0xFF, 0xFF, 0x55), # 13 light yellow
(0xAA, 0xAA, 0xAA), # 14 light grey
(0xFF, 0xFF, 0xFF), # 15 white
], dtype=np.float64)
def get_palette() -> np.ndarray:
return VDC
def palette_lab() -> np.ndarray:
return srgb_to_lab(VDC)

View file

@ -0,0 +1 @@
"""C128 8502/VDC viewer (assembled by xa)."""

View file

@ -0,0 +1,122 @@
"""Assemble the C128 VDC viewer with `xa` and build the loadable PRG.
The PRG loads at the C128 BASIC start ($1C01): a tiny BASIC stub (`10 SYS7200`)
followed by the 8502 viewer (at $1C20) and, from $2000, the 640x200 bitmap.
Running it (RUN"PIC") executes the stub, which SYSes the viewer.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
BASIC_START = 0x1C01
ML_ORG = 0x1C20
DATA_ORG = 0x2000 # bitmap goes here (viewer copies it to the VDC)
# BASIC: 10 SYS7200 ($1C20 = 7200) -- bytes as they sit from $1C01
_STUB = bytes([0x0B, 0x1C, 0x0A, 0x00, 0x9E,
0x37, 0x32, 0x30, 0x30, 0x00, 0x00, 0x00])
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _xa(wrapper: str) -> bytes:
"""Assemble a generated wrapper (xa runs in VIEWER_DIR so #includes resolve)."""
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)
def _assemble(fgbg: int, source: str) -> bytes:
return _xa(f"#define SRC ${DATA_ORG:04X}\n"
f"#define FGBG ${fgbg & 0xFF:02X}\n"
f'#include "{source}"\n')
def _wrap_prg(code: bytes, data: bytes) -> bytes:
"""Lay out load-address prefix + BASIC stub + viewer code + data @ $2000."""
mem = bytearray()
mem += _STUB # $1C01..
mem += b"\x00" * (ML_ORG - BASIC_START - len(_STUB))
mem += code # $1C20..
if len(mem) > DATA_ORG - BASIC_START:
raise AssemblerError("viewer code overruns the $2000 data area")
mem += b"\x00" * (DATA_ORG - BASIC_START - len(mem))
mem += data # $2000..
return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem)
def build_prg_color(attributes: bytes, fgbg: int = 0x0F) -> bytes:
"""Return the loadable PRG for the 80x100 chunky-colour viewer.
`attributes` is the 8000-byte per-cell colour map (colour in the high
nibble); the viewer fills the character matrix with a solid glyph itself.
"""
return _wrap_prg(_assemble(fgbg, "color.s"), attributes)
def build_prg_hicolor(vdc_image: bytes, fgbg: int = 0x00) -> bytes:
"""Return the loadable PRG for the 640x200 custom-charset viewer (font mode).
Used by both the `hicolor` and `mono` modes. `vdc_image` is the full VDC RAM
image (character codes, attributes and the custom character set already laid
out); `fgbg` carries the global background in its low nibble. The viewer
copies the image verbatim into VDC RAM.
"""
return _wrap_prg(_assemble(fgbg, "hicolor.s"), vdc_image)
_SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
def build_slideshow_prg(fgbg_list, advance: str = "both", seconds: int = 10,
loop: bool = True, video: str = "pal") -> bytes:
"""Return the bootable C128 slideshow viewer PRG (RUN\"PIC\").
``fgbg_list`` is one VDC R26 background byte per image (conv.meta["fgbg"]);
the per-image pictures are separate "00".."NN" files the viewer loads. The
viewer is code-only (no appended image) -- just the BASIC stub + the 8502
loop + the ss_fgbg table.
"""
if not fgbg_list:
raise AssemblerError("a slideshow needs at least one image")
jiffyps = 60 if video == "ntsc" else 50
table = ",".join(str(int(b) & 0xFF) for b in fgbg_list)
code = _xa(
f"#define WAITMODE {_SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define JIFFYPS {jiffyps}\n"
f"#define NIMAGES {len(fgbg_list)}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
'#include "slideshow.s"\n'
"ss_fgbg:\n"
f" .byte {table}\n")
mem = bytearray()
mem += _STUB # $1C01 BASIC stub (10 SYS7200)
mem += b"\x00" * (ML_ORG - BASIC_START - len(_STUB))
mem += code # $1C20 viewer + ss_fgbg table
return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem)

173
lenser/c128/viewer/color.s Normal file
View file

@ -0,0 +1,173 @@
; Commodore 128 VDC (8563) 80x100 chunky-colour image viewer.
;
; MAME's 8563 renders the bitmap through the character/font path- each display
; byte is a CHARACTER CODE whose glyph is drawn with fg = attribute high nibble,
; bg = attribute low nibble. So a per-cell SOLID colour image is produced by
; filling the whole character matrix with $FF (a solid glyph) and giving every
; cell its colour in the high nibble of its attribute byte.
;
; R9=1 (2 scan lines per char row) makes 80x100 cells- 8000 char bytes + 8000
; attribute bytes = 16000, which fits the stock 16K VDC RAM.
;
; #defines from viewer/assemble.py --
; SRC main-RAM address of the 8000 attribute bytes ($2000)
; FGBG VDC register 26 default value (unused cells / background)
* = $1C20
src = $fb
adlo = $fd
adhi = $fe
cntl = $02
cnth = $03
fill = $04
start:
sei
lda #$0e
sta $ff00 ; bank in RAM $4000-$BFFF, keep I/O for the VDC
lda #<nmi
sta $0318
lda #>nmi
sta $0319 ; neutralise NMI (not masked by SEI)
jsr setregs
; fill the character matrix VDC $0000 with $FF (solid glyph), 8000 bytes
lda #$00
sta adlo
sta adhi
lda #$ff
sta fill
lda #<8000
sta cntl
lda #>8000
sta cnth
jsr fillblk
; copy 8000 attribute bytes main SRC -> VDC $2000
lda #<SRC
sta src
lda #>SRC
sta src+1
lda #$00
sta adlo
lda #$20
sta adhi
lda #<8000
sta cntl
lda #>8000
sta cnth
jsr copyblk
; enable bitmap + attributes (display the picture)
ldx #25
lda #$c0
jsr vdcw
loop:
jmp loop
nmi:
rti
; program the VDC display geometry + attribute base (everything except R25)
setregs:
ldx #9
lda #$01
jsr vdcw ; R9 = 1 -> 2 scan lines per char row (80x100)
ldx #4
lda #131
jsr vdcw ; R4 vertical total -> (131+1)*2 = 264 lines
ldx #5
lda #$00
jsr vdcw ; R5 vertical total adjust
ldx #6
lda #100
jsr vdcw ; R6 vertical displayed = 100 rows (*2 = 200)
ldx #7
lda #116
jsr vdcw ; R7 vsync position
ldx #20
lda #$20
jsr vdcw ; R20 attribute base high = $2000
ldx #21
lda #$00
jsr vdcw ; R21 attribute base low
ldx #26
lda #FGBG
jsr vdcw ; R26 default fg/bg
ldx #10
lda #$20
jsr vdcw ; R10 cursor off (bit5) - hide the blinking cursor
ldx #12
lda #$00
jsr vdcw ; R12 display start high
ldx #13
lda #$00
jsr vdcw ; R13 display start low
rts
; fill cnt bytes = (fill) into VDC RAM from adhi/adlo (explicit address per byte)
fillblk:
ldx #18
lda adhi
jsr vdcw
ldx #19
lda adlo
jsr vdcw
ldx #31
lda fill
jsr vdcw
inc adlo
bne fb1
inc adhi
fb1:
lda cntl
bne fb2
dec cnth
fb2:
dec cntl
lda cntl
ora cnth
bne fillblk
rts
; copy cnt bytes from (src) into VDC RAM from adhi/adlo (explicit address per byte)
copyblk:
ldx #18
lda adhi
jsr vdcw
ldx #19
lda adlo
jsr vdcw
ldx #31
ldy #$00
lda (src),y
jsr vdcw
inc src
bne cb1
inc src+1
cb1:
inc adlo
bne cb2
inc adhi
cb2:
lda cntl
bne cb3
dec cnth
cb3:
dec cntl
lda cntl
ora cnth
bne copyblk
rts
; write A to VDC register X
vdcw:
stx $d600
vw:
bit $d600
bpl vw
sta $d601
rts

Some files were not shown because too many files have changed in this diff Show more