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/coco/__init__.py Normal file
View file

@ -0,0 +1 @@
"""TRS-80 Color Computer (MC6847 VDG) image conversion and cartridge export."""

31
lenser/coco/cartridge.py Normal file
View file

@ -0,0 +1,31 @@
"""Build a TRS-80 CoCo Program Pak cartridge ROM (.ccc) holding viewer + image.
The CoCo autostarts the cartridge at $C000. Layout: viewer code, then the
6144-byte PMODE 4 image, padded to an 8KB ROM ($C000-$DFFF)."""
from __future__ import annotations
from . import viewer
CART_BASE = 0xC000
CART_SIZE = 0x2000 # 8 KB
def build_rom(data: bytes, vdg: int = 0xF8, display: str = "forever",
seconds: int = 0) -> bytes:
if len(data) != viewer.SCREEN_BYTES:
raise ValueError(f"unexpected image length {len(data)}")
vk = dict(vdg=vdg, display=display, seconds=seconds)
code = viewer.build(0, **vk) # pass 1: measure code length
data_src = CART_BASE + len(code)
code = viewer.build(data_src, **vk) # pass 2: real data address
rom = code + bytes(data)
if len(rom) > CART_SIZE:
raise ValueError("viewer + image exceed the 8KB cartridge")
return rom + bytes(CART_SIZE - len(rom))
def write_ccc(rom: bytes, path: str) -> str:
with open(path, "wb") as f:
f.write(rom)
return path

View file

@ -0,0 +1,16 @@
"""TRS-80 Color Computer conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import pmode3, pmode4, mono
_MODULES = {"pmode4": pmode4, "pmode3": pmode3, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="pmode4", palette_name="mc6847",
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,15 @@
"""CoCo monochrome -- PMODE 4's 256x192 black & white, exposed as the standard
``mono`` mode for cross-platform parity (tone carried by dithering)."""
from __future__ import annotations
from . import pmode4
WIDTH, HEIGHT, PIXEL_ASPECT = pmode4.WIDTH, pmode4.HEIGHT, pmode4.PIXEL_ASPECT
def convert(img_rgb, palette_name="mc6847", dither_mode="floyd",
intensive=False, base_color=None):
conv = pmode4.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv

View file

@ -0,0 +1,44 @@
"""CoCo PMODE 3: 128x192, 4 colours from one MC6847 colour set.
128 wide on a 4:3 screen -> 2:1 pixels (like C64 multicolor). The 4 colours are
fixed per CSS set (green/yellow/blue/red or buff/cyan/magenta/orange); we dither
to whichever set reproduces the image with lower error and tell the viewer which.
"""
from __future__ import annotations
import numpy as np
from ... import dither
from ...convert.base import Conversion, perceptual_error
from ...palette import srgb_to_lab
from .. import palette as cpal
WIDTH, HEIGHT = 128, 192
PIXEL_ASPECT = 2.0
def convert(img_rgb, palette_name="mc6847", dither_mode="floyd",
intensive=False, base_color=None):
img_lab = srgb_to_lab(img_rgb)
allowed = np.tile(np.array([0, 1, 2, 3]), (HEIGHT, WIDTH, 1))
best = None
for vdg, rgb in cpal.PMODE3_SETS.items():
plab = srgb_to_lab(rgb)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
err = perceptual_error(idx, img_lab, plab)
if best is None or err < best[0]:
best = (err, vdg, rgb, plab, idx)
err, vdg, rgb, plab, idx = best
data = cpal.pack_pmode3(idx) # 6144-byte video buffer
preview = rgb.astype(np.uint8)[idx]
return Conversion(
mode="pmode3", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="pmode3", preview_rgb=preview,
error=err,
meta={"palette": "mc6847", "dither": dither_mode, "vdg": vdg},
)

View file

@ -0,0 +1,41 @@
"""CoCo PMODE 4: 256x192, 1 bit/pixel, black & white (buff on black).
The CoCo's high-resolution monochrome mode -- 256x192 is exactly 4:3, so square
pixels. Tone is carried by dithering, like Apple HGR mono.
"""
from __future__ import annotations
import numpy as np
from ... import dither
from ...convert.base import Conversion, perceptual_error
from .. import palette as cpal
WIDTH, HEIGHT = 256, 192
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="mc6847", dither_mode="floyd",
intensive=False, base_color=None):
from ...palette import srgb_to_lab
plab = cpal.mono_lab()
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 = cpal.pack_pmode4(idx) # 6144-byte video buffer
preview = cpal.MONO.astype(np.uint8)[idx]
return Conversion(
mode="pmode4", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="pmode4", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": "mc6847", "dither": dither_mode, "vdg": 0xF8},
)

11
lenser/coco/exporter.py Normal file
View file

@ -0,0 +1,11 @@
"""Build a TRS-80 CoCo cartridge (.ccc) from a conversion."""
from __future__ import annotations
from . import cartridge
def export_ccc(conv, output_path, source_path=None, display="forever", seconds=0):
if not output_path.lower().endswith((".ccc", ".rom")):
output_path += ".ccc"
vdg = conv.meta.get("vdg", 0xF8) if conv.meta else 0xF8
rom = cartridge.build_rom(conv.data, vdg=vdg, display=display, seconds=seconds)
return cartridge.write_ccc(rom, output_path)

99
lenser/coco/mc6809.py Normal file
View file

@ -0,0 +1,99 @@
"""A tiny Motorola 6809 machine-code emitter (just what the CoCo viewer needs).
No 6809 assembler is installed, so -- as with the TI's TMS9900 emitter -- we emit
opcodes directly. Supports labels + 8-bit relative-branch backpatching.
Index-register codes for ,R+ postbytes: X=0x80, Y=0xA0, U=0xC0, S=0xE0.
"""
from __future__ import annotations
_IDX = {"x": 0x80, "y": 0xA0, "u": 0xC0, "s": 0xE0}
class Asm:
def __init__(self, base: int):
self.base = base
self.code = bytearray()
self.labels: dict[str, int] = {}
self._fix: list[tuple[int, str]] = [] # (pos of rel byte, label)
def pos(self) -> int:
return self.base + len(self.code)
def label(self, name: str):
self.labels[name] = self.pos()
def _b(self, *bs):
self.code += bytes(bs)
def _w(self, v): # 16-bit, big-endian
self.code += bytes([(v >> 8) & 0xFF, v & 0xFF])
# ---- inherent ----
def nop(self): self._b(0x12)
def clra(self): self._b(0x4F)
def clrb(self): self._b(0x5F)
def deca(self): self._b(0x4A)
def decb(self): self._b(0x5A)
def inca(self): self._b(0x4C)
def incb(self): self._b(0x5C)
def rts(self): self._b(0x39)
def sync(self): self._b(0x13)
# ---- immediate ----
def lda_imm(self, v): self._b(0x86, v & 0xFF)
def ldb_imm(self, v): self._b(0xC6, v & 0xFF)
def anda_imm(self, v): self._b(0x84, v & 0xFF)
def ora_imm(self, v): self._b(0x8A, v & 0xFF)
def cmpa_imm(self, v): self._b(0x81, v & 0xFF)
def cmpb_imm(self, v): self._b(0xC1, v & 0xFF)
def orcc(self, v): self._b(0x1A, v & 0xFF)
def andcc(self, v): self._b(0x1C, v & 0xFF)
def ldd_imm(self, v): self._b(0xCC); self._w(v)
def subd_imm(self, v): self._b(0x83); self._w(v)
def ldx_imm(self, v): self._b(0x8E); self._w(v)
def ldu_imm(self, v): self._b(0xCE); self._w(v)
def ldy_imm(self, v): self._b(0x10, 0x8E); self._w(v)
def lds_imm(self, v): self._b(0x10, 0xCE); self._w(v)
def cmpx_imm(self, v): self._b(0x8C); self._w(v)
def cmpu_imm(self, v): self._b(0x11, 0x83); self._w(v)
# ---- extended (16-bit address) ----
def lda_ext(self, a): self._b(0xB6); self._w(a)
def sta_ext(self, a): self._b(0xB7); self._w(a)
def ldb_ext(self, a): self._b(0xF6); self._w(a)
def stb_ext(self, a): self._b(0xF7); self._w(a)
def std_ext(self, a): self._b(0xFD); self._w(a)
def jmp_ext(self, a): self._b(0x7E); self._w(a)
def jsr_ext(self, a): self._b(0xBD); self._w(a)
def jmp_ind(self, a): self._b(0x6E, 0x9F); self._w(a) # JMP [addr] (indirect)
# ---- indexed auto-increment: LDA ,R+ / STA ,R+ ----
def lda_postinc(self, r): self._b(0xA6, _IDX[r])
def sta_postinc(self, r): self._b(0xA7, _IDX[r])
def ldb_postinc(self, r): self._b(0xE6, _IDX[r])
def stb_postinc(self, r): self._b(0xE7, _IDX[r])
# ---- 8-bit relative branches ----
def _branch(self, op, label):
self._b(op)
self._fix.append((len(self.code), label))
self._b(0x00) # placeholder
def bra(self, label): self._branch(0x20, label)
def bne(self, label): self._branch(0x26, label)
def beq(self, label): self._branch(0x27, label)
def bpl(self, label): self._branch(0x2A, label)
def bmi(self, label): self._branch(0x2B, label)
def bcc(self, label): self._branch(0x24, label)
def bcs(self, label): self._branch(0x25, label)
def resolve(self) -> bytes:
for pos, label in self._fix:
target = self.labels[label]
rel = target - (self.base + pos + 1)
if not -128 <= rel <= 127:
raise ValueError(f"branch to {label} out of range ({rel})")
self.code[pos] = rel & 0xFF
return bytes(self.code)

69
lenser/coco/palette.py Normal file
View file

@ -0,0 +1,69 @@
"""TRS-80 Color Computer MC6847 VDG palette + pixel packing.
PMODE 4 (256x192) is 2-colour: black + the foreground of the selected colour set
(CSS=0 green, CSS=1 "buff" ~ off-white). We use CSS=1 (buff on black) for clean
monochrome, like Apple HGR mono.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# Approximate sRGB for the MC6847 colours.
BLACK = (0, 0, 0)
BUFF = (255, 255, 255)
GREEN = (38, 194, 64)
YELLOW = (255, 240, 112)
BLUE = (40, 62, 211)
RED = (180, 38, 40)
CYAN = (52, 198, 160)
MAGENTA = (200, 70, 180)
ORANGE = (224, 116, 36)
# PMODE 4 monochrome (CSS=1): index 0 = black, 1 = buff.
MONO = np.array([BLACK, BUFF], dtype=np.float64)
# PMODE 3 (CG6) 4-colour sets, selected by the CSS bit. The 2-bit pixel value
# 0..3 indexes the set. VDG byte ($FF22) high nibble = E (A/G,GM2,GM1=1, GM0=0).
PMODE3_SETS = {
0xE0: np.array([GREEN, YELLOW, BLUE, RED], dtype=np.float64), # CSS=0
0xE8: np.array([BUFF, CYAN, MAGENTA, ORANGE], dtype=np.float64), # CSS=1
}
def mono_lab() -> np.ndarray:
return srgb_to_lab(MONO)
def pack_pmode3(val: np.ndarray) -> bytes:
"""Pack a (192,128) 0..3 array into 6144 bytes, 4 pixels/byte (2bpp),
leftmost pixel in the high bits."""
h, w = val.shape
out = bytearray(w // 4 * h)
k = 0
for y in range(h):
row = val[y]
for x in range(0, w, 4):
out[k] = ((row[x] & 3) << 6) | ((row[x + 1] & 3) << 4) | \
((row[x + 2] & 3) << 2) | (row[x + 3] & 3)
k += 1
return bytes(out)
def pack_pmode4(idx: np.ndarray) -> bytes:
"""Pack a (192,256) 0/1 array into 6144 bytes, 8 pixels/byte, bit7 leftmost,
1 = foreground (the VDG reads this straight from video RAM)."""
h, w = idx.shape
out = bytearray(w // 8 * h)
k = 0
for y in range(h):
row = idx[y]
for x in range(0, w, 8):
b = 0
for i in range(8):
b = (b << 1) | (1 if row[x + i] else 0)
out[k] = b
k += 1
return bytes(out)

71
lenser/coco/viewer.py Normal file
View file

@ -0,0 +1,71 @@
"""Generates the CoCo cartridge viewer (Motorola 6809 machine code).
Runs from the Program Pak ROM at $C000 (the CoCo autostarts it). Sets the VDG
graphics mode via PIA1 $FF22 and the SAM video registers (PMODE 3 and 4 are both
6144-byte SAM mode 6; only the $FF22 byte differs), copies the 6144-byte image
from cart ROM down to the video page at $0E00, then holds it.
display: forever (hold), key (poll the keyboard then reset), seconds (count 60 Hz
field-syncs then reset). A Program Pak can't cleanly return to BASIC, so key and
seconds reset the machine (which re-displays the picture).
"""
from __future__ import annotations
from .mc6809 import Asm
CART_BASE = 0xC000
VIDEO = 0x0E00
SCREEN_BYTES = 6144
RATE = 60 # NTSC field-syncs per second
def build(data_src: int, vdg: int = 0xF8, display: str = "forever",
seconds: int = 0) -> bytes:
a = Asm(CART_BASE)
a.orcc(0x50) # mask IRQ + FIRQ; we run standalone
a.lda_imm(vdg)
a.sta_ext(0xFF22) # VDG graphics mode (PMODE 3 = $E0/$E8, PMODE 4 = $F8)
# SAM video mode 6 (V2=1, V1=1, V0=0). SAM regs toggle by address; data ignored.
a.sta_ext(0xFFC5) # V2 set
a.sta_ext(0xFFC3) # V1 set
a.sta_ext(0xFFC0) # V0 clear
# SAM video offset = 7 ($0E00): F0,F1,F2 set; F3..F6 clear.
a.sta_ext(0xFFC7); a.sta_ext(0xFFC9); a.sta_ext(0xFFCB)
a.sta_ext(0xFFCC); a.sta_ext(0xFFCE); a.sta_ext(0xFFD0); a.sta_ext(0xFFD2)
# copy SCREEN_BYTES from cart ROM (data_src) to the video page
a.ldx_imm(data_src)
a.ldu_imm(VIDEO)
a.label("copy")
a.lda_postinc("x")
a.sta_postinc("u")
a.cmpu_imm(VIDEO + SCREEN_BYTES)
a.bne("copy")
# ---- hold the picture ----
if display == "key":
a.clra()
a.sta_ext(0xFF02) # drive all keyboard columns low
a.label("kwait")
a.lda_ext(0xFF00) # row sense
a.ora_imm(0x80) # ignore the joystick-compare bit 7
a.cmpa_imm(0xFF)
a.beq("kwait") # all rows high -> no key, keep waiting
a.jmp_ind(0xFFFE) # reset
elif display == "seconds":
a.ldd_imm(max(1, min(0xFFFF, int(seconds) * RATE)))
a.label("swait")
a.lda_ext(0xFF03) # PIA0 CB1 (field-sync) status
a.bpl("swait") # bit 7 clear -> no new field yet
a.lda_ext(0xFF02) # read PB data to clear the field-sync flag
a.subd_imm(1)
a.bne("swait")
a.jmp_ind(0xFFFE) # reset
else: # forever
a.label("hang")
a.bra("hang")
return a.resolve()