172 lines
6 KiB
Python
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)
|