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