8bitlenser/lenser/sms/viewer.py
2026-07-03 19:35:35 -07:00

109 lines
3.7 KiB
Python

"""Build a Sega Master System .sms cartridge: a Z80 VDP-setup program + data.
The Z80 code (org $0000) programs the VDP for mode 4 (256x192), uploads the tile
patterns to VRAM $0000, the name table to $3800 and the palette to CRAM, turns
the display on, then idles. A valid "TMR SEGA" header + checksum is written at
$7FF0 so the export BIOS accepts the cartridge.
"""
from __future__ import annotations
from .z80 import Asm
ROM_SIZE = 0x8000 # 32 KB (maps to $0000-$7FFF, no mapper needed)
PORT_CTRL = 0xBF
PORT_DATA = 0xBE
# VDP registers 0-10 for a plain 256x192 mode-4 background screen. Reg 1 starts
# with the display OFF ($80); it is set to $C0 (display on) at the end.
VDP_REGS = [0x04, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0x00, 0x00, 0x00, 0xFF]
def _code(reglen, patlen, ntlen) -> bytes:
a = Asm(0x0000)
a.di()
a.im1()
a.ld_sp(0xDFF0)
# --- VDP register init: write VDP_REGS[i] then $80|i ---
a.ld_hl("REGDATA")
a.ld_b(len(VDP_REGS))
a.ld_c(0x00)
a.label("rinit")
a.ld_a_hl(); a.out_a(PORT_CTRL) # value
a.ld_a_c(); a.or_n(0x80); a.out_a(PORT_CTRL) # $80 | reg
a.inc_hl(); a.inc_c()
a.djnz("rinit")
# --- upload patterns to VRAM $0000 ---
a.xor_a(); a.out_a(PORT_CTRL)
a.ld_a(0x40); a.out_a(PORT_CTRL) # $0000 | write
a.ld_hl("PATDATA")
a.ld_bc(patlen)
a.label("ptile")
a.ld_a_hl(); a.out_a(PORT_DATA)
a.inc_hl(); a.dec_bc()
a.ld_a_b(); a.or_c(); a.jp_nz("ptile")
# --- upload name table to VRAM $3800 ---
a.xor_a(); a.out_a(PORT_CTRL)
a.ld_a(0x78); a.out_a(PORT_CTRL) # $3800 | write ($38 | $40)
a.ld_hl("NTDATA")
a.ld_bc(ntlen)
a.label("pnt")
a.ld_a_hl(); a.out_a(PORT_DATA)
a.inc_hl(); a.dec_bc()
a.ld_a_b(); a.or_c(); a.jp_nz("pnt")
# --- upload palette to CRAM 0 ---
a.xor_a(); a.out_a(PORT_CTRL)
a.ld_a(0xC0); a.out_a(PORT_CTRL) # CRAM write
a.ld_hl("PALDATA")
a.ld_b(0x20) # 32 colours
a.label("ppal")
a.ld_a_hl(); a.out_a(PORT_DATA)
a.inc_hl()
a.djnz("ppal")
# --- disable sprites: write $D0 (list terminator) to SAT Y[0] at $3F00 ---
a.xor_a(); a.out_a(PORT_CTRL)
a.ld_a(0x7F); a.out_a(PORT_CTRL) # $3F00 | write
a.ld_a(0xD0); a.out_a(PORT_DATA)
# --- display on (reg 1 = $C0) ---
a.ld_a(0xC0); a.out_a(PORT_CTRL)
a.ld_a(0x81); a.out_a(PORT_CTRL)
a.label("hang")
a.jp("hang")
# data labels live right after the code, in this order
base = len(a.code)
a.set_label("REGDATA", base)
a.set_label("PATDATA", base + reglen)
a.set_label("NTDATA", base + reglen + patlen)
a.set_label("PALDATA", base + reglen + patlen + ntlen)
return a.resolve()
def build_rom(patterns: bytes, nametable: bytes, palette: bytes) -> bytes:
code = _code(len(VDP_REGS), len(patterns), len(nametable))
rom = bytearray(ROM_SIZE)
blob = code + bytes(VDP_REGS) + bytes(patterns) + bytes(nametable) + bytes(palette)
if len(blob) > 0x7FF0:
raise ValueError("SMS image data overruns the 32KB cartridge")
rom[0:len(blob)] = blob
# "TMR SEGA" header at $7FF0 (export region, 32KB) so the BIOS accepts it.
rom[0x7FF0:0x7FF8] = b"TMR SEGA"
rom[0x7FF8:0x7FFA] = bytes(2) # reserved
chk = sum(rom[0:0x7FF0]) & 0xFFFF # checksum of $0000-$7FEF
rom[0x7FFA] = chk & 0xFF
rom[0x7FFB] = (chk >> 8) & 0xFF
rom[0x7FFC:0x7FFF] = bytes(3) # product code / version
rom[0x7FFF] = 0x4C # region 4 (export) | size $C (32K)
return bytes(rom)
def write_sms(rom: bytes, path: str) -> str:
with open(path, "wb") as f:
f.write(rom)
return path