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

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

View file

@ -0,0 +1,19 @@
"""Commodore 16 (TED) conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import hires, mono
_MODULES = {"hires": hires, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="hires", palette_name="ted",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, hires)
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,93 @@
"""C16 TED hires bitmap mode: 320x200, two colours per 8x8 cell.
Unlike the VIC-II, each of the two per-cell colours may be any of the TED's 128
colours. The colours are stored across two 1K matrices (see MAME's mos7360
draw_bitmap):
ch byte (video base + $400): high nibble = fg hue, low nibble = bg hue
attr byte (video base): bits 0-2 = fg luminance, bits 4-6 = bg lum
A bitmap bit of 1 selects the foreground colour, 0 the background.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as tedpal
WIDTH, HEIGHT = 320, 200
CELL_W, CELL_H = 8, 8
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="ted", dither_mode="floyd",
intensive=False, base_color=None):
plab = tedpal.palette_lab()
img_lab = c64pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
sets = _select_pairs(dist)
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
index_image = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint16)
bitmap, attr, ch = _encode(index_image, sets, rows, cols)
payload = bytes(bitmap) + bytes(attr) + bytes(ch)
prgb = tedpal.get_palette().astype(np.uint8)
return base.Conversion(
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=index_image, data=payload, viewer="c16",
preview_rgb=prgb[index_image],
error=base.perceptual_error(index_image, img_lab, plab),
meta={"palette": "ted", "dither": dither_mode, "border": 0},
)
def _select_pairs(dist, k=16):
"""Per-cell best 2 colours from the 128-colour TED palette.
A full C(128,2) search is 8128 combos/cell; instead we restrict each cell to
its ``k`` best single colours (the optimal pair is almost always among them)
and search only C(k,2) pairs -- ~100x faster with effectively identical
results. dist: (n_cells, P, 128) squared CIELAB distances.
"""
n_cells = dist.shape[0]
single = dist.sum(1) # (n_cells, 128)
cand = np.argsort(single, axis=1)[:, :k] # (n_cells, k)
dist_c = np.take_along_axis(dist, cand[:, None, :], axis=2) # (n_cells, P, k)
best_err = np.full(n_cells, np.inf)
best = np.zeros((n_cells, 2), dtype=np.int64)
for i in range(k):
for j in range(i + 1, k):
m = np.minimum(dist_c[:, :, i], dist_c[:, :, j]).sum(1)
upd = m < best_err
best_err[upd] = m[upd]
best[upd, 0] = cand[upd, i]
best[upd, 1] = cand[upd, j]
return best
def _encode(index_image, sets, rows, cols):
"""Return (bitmap 8000, attr 1000, ch 1000) for the TED hires layout."""
bitmap = np.zeros(8000, dtype=np.uint8)
attr = np.zeros(1000, dtype=np.uint8)
ch = np.zeros(1000, dtype=np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
bg, fg = int(sets[ci, 0]), int(sets[ci, 1])
fg_hue, fg_lum = fg & 0x0F, (fg >> 4) & 0x07
bg_hue, bg_lum = bg & 0x0F, (bg >> 4) & 0x07
ch[ci] = (fg_hue << 4) | bg_hue
attr[ci] = (bg_lum << 4) | fg_lum
base_addr = cr * 320 + cc * 8
block = index_image[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
for r in range(8):
row = block[r]
byte = 0
for x in range(8):
byte = (byte << 1) | (1 if int(row[x]) == fg else 0)
bitmap[base_addr + r] = byte
return bitmap, attr, ch

View file

@ -0,0 +1,35 @@
"""C16 TED monochrome: 320x200, restricted to the TED's neutral grey ramp (8
luminance levels of hue 1, plus black) for smooth greyscale. ``--mono-base``
tints it toward one hue (black -> hue -> white). Two greys per 8x8 cell, packed
into the same TED hires layout as the colour mode."""
from __future__ import annotations
import numpy as np
from ...convert import base
from .. import palette as tedpal
from . import hires
WIDTH, HEIGHT = hires.WIDTH, hires.HEIGHT
CELL_W, CELL_H = hires.CELL_W, hires.CELL_H
PIXEL_ASPECT = hires.PIXEL_ASPECT
def convert(img_rgb, palette_name="ted", dither_mode="floyd",
intensive=False, base_color=None):
plab = tedpal.palette_lab()
prgb = tedpal.get_palette().astype(np.uint8)
ramp = base.luminance_ramp(plab, tedpal.GREYS, base_color)
idx, sets, rows, cols, err = base.mono_render(
img_rgb, plab, ramp, WIDTH, HEIGHT, CELL_W, CELL_H, dither_mode, n_free=2)
bitmap, attr, ch = hires._encode(idx, sets, rows, cols)
payload = bytes(bitmap) + bytes(attr) + bytes(ch)
return base.Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=payload, viewer="c16",
preview_rgb=prgb[idx], error=err,
meta={"palette": "ted", "dither": dither_mode, "border": 0},
)

16
lenser/c16/exporter.py Normal file
View file

@ -0,0 +1,16 @@
"""Build a Commodore 16 .prg (loaded + run via MAME quickload / on real HW)."""
from __future__ import annotations
from .viewer import assemble
_EXTS = (".prg", ".p00")
def export_prg(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".prg"
prg = assemble.build_prg(bytes(conv.data))
with open(output_path, "wb") as f:
f.write(prg)
return output_path

62
lenser/c16/palette.py Normal file
View file

@ -0,0 +1,62 @@
"""Commodore 16 / Plus4 TED (7360/8360) colour palette.
The TED produces 128 colours arranged as 8 luminance levels x 16 hues. A colour
value is 7-bit: bits 0-3 = hue (0-15), bits 4-6 = luminance (0-7), so the palette
index is ``(luminance << 4) | hue``. Hue 0 is (near) black at every luminance and
hue 1 is the neutral grey ramp, so true greys are available too.
RGB values are taken verbatim from MAME's ``mos7360`` PALETTE_MOS table so the
encoder matches what the emulator renders.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# 128 entries in (luminance<<4)|hue order, copied from MAME's mos7360.cpp.
TED = np.array([
(0x06, 0x01, 0x03), (0x2b, 0x2b, 0x2b), (0x67, 0x0e, 0x0f), (0x00, 0x3f, 0x42),
(0x57, 0x00, 0x6d), (0x00, 0x4e, 0x00), (0x19, 0x1c, 0x94), (0x38, 0x38, 0x00),
(0x56, 0x20, 0x00), (0x4b, 0x28, 0x00), (0x16, 0x48, 0x00), (0x69, 0x07, 0x2f),
(0x00, 0x46, 0x26), (0x06, 0x2a, 0x80), (0x2a, 0x14, 0x9b), (0x0b, 0x49, 0x00),
(0x00, 0x03, 0x02), (0x3d, 0x3d, 0x3d), (0x75, 0x1e, 0x20), (0x00, 0x50, 0x4f),
(0x6a, 0x10, 0x78), (0x04, 0x5c, 0x00), (0x2a, 0x2a, 0xa3), (0x4c, 0x47, 0x00),
(0x69, 0x2f, 0x00), (0x59, 0x38, 0x00), (0x26, 0x56, 0x00), (0x75, 0x15, 0x41),
(0x00, 0x58, 0x3d), (0x15, 0x3d, 0x8f), (0x39, 0x22, 0xae), (0x19, 0x59, 0x00),
(0x00, 0x03, 0x04), (0x42, 0x42, 0x42), (0x7b, 0x28, 0x20), (0x02, 0x56, 0x59),
(0x6f, 0x1a, 0x82), (0x0a, 0x65, 0x09), (0x30, 0x34, 0xa7), (0x50, 0x51, 0x00),
(0x6e, 0x36, 0x00), (0x65, 0x40, 0x00), (0x2c, 0x5c, 0x00), (0x7d, 0x1e, 0x45),
(0x01, 0x61, 0x45), (0x1c, 0x45, 0x99), (0x42, 0x2d, 0xad), (0x1d, 0x62, 0x00),
(0x05, 0x00, 0x02), (0x56, 0x55, 0x5a), (0x90, 0x3c, 0x3b), (0x17, 0x6d, 0x72),
(0x87, 0x2d, 0x99), (0x1f, 0x7b, 0x15), (0x46, 0x49, 0xc1), (0x66, 0x63, 0x00),
(0x84, 0x4c, 0x0d), (0x73, 0x55, 0x00), (0x40, 0x72, 0x00), (0x91, 0x33, 0x5e),
(0x19, 0x74, 0x5c), (0x32, 0x59, 0xae), (0x59, 0x3f, 0xc3), (0x32, 0x76, 0x00),
(0x02, 0x01, 0x06), (0x84, 0x7e, 0x85), (0xbb, 0x67, 0x68), (0x45, 0x96, 0x96),
(0xaf, 0x58, 0xc3), (0x4a, 0xa7, 0x3e), (0x73, 0x73, 0xec), (0x92, 0x8d, 0x11),
(0xaf, 0x78, 0x32), (0xa1, 0x80, 0x20), (0x6c, 0x9e, 0x12), (0xba, 0x5f, 0x89),
(0x46, 0x9f, 0x83), (0x61, 0x85, 0xdd), (0x84, 0x6c, 0xef), (0x5d, 0xa3, 0x29),
(0x02, 0x00, 0x0a), (0xb2, 0xac, 0xb3), (0xe9, 0x92, 0x92), (0x6c, 0xc3, 0xc1),
(0xd9, 0x86, 0xf0), (0x79, 0xd1, 0x76), (0x9d, 0xa1, 0xff), (0xbd, 0xbe, 0x40),
(0xdc, 0xa2, 0x61), (0xd1, 0xa9, 0x4c), (0x93, 0xc8, 0x3d), (0xe9, 0x8a, 0xb1),
(0x6f, 0xcd, 0xab), (0x8a, 0xb4, 0xff), (0xb2, 0x9a, 0xff), (0x88, 0xcb, 0x59),
(0x02, 0x00, 0x0a), (0xc7, 0xca, 0xc9), (0xff, 0xac, 0xac), (0x85, 0xd8, 0xe0),
(0xf3, 0x9c, 0xff), (0x92, 0xea, 0x8a), (0xb7, 0xba, 0xff), (0xd6, 0xd3, 0x5b),
(0xf3, 0xbe, 0x79), (0xe6, 0xc5, 0x65), (0xb0, 0xe0, 0x57), (0xff, 0xa4, 0xcf),
(0x89, 0xe5, 0xc8), (0xa4, 0xca, 0xff), (0xca, 0xb3, 0xff), (0xa2, 0xe5, 0x7a),
(0x01, 0x01, 0x01), (0xff, 0xff, 0xff), (0xff, 0xf6, 0xf2), (0xd1, 0xff, 0xff),
(0xff, 0xe9, 0xff), (0xdb, 0xff, 0xd3), (0xfd, 0xff, 0xff), (0xff, 0xff, 0xa3),
(0xff, 0xff, 0xc1), (0xff, 0xff, 0xb2), (0xfc, 0xff, 0xa2), (0xff, 0xee, 0xff),
(0xd1, 0xff, 0xff), (0xeb, 0xff, 0xff), (0xff, 0xf8, 0xff), (0xed, 0xff, 0xbc),
], dtype=np.float64)
# Neutral grey ramp (hue 1) plus black -- used by the monochrome mode.
GREYS = [0, 0x11, 0x21, 0x31, 0x41, 0x51, 0x61, 0x71] # (lum<<4)|1, lum 0..7
def get_palette() -> np.ndarray:
return TED
def palette_lab() -> np.ndarray:
return srgb_to_lab(TED)

View file

View file

@ -0,0 +1,62 @@
"""Assemble the C16 TED viewer with `xa` and build the loadable PRG.
The PRG loads at the C16 BASIC start ($1001): a BASIC stub (`10 SYS4128`)
followed by the 7501 viewer (at $1020), the two colour matrices ($1800 / $1C00)
and the bitmap ($2000). MAME's quickload runs it, the stub 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 = 0x1001
ML_ORG = 0x1020
ATTR_ORG = 0x1800 # luminance matrix (video matrix base)
CH_ORG = 0x1C00 # hue matrix (video base | $400)
BITMAP_ORG = 0x2000
# BASIC: 10 SYS4128 ($1020 = 4128) -- bytes as they sit from $1001
_STUB = bytes([0x0B, 0x10, 0x0A, 0x00, 0x9E,
0x34, 0x31, 0x32, 0x38, 0x00, 0x00, 0x00])
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")
proc = subprocess.run(["xa", "-o", "/dev/stdout", "viewer.s"],
capture_output=True, cwd=VIEWER_DIR)
if proc.returncode != 0:
raise AssemblerError(f"xa failed:\n{proc.stderr.decode()}")
return proc.stdout
def build_prg(payload: bytes) -> bytes:
"""payload = bitmap(8000) + attr(1000) + ch(1000); returns the loadable PRG."""
bitmap, attr, ch = payload[:8000], payload[8000:9000], payload[9000:10000]
code = _assemble()
mem = bytearray()
mem += _STUB # $1001..
mem += b"\x00" * (ML_ORG - BASIC_START - len(mem))
mem += code # $1020..
if len(mem) > ATTR_ORG - BASIC_START:
raise AssemblerError("viewer code overruns the $1800 matrix area")
mem += b"\x00" * (ATTR_ORG - BASIC_START - len(mem))
mem += attr # $1800..
mem += b"\x00" * (CH_ORG - BASIC_START - len(mem))
mem += ch # $1C00..
mem += b"\x00" * (BITMAP_ORG - BASIC_START - len(mem))
mem += bitmap # $2000..
return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem)

View file

@ -0,0 +1,26 @@
; Commodore 16 (TED 7360/8360) hires bitmap viewer.
;
; The whole picture is already laid out in RAM by the PRG (loaded via quickload):
; $1800 attribute matrix (luminance) 1000 bytes
; $1C00 colour matrix (hue) 1000 bytes
; $2000 bitmap 8000 bytes
; This just programs the TED for 320x200 hires bitmap mode and holds the display.
* = $1020
start:
sei
lda #$08
sta $ff12 ; bitmap base = $2000
lda #$18
sta $ff14 ; video matrix base = $1800 (hue matrix at $1C00)
lda #$00
sta $ff15 ; background colour (per-cell in hires; black)
lda #$00
sta $ff19 ; border / frame colour = black
lda #$08
sta $ff07 ; MCM off (hires), 40 columns
lda #$3b
sta $ff06 ; bitmap mode on, display on, 25 rows (set last)
loop:
jmp loop