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

2
lenser/intv/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""Mattel Intellivision target: STIC Color-Stack image viewer on a clean
CP1610 cartridge (no copyrighted EXEC/game data)."""

172
lenser/intv/cartridge.py Normal file
View file

@ -0,0 +1,172 @@
"""Builds a clean Mattel Intellivision cartridge ROM (maps at $5000) holding a
CP1610 viewer plus the encoded image, with NO copyrighted EXEC/game data.
ROM layout (4096 16-bit words, big-endian; loose-file extension ``.int`` /
``.bin`` / ``.rom`` -- MAME maps a headerless dump at $5000):
$5000 six BIDECLE header pointers + flags word
MOB / process / bkgnd -> a $0000 word (empty, tolerated by the EXEC)
start -> MAIN
GRAM -> [$0001, 0x8] (1 blank card -- minimal list
that does NOT crash the EXEC's loader; the
ISR re-uploads the real tiles anyway)
title -> (year-1900) word, ASCII, 0
$5040 MAIN (install ISR, fill BACKTAB from the card table, then idle/duration)
.... ISR (display handshake $0020, colour stack, GRAM upload)
.... card table (240 words) and GRAM tile table (512 words)
"""
from __future__ import annotations
import struct
from .cp1610 import Asm
ROM_WORDS = 4096
EMPTY = 0x500D
TITLE = 0x500E
GRAMLIST = 0x5018
MAIN = 0x5040
CARDROM = 0x5100
GRAMROM = 0x5200
# 8-bit Scratch RAM ($0100-$01EF) is separate from the EXEC stack (which roams
# 16-bit System RAM $02F0+), so it is safe for our persistent ISR state.
CHUNKCTR = 0x0108 # GRAM-upload progress (0..8 chunks of 64 words)
FRAMELO = 0x0109 # display-duration frame counter (lo byte)
FRAMEHI = 0x010A # ... hi byte
N_CHUNKS = 8 # 8 x 64 = 512 GRAM words
HAND_R = 0x01FF # right hand controller (PSG I/O port)
def _bidecle(put, addr, target):
put(addr, target & 0xFF)
put(addr + 1, (target >> 8) & 0xFF)
def build_rom(data: dict, title: str = "PHOTO", display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
gram = [int(w) & 0xFFFF for w in data["gram"]] # 512 words
cards = [int(w) & 0xFFFF for w in data["cards"]] # 240 words
rom = [0] * ROM_WORDS
def put(a, w):
rom[a - 0x5000] = w & 0xFFFF
# ---- header ----
_bidecle(put, 0x5000, EMPTY) # MOB
_bidecle(put, 0x5002, EMPTY) # process
_bidecle(put, 0x5004, MAIN) # program start
_bidecle(put, 0x5006, EMPTY) # background
_bidecle(put, 0x5008, GRAMLIST) # GRAM
_bidecle(put, 0x500A, TITLE) # title
put(0x500C, 0x0000) # flags
put(EMPTY, 0x0000)
for i, w in enumerate([0x0001, 0, 0, 0, 0, 0, 0, 0, 0]):
put(GRAMLIST + i, w)
# ---- title block ----
a = TITLE
put(a, 84) # year - 1900 (cosmetic)
a += 1
name = "".join(c for c in title.upper() if 32 <= ord(c) < 127)[:14] or "PHOTO"
for ch in name:
put(a, ord(ch))
a += 1
put(a, 0)
# ---- data tables ----
for i, w in enumerate(cards):
put(CARDROM + i, w)
for i, w in enumerate(gram):
put(GRAMROM + i, w)
# ---- code ----
words, isr = _assemble(display, seconds, video)
for i, w in enumerate(words):
put(MAIN + i, w)
return b"".join(struct.pack(">H", w) for w in rom)
def _duration_loop(z, display, seconds, fps):
"""Emit the post-setup idle/duration loop."""
nframes = max(1, int(seconds) * fps)
if display == "key":
z.label('loop')
z.mvi(HAND_R, 0); z.cmpi(0xFF, 0); z.bnze('redisplay')
z.b('loop')
z.label('redisplay'); z.j(MAIN)
elif display == "seconds":
hi, lo = (nframes >> 8) & 0xFF, nframes & 0xFF
z.label('loop')
z.mvi(FRAMEHI, 0); z.cmpi(hi, 0); z.bnze('chkhi')
z.mvi(FRAMELO, 0); z.cmpi(lo, 0); z.bc('redisplay')
z.b('loop')
z.label('chkhi'); z.bc('redisplay')
z.b('loop')
z.label('redisplay'); z.j(MAIN)
else:
z.label('loop'); z.b('loop')
def _seconds_tick(z):
z.mvi(FRAMELO, 1); z.incr(1); z.andi(0xFF, 1); z.mvo(1, FRAMELO)
z.bnze('noco'); z.mvi(FRAMEHI, 1); z.incr(1); z.mvo(1, FRAMEHI)
z.label('noco')
def _gram_chunk(z, gramrom):
"""ISR fragment: upload one 64-word GRAM chunk/frame over 8 frames."""
z.mvi(CHUNKCTR, 2)
z.cmpi(N_CHUNKS, 2); z.bc('grdone')
z.movr(2, 3); z.sll(3, 2); z.sll(3, 2); z.sll(3, 2) # R3 = chunk * 64
z.movr(3, 4); z.addi(0x3800, 4)
z.movr(3, 5); z.addi(gramrom, 5)
z.mvii(64, 1)
z.label('up'); z.mvi_at(5, 0); z.mvo_at(0, 4); z.decr(1); z.bnze('up')
z.incr(2); z.mvo(2, CHUNKCTR)
z.label('grdone')
def _patch_isr(z):
"""Patch the two MVII immediates that install the ISR vector at $0100/$0101."""
words = z.resolve()
isr = z.labels['isr']
words[2] = isr & 0xFF
words[6] = (isr >> 8) & 0xFF
return words, isr
def _assemble(display, seconds, video):
"""Emit MAIN + ISR for the flicker-free FGBG viewer. Returns (words, isr).
GRAM (512 words) is far too big to upload in one VBLANK (~3500 cycles), so
the ISR uploads it 64 words at a time over 8 frames, tracking progress in
Scratch RAM; once done the cards reference the fully-loaded tiles.
"""
fps = 50 if video == "pal" else 60
z = Asm(MAIN)
z.dis()
z.mvii(0, 0); z.mvo(0, 0x0100) # install ISR (words 2,6 = imm lo/hi)
z.mvii(0, 0); z.mvo(0, 0x0101)
z.mvii(0, 0)
z.mvo(0, CHUNKCTR); z.mvo(0, FRAMELO); z.mvo(0, FRAMEHI)
z.mvii(0x0200, 4); z.mvii(CARDROM, 5); z.mvii(240, 1)
z.label('fill'); z.mvi_at(5, 0); z.mvo_at(0, 4); z.decr(1); z.bnze('fill')
z.eis()
_duration_loop(z, display, seconds, fps)
# ---- ISR ----
# GRAM writes are VBLANK-only and writing $0020 (display enable) closes that
# window, so upload FIRST and handshake LAST. Writing $0021 selects FGBG mode
# (each card carries its own fg+bg); never READ $0021 (-> Color-Stack mode).
z.label('isr')
z.pshr(5)
_gram_chunk(z, GRAMROM)
if display == "seconds":
_seconds_tick(z)
z.mvii(0, 0); z.mvo(0, 0x0021) # select FGBG mode
z.mvii(1, 0); z.mvo(0, 0x0020) # display handshake LAST
z.pulr(5); z.jr(5)
return _patch_isr(z)

View file

@ -0,0 +1,19 @@
"""Mattel Intellivision conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import mono, stic
_MODULES = {"stic": stic, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="stic", palette_name="stic",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, stic)
img_rgb = imageprep.prepare(path_or_img, stic.WIDTH, stic.HEIGHT,
stic.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,87 @@
"""Mattel Intellivision monochrome / tinted-mono mode.
160x96 matched by luminance to two greys (black + white, or black + a tinted base
colour). With the colours fixed, the whole image budget goes into the 64-tile
GRAM shapes, which are optimised by the same vector-quantisation reshape/reassign
used by the colour encoder -- a clean two-tone Intellivision picture.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as ipal
from . import stic
BLACK = 0
WHITE = 7 # white is a foreground-legal colour (0-7)
def convert(img_rgb, palette_name="stic", dither_mode="none",
intensive=False, base_color=None):
# mono is carried entirely by dithering, so it needs error diffusion -- the
# colour mode's "none" default would give a stark, high-contrast result.
if dither_mode not in base.DIFFUSION_DITHERS:
dither_mode = "floyd"
plab = ipal.palette_lab()
prgb = ipal.get_palette().astype(np.uint8)
fg = base_color if base_color in range(1, 8) else WHITE
bg = BLACK
# luminance image + luminance-only palette
L = c64pal.srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros((stic.HEIGHT, stic.WIDTH, 3))
img_mono[..., 0] = L
plab_mono = np.zeros_like(plab)
plab_mono[:, 0] = plab[:, 0]
cells, rows, cols = base.cells_lab(img_mono, stic.CELL_W, stic.CELL_H)
sets = np.tile(np.array([bg, fg], np.int64), (stic.N_ROWS * stic.N_COLS, 1))
allowed = base.per_pixel_allowed(sets, rows, cols, stic.CELL_W, stic.CELL_H,
stic.HEIGHT, stic.WIDTH)
idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
bitmaps = np.zeros((stic.N_ROWS * stic.N_COLS, 64), np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
bitmaps[ci] = (block == fg).astype(np.uint8).reshape(-1)
tiles, labels = base.mono_codebook(bitmaps, stic.N_TILES)
prev_idx = np.empty((stic.HEIGHT, stic.WIDTH), np.uint8)
cards = np.zeros(stic.N_ROWS * stic.N_COLS, np.uint16)
for ci in range(stic.N_ROWS * stic.N_COLS):
cr, cc = divmod(ci, cols)
tile = tiles[labels[ci]].reshape(8, 8)
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(tile == 1, fg, bg)
cards[ci] = (fg & 0x07) | stic.GRAM_BIT | ((labels[ci] & 0x3F) << 3) \
| stic._bg_bits(bg)
gram = np.zeros(stic.N_TILES * 8, np.uint16)
for t in range(stic.N_TILES):
rb = tiles[t].reshape(8, 8)
for r in range(8):
byte = 0
for x in range(8):
byte = (byte << 1) | int(rb[r, x])
gram[t * 8 + r] = byte
data = {"gram": gram, "cards": cards}
preview = prgb[prev_idx]
disp_w = int(round(stic.WIDTH * stic.PIXEL_ASPECT))
xs = (np.arange(disp_w) * stic.WIDTH) // disp_w
preview = preview[:, xs]
return base.Conversion(
mode="mono", width=stic.WIDTH, height=stic.HEIGHT,
pixel_aspect=stic.PIXEL_ASPECT, index_image=prev_idx.astype(np.uint16),
data=data, data_addr=0, viewer="stic", preview_rgb=preview,
error=base.perceptual_error(prev_idx, img_mono, plab_mono),
meta={"palette": "stic", "dither": dither_mode, "base_color": base_color,
"mode": "fgbg"},
)

235
lenser/intv/convert/stic.py Normal file
View file

@ -0,0 +1,235 @@
"""Intellivision STIC Foreground/Background image encoder.
160x96 = 20x12 cells of 8x8. FGBG mode gives each cell TWO independent colours
(like C64 hires): a foreground from the 8 primary colours (0-7) and a background
from all 16. Each cell's 8x8 bitmap is drawn from a dictionary of 64 user tiles
(GRAM), so cell bitmaps are clustered to 64 patterns.
Card word (FGBG): FG(bits 0-2) | GRAMbit(0x800) | (tile<<3) | BG(bits 9,10,12,13).
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import (Conversion, cell_distance, cells_lab,
per_pixel_allowed, perceptual_error)
from .. import palette as ipal
WIDTH, HEIGHT = 160, 96
PIXEL_ASPECT = 1.2 # 160x96 shown on a ~4:3 screen -> pixels a touch tall
CELL_W, CELL_H = 8, 8
N_COLS, N_ROWS = 20, 12
N_TILES = 64
GRAM_BIT = 0x0800
N_FG = 8 # foreground limited to primary colours 0-7
def _bg_bits(c: int) -> int:
"""Pack a 0-15 background colour into FGBG card bits 9,10,12,13."""
return (((c >> 0) & 1) << 9) | (((c >> 1) & 1) << 10) | \
(((c >> 2) & 1) << 12) | (((c >> 3) & 1) << 13)
def _best_pairs(dist):
"""Per cell choose (fg in 0-7, bg in 0-15) minimising nearest-colour error.
dist: (n_cells, P, 16) squared distances. Returns (fg, bg) int arrays."""
n = dist.shape[0]
best_err = np.full(n, np.inf)
best_fg = np.zeros(n, np.int64)
best_bg = np.zeros(n, np.int64)
for fg in range(N_FG):
dfg = dist[:, :, fg]
for bg in range(16):
m = np.minimum(dfg, dist[:, :, bg])
err = m.sum(axis=1)
better = err < best_err
best_err[better] = err[better]
best_fg[better] = fg
best_bg[better] = bg
return best_fg, best_bg
def _cluster_tiles(bitmaps, k=N_TILES, iters=16, seed=0):
"""k-means on 64-dim {0,1} cell bitmaps -> k binary representative tiles."""
pats = bitmaps.astype(np.float64)
uniq, counts = np.unique(bitmaps, axis=0, return_counts=True)
if len(uniq) <= k:
tiles = np.zeros((k, 64), np.uint8)
tiles[:len(uniq)] = uniq
lut = {tuple(p): i for i, p in enumerate(uniq)}
labels = np.array([lut[tuple(b)] for b in bitmaps])
return tiles, labels
order = np.argsort(-counts)[:k]
cent = uniq[order].astype(np.float64)
rng = np.random.default_rng(seed)
for _ in range(iters):
d = ((pats[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
labels = d.argmin(1)
for j in range(k):
msk = labels == j
if msk.any():
cent[j] = pats[msk].mean(0)
else:
cent[j] = pats[rng.integers(len(pats))]
tiles = (cent >= 0.5).astype(np.uint8)
d = ((pats[:, None, :] - tiles[None, :, :].astype(np.float64)) ** 2).sum(-1)
labels = d.argmin(1)
return tiles, labels
def _recolor(dist, tiles, labels):
"""Given each cell's tile (shape), pick the (fg in 0-7, bg in 0-15) that
minimise reconstruction error. Returns (fg, bg, total_error)."""
n = dist.shape[0]
M = tiles[labels].astype(np.float64) # (n, P) 1=fg
fg_cost = np.einsum('np,npk->nk', M, dist) # (n,16) error on fg pixels
bg_cost = np.einsum('np,npk->nk', 1.0 - M, dist) # (n,16) error on bg pixels
fg = fg_cost[:, :N_FG].argmin(1)
bg = bg_cost.argmin(1)
rows = np.arange(n)
total = fg_cost[rows, fg].sum() + bg_cost[rows, bg].sum()
return fg.astype(np.int64), bg.astype(np.int64), float(total)
def _reassign(dist, tiles):
"""Assign each cell to the tile that (after optimal recolouring) minimises its
error. Returns labels (n,)."""
M = tiles.astype(np.float64) # (T, P)
fg_sum = np.einsum('tp,npk->ntk', M, dist) # (n,T,16)
bg_sum = np.einsum('tp,npk->ntk', 1.0 - M, dist)
cost = fg_sum[:, :, :N_FG].min(2) + bg_sum.min(2) # (n,T)
return cost.argmin(1)
def _reshape(dist, labels, fg, bg, n_tiles=N_TILES):
"""Recompute each tile's 8x8 mask from all cells using it: a pixel is fg where
that lowers total error across those cells (block-truncation coding)."""
n, P, _ = dist.shape
rows = np.arange(n)[:, None]
cols = np.arange(P)[None, :]
dfg = dist[rows, cols, fg[:, None]] # (n,P) error if pixel = fg
dbg = dist[rows, cols, bg[:, None]] # (n,P) error if pixel = bg
tiles = np.zeros((n_tiles, P), np.uint8)
# cells with the largest fg-vs-bg disagreement seed any empty tiles
worst = np.argsort(-(np.abs(dfg - dbg).sum(1)))
wi = 0
for t in range(n_tiles):
msk = labels == t
if msk.any():
tiles[t] = (dfg[msk].sum(0) < dbg[msk].sum(0)).astype(np.uint8)
else:
# reseed from an unused high-contrast cell so all 64 tiles get used
c = int(worst[wi % n]); wi += 1
tiles[t] = (dfg[c] < dbg[c]).astype(np.uint8)
return tiles
def _refine_once(dist, tiles, max_iters=40):
"""One Lloyd run from a given tile set to convergence. Returns
(tiles, labels, fg, bg, total_error)."""
best = None
best_err = np.inf
for _ in range(max_iters):
labels = _reassign(dist, tiles)
fg, bg, _ = _recolor(dist, tiles, labels)
tiles = _reshape(dist, labels, fg, bg)
fg, bg, err = _recolor(dist, tiles, labels)
if err < best_err - 1e-6:
best_err = err
best = (tiles.copy(), labels.copy(), fg.copy(), bg.copy())
else:
break # converged
return (*best, best_err)
def _refine(dist, tiles0, n_restarts):
"""Joint optimisation of the 64 tile shapes and the per-cell (tile, fg, bg).
Lloyd iteration has local minima, so run it from the clustered initialisation
plus several random tile sets and keep the globally lowest-error result -- the
one that reproduces the picture most faithfully within the 64-tile budget.
"""
P = dist.shape[1]
best = None
best_err = np.inf
rng = np.random.default_rng(0)
inits = [tiles0] + [rng.integers(0, 2, (N_TILES, P)).astype(np.uint8)
for _ in range(n_restarts)]
for t0 in inits:
tiles, labels, fg, bg, err = _refine_once(dist, t0)
if err < best_err:
best_err = err
best = (tiles, labels, fg, bg)
return best
def convert(img_rgb, palette_name="stic", dither_mode="floyd",
intensive=False, base_color=None):
plab = ipal.palette_lab()
prgb = ipal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
cells, rows, cols = cells_lab(img_lab, CELL_W, CELL_H)
dist = cell_distance(cells, plab)
fg, bg = _best_pairs(dist) # (240,) each
# dither each cell between its two colours (order [bg, fg] for per_pixel_allowed)
sets = np.stack([bg, fg], axis=1)
allowed = per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmaps = np.zeros((N_ROWS * N_COLS, 64), np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
bitmaps[ci] = (block == fg[ci]).astype(np.uint8).reshape(-1)
tiles, labels = _cluster_tiles(bitmaps)
# Joint vector-quantisation refinement: the one-shot select->cluster->recolour
# leaves the 64 shared tile shapes and the per-cell (tile, fg, bg) far from
# optimal. Alternately re-assign each cell to its best tile, re-pick the two
# colours, and re-cut every tile's shape (block-truncation coding) -- each step
# provably lowers total CIELAB error, so the picture converges much closer to
# the original within the 64-tile budget.
n_restarts = 16 if intensive else 4
refined = _refine(dist, tiles, n_restarts)
if refined is not None:
tiles, labels, fg, bg = refined
prev_idx = np.empty((HEIGHT, WIDTH), np.uint8)
cards = np.zeros(N_ROWS * N_COLS, np.uint16)
for ci in range(N_ROWS * N_COLS):
cr, cc = divmod(ci, cols)
tile = tiles[labels[ci]].reshape(8, 8)
f, b = int(fg[ci]), int(bg[ci])
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(tile == 1, f, b)
cards[ci] = (f & 0x07) | GRAM_BIT | ((labels[ci] & 0x3F) << 3) | _bg_bits(b)
gram = np.zeros(N_TILES * 8, np.uint16)
for t in range(N_TILES):
rb = tiles[t].reshape(8, 8)
for r in range(8):
byte = 0
for x in range(8):
byte = (byte << 1) | int(rb[r, x])
gram[t * 8 + r] = byte
data = {"gram": gram, "cards": cards}
# pre-widen preview to display resolution (matches atari/apple/a2600 convention)
preview = prgb[prev_idx]
disp_w = int(round(WIDTH * PIXEL_ASPECT))
xs = (np.arange(disp_w) * WIDTH) // disp_w
preview = preview[:, xs]
return Conversion(
mode="stic", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=prev_idx.astype(np.uint16), data=data, data_addr=0,
viewer="stic", preview_rgb=preview,
error=perceptual_error(prev_idx, img_lab, plab),
meta={"palette": "stic", "dither": dither_mode, "mode": "fgbg"},
)

125
lenser/intv/cp1610.py Normal file
View file

@ -0,0 +1,125 @@
"""A tiny General Instrument CP1610 machine-code emitter (Intellivision).
No CP1610 assembler is installed, so -- as with the TMS9900/Z80/6809 emitters --
we emit opcodes directly. Encodings validated by disassembling a real cart
(Astrosmash). Output is a list of 16-bit words (the CP1610 fetches 10-bit
"decles" from the low bits; the Intellivision ROM is 16-bit big-endian at $5000).
"""
from __future__ import annotations
class Asm:
def __init__(self, base: int):
self.base = base
self.words: list[int] = []
self.labels: dict[str, int] = {}
self._fix: list[tuple[int, str, str]] = [] # (word index, label, kind)
def pos(self) -> int:
return self.base + len(self.words)
def label(self, name: str):
self.labels[name] = self.pos()
def _w(self, *ws):
self.words.extend(w & 0xFFFF for w in ws)
# ---- implied ----
def sdbd(self): self._w(0x0001)
def eis(self): self._w(0x0002)
def dis(self): self._w(0x0003)
def tci(self): self._w(0x0005)
def clrc(self): self._w(0x0006)
def setc(self): self._w(0x0007)
def nop(self): self._w(0x0034)
# ---- single register ----
def incr(self, r): self._w(0x0008 | r)
def decr(self, r): self._w(0x0010 | r)
def comr(self, r): self._w(0x0018 | r)
def negr(self, r): self._w(0x0020 | r)
def adcr(self, r): self._w(0x0028 | r)
def tstr(self, r): self._w(0x0080 | (r << 3) | r) # MOVR r,r sets flags
# ---- register-register ----
# ---- shifts/rotates (R0-R3 only; n = 1 or 2 bits) ----
def sll(self, r, n=1): self._w(0x0048 | ((n - 1) << 2) | r) # shift left logical
def slr(self, r, n=1): self._w(0x0060 | ((n - 1) << 2) | r) # shift right logical
def movr(self, s, d): self._w(0x0080 | (s << 3) | d)
def addr(self, s, d): self._w(0x00C0 | (s << 3) | d)
def subr(self, s, d): self._w(0x0100 | (s << 3) | d)
def cmpr(self, s, d): self._w(0x0140 | (s << 3) | d)
def andr(self, s, d): self._w(0x0180 | (s << 3) | d)
def xorr(self, s, d): self._w(0x01C0 | (s << 3) | d)
def clrr(self, r): self._w(0x01C0 | (r << 3) | r) # XORR r,r
def jr(self, r): self._w(0x0080 | (r << 3) | 7) # MOVR r,R7 = jump
# ---- external reference (direct addr / immediate / indirect) ----
def mvi(self, addr, d): self._w(0x0280 | d, addr) # load (addr)->Rd
def mvo(self, s, addr): self._w(0x0240 | s, addr) # store Rs->(addr)
def mvii(self, imm, d): self._w(0x0280 | (7 << 3) | d, imm)
def addi(self, imm, d): self._w(0x02C0 | (7 << 3) | d, imm)
def subi(self, imm, d): self._w(0x0300 | (7 << 3) | d, imm)
def cmpi(self, imm, d): self._w(0x0340 | (7 << 3) | d, imm)
def andi(self, imm, d): self._w(0x0380 | (7 << 3) | d, imm)
def xori(self, imm, d): self._w(0x03C0 | (7 << 3) | d, imm)
def mvi_at(self, m, d): self._w(0x0280 | (m << 3) | d) # load (Rm)->Rd
def mvo_at(self, s, m): self._w(0x0240 | (m << 3) | s) # store Rs->(Rm)
def pshr(self, s): self._w(0x0240 | (6 << 3) | s) # MVO@ Rs,R6
def pulr(self, d): self._w(0x0280 | (6 << 3) | d) # MVI@ R6,Rd
# ---- jumps (absolute, 3 words) ----
def _jword(self, addr, reg):
self._w(0x0004, (reg << 8) | (((addr >> 10) & 0x3F) << 2), addr & 0x3FF)
def j(self, addr): self._jword(addr, 3) # J (no return)
def jsr(self, addr): self._jword(addr, 1) # JSR R5,addr
def j_label(self, label):
i = len(self.words)
self._w(0x0004, 0, 0)
self._fix.append((i, label, "j"))
def jsr_label(self, label):
i = len(self.words)
self._w(0x0004, 0, 0)
self._fix.append((i, label, "jsr"))
# ---- branches (opcode + displacement word) ----
def _branch(self, cond, label):
i = len(self.words)
self._w(0x0200 | cond, 0) # direction/disp filled by resolve
self._fix.append((i, label, "b"))
def b(self, label): self._branch(0x0, label)
def beq(self, label): self._branch(0x4, label)
def bnze(self, label): self._branch(0xC, label)
def bc(self, label): self._branch(0x1, label)
def bnc(self, label): self._branch(0x9, label)
def decle(self, *vals):
self._w(*vals)
def resolve(self) -> list[int]:
for i, label, kind in self._fix:
target = self.labels[label]
if kind == "j":
self.words[i + 1] = (3 << 8) | (((target >> 10) & 0x3F) << 2)
self.words[i + 2] = target & 0x3FF
elif kind == "jsr":
self.words[i + 1] = (1 << 8) | (((target >> 10) & 0x3F) << 2)
self.words[i + 2] = target & 0x3FF
else: # branch
a = self.base + i # address of the branch opcode
if target <= a: # backward: CPU computes target = a + 1 - disp
disp = a + 1 - target
self.words[i] |= (1 << 5)
else: # forward: target = a + 2 + disp
disp = target - a - 2
if not 0 <= disp <= 0xFFFF:
raise ValueError(f"branch to {label} out of range")
self.words[i + 1] = disp & 0xFFFF
return list(self.words)

20
lenser/intv/exporter.py Normal file
View file

@ -0,0 +1,20 @@
"""Build a Mattel Intellivision cartridge from a conversion."""
from __future__ import annotations
import os
from . import cartridge
_EXTS = (".int", ".bin", ".rom")
def export_int(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".int"
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)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

45
lenser/intv/palette.py Normal file
View file

@ -0,0 +1,45 @@
"""Mattel Intellivision STIC 16-colour palette (MAME `intv` values).
Verified against MAME pixel reads (white=7 #FFFCFF, brown/olive=11 #546E00,
magenta=15 #B51A58). In Color-Stack mode the per-card FOREGROUND is limited to
the first 8 colours (3 bits); the BACKGROUND (colour-stack entry) may be any of
the 16.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
STIC = np.array([
(0x00, 0x00, 0x00), # 0 black
(0x00, 0x2D, 0xFF), # 1 blue
(0xFF, 0x3D, 0x10), # 2 red
(0xC9, 0xCF, 0xAB), # 3 tan
(0x38, 0x6B, 0x3F), # 4 dark green
(0x00, 0xA7, 0x56), # 5 green
(0xFA, 0xEA, 0x50), # 6 yellow
(0xFF, 0xFC, 0xFF), # 7 white
(0xBD, 0xAC, 0xC8), # 8 grey
(0x24, 0xB8, 0xFF), # 9 cyan
(0xFF, 0xB4, 0x1F), # 10 orange
(0x54, 0x6E, 0x00), # 11 brown / olive
(0xFF, 0x4E, 0x57), # 12 pink
(0xA4, 0x96, 0xFF), # 13 light blue
(0x75, 0xCC, 0x80), # 14 yellow-green
(0xB5, 0x1A, 0x58), # 15 magenta
], dtype=np.float64)
# Foreground colours usable per card in Color-Stack mode (3-bit field).
FG_USABLE = list(range(8))
# Background (colour-stack) may be any of the 16.
BG_USABLE = list(range(16))
def get_palette() -> np.ndarray:
return STIC
def palette_lab() -> np.ndarray:
return srgb_to_lab(STIC)