First public commit.

This commit is contained in:
The Dust Council 2026-07-03 19:35:35 -07:00
parent 2a48f52979
commit 4bac9d83ed
288 changed files with 18417 additions and 1076 deletions

1
lenser/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)}")