First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
1
lenser/a7800/__init__.py
Normal file
1
lenser/a7800/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 7800 ProSystem target for lenser (MARIA display processor)."""
|
||||
30
lenser/a7800/convert/__init__.py
Normal file
30
lenser/a7800/convert/__init__.py
Normal 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)
|
||||
127
lenser/a7800/convert/c160.py
Normal file
127
lenser/a7800/convert/c160.py
Normal 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
|
||||
36
lenser/a7800/convert/mono.py
Normal file
36
lenser/a7800/convert/mono.py
Normal 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
19
lenser/a7800/exporter.py
Normal 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
|
||||
1
lenser/a7800/viewer/__init__.py
Normal file
1
lenser/a7800/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 7800 6502/MARIA viewer (assembled by xa)."""
|
||||
139
lenser/a7800/viewer/assemble.py
Normal file
139
lenser/a7800/viewer/assemble.py
Normal 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)
|
||||
47
lenser/a7800/viewer/viewer.s
Normal file
47
lenser/a7800/viewer/viewer.s
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue