8bitlenser/lenser/intv/cartridge.py
2026-07-03 19:35:35 -07:00

172 lines
6 KiB
Python

"""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)