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

@ -0,0 +1 @@
"""TI-99/4A (TMS9918A) image conversion and cartridge export."""

92
lenser/ti99/cartridge.py Normal file
View file

@ -0,0 +1,92 @@
"""Builds a TI-99/4A 8KB cartridge ROM (>6000-7FFF) holding the viewer + image
data, and packages it as an RPK (the cartridge container MAME loads).
ROM layout
>6000 standard cartridge header (>AA magic, pointer to the program list)
>6010 program-list entry -> appears on the TI menu, points at the viewer
.... viewer machine code (TMS9900)
.... image data (6144-byte pattern + 768-byte cell colours)
pad to 8192 bytes
"""
from __future__ import annotations
import io
import zipfile
from . import viewer
CART_BASE = 0x6000
ROM_SIZE = 0x2000 # 8 KB
PROG_LIST = 0x6010
def _ascii(name: str, limit: int) -> bytes:
"""TI menu names are uppercase ASCII; strip anything else."""
out = "".join(c for c in name.upper() if 32 <= ord(c) < 127)
return out[:limit].encode("ascii") or b"PHOTO"
def _even(x: int) -> int:
return x + (x & 1)
def build_rom(data: bytes, title: str = "PHOTO", display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
if len(data) != viewer.PATTERN_BYTES + viewer.NCELLS:
raise ValueError(f"unexpected data length {len(data)}")
name = _ascii(title, 16)
vk = dict(display=display, seconds=seconds, video=video)
entry = _even(PROG_LIST + 2 + 2 + 1 + len(name)) # code starts after the name
code = viewer.build(entry, 0, **vk) # pass 1: measure length
data_addr = _even(entry + len(code))
code = viewer.build(entry, data_addr, **vk) # pass 2: real data address
if data_addr - CART_BASE + len(data) > ROM_SIZE:
raise ValueError("image + viewer exceed 8KB cartridge")
rom = bytearray(b"\x00" * ROM_SIZE)
def put(addr, payload):
off = addr - CART_BASE
rom[off:off + len(payload)] = payload
def putw(addr, word):
put(addr, bytes([(word >> 8) & 0xFF, word & 0xFF]))
# cartridge header
rom[0] = 0xAA # valid
rom[1] = 0x01 # version
putw(0x6006, PROG_LIST)
# program-list entry (single item)
putw(PROG_LIST + 0, 0x0000) # no next entry
putw(PROG_LIST + 2, entry) # viewer entry point
rom[PROG_LIST + 4 - CART_BASE] = len(name)
put(PROG_LIST + 5, name)
put(entry, code)
put(data_addr, data)
return bytes(rom)
def write_rpk(rom: bytes, path: str, title: str = "photo"):
"""Write an MAME RPK (zip with a standard single-ROM layout)."""
binname = "viewer.bin"
layout = (
'<?xml version="1.0" encoding="utf-8"?>\n'
'<romset version="1.0">\n'
' <resources>\n'
f' <rom id="romimage" file="{binname}"/>\n'
' </resources>\n'
' <configuration>\n'
' <pcb type="standard">\n'
' <socket id="rom_socket" uses="romimage"/>\n'
' </pcb>\n'
' </configuration>\n'
'</romset>\n'
)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr(binname, rom)
z.writestr("layout.xml", layout)

View file

@ -0,0 +1,16 @@
"""TI-99/4A conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import gm2, mono
_MODULES = {"gm2": gm2, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="gm2", palette_name="tms9918",
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, gm2)
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,72 @@
"""TI-99/4A Graphics Mode 2: 256x192, 2 colours per 8x8 cell (15-colour palette).
Like C64 hires but on the TMS9918A. Produces the bitmap pattern table (6144 B)
and one colour byte per cell (768 B); the cartridge viewer expands each cell's
colour across its 8 rows of the VDP colour table.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as tpal
WIDTH, HEIGHT = 256, 192
CELL_W, CELL_H = 8, 8
PIXEL_ASPECT = 1.0
N_COLS, N_ROWS = 32, 24
def convert(img_rgb, palette_name="tms9918", dither_mode="floyd",
intensive=False, base_color=None):
plab = tpal.palette_lab()
prgb = tpal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
# Dither-aware colour selection for error-diffusion modes -- each cell's two
# colours are chosen so the segment between them brackets the cell, letting
# dithering blend to the true shade (far smoother than nearest-colour, which
# bands). Ordered/none keep plain nearest-colour selection.
if dither_mode in base.DIFFUSION_DITHERS:
sets, _ = base.select_cell_sets_dither(cells, plab, tpal.USABLE, n_free=2)
else:
dist = base.cell_distance(cells, plab)
sets, _ = base.select_cell_sets(dist, tpal.USABLE, n_free=2)
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
pattern, colors = _encode(idx, sets, rows, cols)
data = bytes(pattern) + bytes(colors) # 6144 + 768
return base.Conversion(
mode="gm2", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="gm2", preview_rgb=prgb[idx],
error=base.perceptual_error(idx, img_lab, plab),
meta={"palette": "tms9918", "dither": dither_mode},
)
def _encode(idx, sets, rows, cols):
pattern = np.zeros(6144, dtype=np.uint8) # 768 cells x 8 rows
colors = np.zeros(768, dtype=np.uint8) # 1 colour byte per cell
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
c0, c1 = int(sets[ci, 0]), int(sets[ci, 1])
# brighter colour = foreground (bit 1); store fg in high nibble.
bg, fg = (c0, c1)
colors[ci] = ((fg & 0x0F) << 4) | (bg & 0x0F)
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
base = ci * 8
for r in range(8):
row = block[r]
byte = 0
for x in range(8):
byte = (byte << 1) | (1 if row[x] == fg else 0)
pattern[base + r] = byte
return pattern, colors

View file

@ -0,0 +1,44 @@
"""TI-99/4A (TMS9918A) monochrome / tinted-mono mode.
Same 256x192, 2-colours-per-cell format as gm2, but matched by *luminance* to a
grey ramp (black -> grey -> white) so every cell is neutral -- no colour clash,
maximum perceived detail, a clean greyscale photo. Pick a base colour for a
tinted monochrome instead. Reuses the gm2 byte packing and viewer.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert import base
from .. import palette as tpal
from . import gm2
WIDTH, HEIGHT = gm2.WIDTH, gm2.HEIGHT
CELL_W, CELL_H = gm2.CELL_W, gm2.CELL_H
PIXEL_ASPECT = gm2.PIXEL_ASPECT
# Neutral ramp: black(1), grey(14), white(15). Lighter siblings for tinting.
NEUTRAL = [1, 14, 15]
SIBLINGS = {2: 3, 3: 2, 4: 5, 5: 4, 6: 9, 8: 9, 9: 8, 12: 14, 13: 9}
def convert(img_rgb, palette_name="tms9918", dither_mode="atkinson",
intensive=False, base_color=None):
plab = tpal.palette_lab()
prgb = tpal.get_palette().astype(np.uint8)
ramp = base.luminance_ramp(plab, NEUTRAL, base_color, SIBLINGS)
idx, sets, rows, cols, err = base.mono_render(
img_rgb, plab, ramp, WIDTH, HEIGHT, CELL_W, CELL_H, dither_mode, n_free=2)
pattern, colors = gm2._encode(idx, sets, rows, cols)
data = bytes(pattern) + bytes(colors)
return base.Conversion(
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=0,
viewer="gm2", preview_rgb=prgb[idx], error=err,
meta={"palette": "tms9918", "dither": dither_mode, "base_color": base_color},
)

15
lenser/ti99/exporter.py Normal file
View file

@ -0,0 +1,15 @@
"""Build a TI-99/4A cartridge (.rpk) from a conversion."""
from __future__ import annotations
import os
from . import cartridge
def export_rpk(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(".rpk"):
output_path += ".rpk"
title = os.path.splitext(os.path.basename(source_path or output_path))[0]
rom = cartridge.build_rom(conv.data, title, display=display,
seconds=seconds, video=video)
cartridge.write_rpk(rom, output_path, title)
return output_path

39
lenser/ti99/palette.py Normal file
View file

@ -0,0 +1,39 @@
"""TI-99/4A TMS9918A Video Display Processor palette (15 colours + transparent)."""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
# TMS9918A colours, index 0 = transparent (we never use it for pixels).
# Common measured RGB values.
TMS9918 = np.array([
(0x00, 0x00, 0x00), # 0 transparent (treated as black for matching)
(0x00, 0x00, 0x00), # 1 black
(0x21, 0xc8, 0x42), # 2 medium green
(0x5e, 0xdc, 0x78), # 3 light green
(0x54, 0x55, 0xed), # 4 dark blue
(0x7d, 0x76, 0xfc), # 5 light blue
(0xd4, 0x52, 0x4d), # 6 dark red
(0x42, 0xeb, 0xf5), # 7 cyan
(0xfc, 0x55, 0x54), # 8 medium red
(0xff, 0x79, 0x78), # 9 light red
(0xd4, 0xc1, 0x54), # 10 dark yellow
(0xe6, 0xce, 0x80), # 11 light yellow
(0x21, 0xb0, 0x3b), # 12 dark green
(0xc9, 0x5b, 0xba), # 13 magenta
(0xcc, 0xcc, 0xcc), # 14 grey
(0xff, 0xff, 0xff), # 15 white
], dtype=np.float64)
# Palette indices usable as pixel colours (1..15).
USABLE = list(range(1, 16))
def get_palette() -> np.ndarray:
return TMS9918
def palette_lab() -> np.ndarray:
return srgb_to_lab(TMS9918)

87
lenser/ti99/tms9900.py Normal file
View file

@ -0,0 +1,87 @@
"""A tiny TMS9900 machine-code emitter (just the instructions the TI-99 viewer
needs). Words are big-endian. Supports labels + relative-jump backpatching so
the viewer's loops can be written readably.
This is the TI analogue of using `xa` for the 6502 viewers, but since no TMS9900
assembler is installed we emit the opcodes directly.
"""
from __future__ import annotations
class Asm:
def __init__(self, base: int):
self.base = base
self.code = bytearray()
self.labels: dict[str, int] = {}
self._jfix: list[tuple[int, str]] = [] # (pos, label) for 8-bit jump disp
# ---- low level ----
def pos(self) -> int:
return self.base + len(self.code)
def w(self, word: int):
self.code += bytes([(word >> 8) & 0xFF, word & 0xFF]) # big-endian
def label(self, name: str):
self.labels[name] = self.pos()
# ---- immediate-format (format VIII) ----
def li(self, reg, imm): self.w(0x0200 | reg); self.w(imm & 0xFFFF)
def ai(self, reg, imm): self.w(0x0220 | reg); self.w(imm & 0xFFFF)
def ci(self, reg, imm): self.w(0x0280 | reg); self.w(imm & 0xFFFF)
def limi(self, imm): self.w(0x0300); self.w(imm & 0xFFFF)
def lwpi(self, imm): self.w(0x02E0); self.w(imm & 0xFFFF)
# ---- single-register (format VI) ----
def clr(self, reg): self.w(0x04C0 | reg)
def inc(self, reg): self.w(0x0580 | reg)
def inct(self, reg): self.w(0x05C0 | reg)
def dec(self, reg): self.w(0x0600 | reg)
def dect(self, reg): self.w(0x0640 | reg)
def swpb(self, reg): self.w(0x06C0 | reg)
# ---- two-operand (format I); modes: 0=reg,1=*reg,2=@addr(reg),3=*reg+ ----
def _fmt1(self, base, td, dreg, ts, sreg, saddr=None, daddr=None):
self.w(base | ((td & 3) << 10) | ((dreg & 15) << 6)
| ((ts & 3) << 4) | (sreg & 15))
if ts == 2:
self.w(saddr & 0xFFFF)
if td == 2:
self.w(daddr & 0xFFFF)
def mov_rr(self, s, d): self._fmt1(0xC000, 0, d, 0, s)
def movb_r_sym(self, s, addr): self._fmt1(0xD000, 2, 0, 0, s, daddr=addr)
def movb_sym_r(self, addr, d): self._fmt1(0xD000, 0, d, 2, 0, saddr=addr)
def movb_sinc_sym(self, s, addr): self._fmt1(0xD000, 2, 0, 3, s, daddr=addr)
def movb_sinc_r(self, s, d): self._fmt1(0xD000, 0, d, 3, s)
def movb_r_r(self, s, d): self._fmt1(0xD000, 0, d, 0, s)
# ---- immediate logic / context switch ----
def andi(self, reg, imm): self.w(0x0240 | reg); self.w(imm & 0xFFFF)
def ori(self, reg, imm): self.w(0x0260 | reg); self.w(imm & 0xFFFF)
def blwp_sym(self, addr): self.w(0x0420); self.w(addr & 0xFFFF) # BLWP @addr
# ---- CRU (keyboard scan): R12 holds the CRU base ----
def ldcr(self, reg, count): self.w(0x3000 | ((count & 15) << 6) | reg)
def stcr(self, reg, count): self.w(0x3400 | ((count & 15) << 6) | reg)
# ---- jumps (format II), 8-bit signed displacement ----
def _jump(self, opbase, label):
self._jfix.append((len(self.code), label))
self.w(opbase) # disp filled in by resolve()
def jmp(self, label): self._jump(0x1000, label)
def jeq(self, label): self._jump(0x1300, label)
def jne(self, label): self._jump(0x1600, label)
# ---- finish ----
def resolve(self) -> bytes:
for pos, label in self._jfix:
target = self.labels[label]
here = self.base + pos # address of the jump word
disp = (target - (here + 2)) // 2
if not -128 <= disp <= 127:
raise ValueError(f"jump to {label} out of range ({disp})")
self.code[pos + 1] = disp & 0xFF
return bytes(self.code)

116
lenser/ti99/viewer.py Normal file
View file

@ -0,0 +1,116 @@
"""Generates the TI-99/4A cartridge viewer (TMS9900 machine code).
Sets the TMS9918A to Graphics Mode 2 (256x192 bitmap), builds the name table,
copies the 6144-byte pattern from cartridge ROM to VRAM >0000, and expands the
768 per-cell colour bytes x8 into the 6144-byte colour table at VRAM >2000.
"""
from __future__ import annotations
from .tms9900 import Asm
VDP_DATA = 0x8C00 # write VRAM data
VDP_CTRL = 0x8C02 # write address / register
VDP_STATUS = 0x8802 # read VDP status (bit 7 = frame flag, cleared on read)
WORKSPACE = 0x8300 # scratchpad RAM
# VDP register values for Graphics Mode 2.
VDP_REGS = [
0x02, # R0 M3=1 (bitmap)
0xC0, # R1 16K + display on, interrupts off
0x0E, # R2 name table >3800
0xFF, # R3 colour table >2000 (full 768 rows)
0x03, # R4 pattern table >0000 (full 768 rows)
0x36, # R5 sprite attr >1B00 (parked)
0x07, # R6 sprite patt >3800 (parked)
0x01, # R7 backdrop = black
]
PATTERN_BYTES = 6144
NCELLS = 768
def _set_write_addr(a: Asm, addr: int):
"""Point the VDP write address at VRAM `addr` (low byte then high|>40)."""
a.li(0, (addr & 0xFF) << 8); a.movb_r_sym(0, VDP_CTRL)
a.li(0, (((addr >> 8) | 0x40) & 0xFF) << 8); a.movb_r_sym(0, VDP_CTRL)
def build(code_base: int, data_addr: int, display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
"""Emit the viewer. `data_addr` = ROM address of the 6912-byte image data.
`display` (forever/key/seconds) chooses how long the picture is held; on
key/seconds the console is reset (BLWP @>0000) back to the TI title screen.
"""
a = Asm(code_base)
a.limi(0x0000) # interrupts off
a.lwpi(WORKSPACE) # our register file in scratchpad
# ---- programme the 8 VDP registers ----
for reg, val in enumerate(VDP_REGS):
a.li(0, val << 8)
a.movb_r_sym(0, VDP_CTRL)
a.li(0, (0x80 | reg) << 8)
a.movb_r_sym(0, VDP_CTRL)
# ---- name table >3800 = 0,1,...,255 repeated three times (768 bytes) ----
_set_write_addr(a, 0x3800)
a.clr(3) # R3 high byte = current value
a.li(2, NCELLS) # 768 entries
a.label("nameloop")
a.movb_r_sym(3, VDP_DATA)
a.ai(3, 0x0100) # value += 1 (in high byte, wraps mod 256)
a.dec(2)
a.jne("nameloop")
# ---- pattern table >0000 = 6144 bytes copied from ROM ----
_set_write_addr(a, 0x0000)
a.li(1, data_addr)
a.li(2, PATTERN_BYTES)
a.label("patloop")
a.movb_sinc_sym(1, VDP_DATA)
a.dec(2)
a.jne("patloop")
# ---- colour table >2000 = each cell colour written 8x (768 -> 6144) ----
_set_write_addr(a, 0x2000)
a.li(1, data_addr + PATTERN_BYTES)
a.li(2, NCELLS)
a.label("colloop")
a.movb_sinc_r(1, 4) # R4 high byte = colour byte, advance ROM ptr
for _ in range(8):
a.movb_r_sym(4, VDP_DATA)
a.dec(2)
a.jne("colloop")
# ---- how long to hold the picture ----
if display == "seconds":
rate = 50 if video == "pal" else 60
frames = max(1, min(0xFFFF, int(seconds) * rate))
a.li(2, frames) # R2 = frames to wait
a.label("fwait")
a.movb_sym_r(VDP_STATUS, 1) # read status (clears frame flag)
a.andi(1, 0x8000) # bit 7 = a new frame elapsed
a.jeq("fwait")
a.dec(2)
a.jne("fwait")
a.blwp_sym(0x0000) # reset -> TI title screen
elif display == "key":
# scan keyboard columns 0..5; any pressed key (a row reads 0) -> reset.
a.label("kscan")
for col in range(6):
a.li(12, 0x0024) # CRU base for the column select
a.li(5, col << 8) # column in the low 3 bits of the high byte
a.ldcr(5, 3) # drive the 3 column-select lines
a.li(12, 0x0006) # CRU base for the 8 row inputs
a.stcr(6, 8) # read rows into R6 high byte (idle = >FF)
a.ci(6, 0xFF00) # any key down makes a row 0
a.jne("kdone")
a.jmp("kscan")
a.label("kdone")
a.blwp_sym(0x0000) # reset -> TI title screen
else: # forever
a.label("halt")
a.jmp("halt")
return a.resolve()