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