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

View file

@ -0,0 +1 @@
"""ColecoVision / Coleco Adam (TMS9918A, Z80) image conversion and cartridge export."""

View file

@ -0,0 +1,49 @@
"""Build a ColecoVision/Adam cartridge ROM (.col) holding the Z80 viewer + image.
Cartridge maps at $8000. Header: magic $55 $AA (skip the Coleco title screen and
run immediately), eight pointer words, then a table of JP vectors -- the BIOS
jumps to the one at $800A (the game entry). We point that at our viewer; the RST
and interrupt vectors go to a stub (we run with interrupts disabled).
"""
from __future__ import annotations
import struct
from . import viewer
CART_BASE = 0x8000
CART_SIZE = 0x2000 # 8 KB (viewer + 6912-byte image fit comfortably)
VECTORS = 8 # entry + 7 RST/INT stubs
CODE_BASE = CART_BASE + 10 + VECTORS * 3 # after magic(2)+ptrs(8)+vectors
def build_rom(data: bytes, display: str = "forever", seconds: int = 0) -> bytes:
if len(data) != viewer.PATTERN_BYTES + viewer.NCELLS:
raise ValueError(f"unexpected image length {len(data)}")
vk = dict(display=display, seconds=seconds)
code = viewer.build(CODE_BASE, 0, **vk) # pass 1: measure code length
data_addr = CODE_BASE + len(code) + 1 # +1 for the stub RET
code = viewer.build(CODE_BASE, data_addr, **vk) # pass 2: real data address
stub_addr = CODE_BASE + len(code)
data_addr = stub_addr + 1
if data_addr - CART_BASE + len(data) > CART_SIZE:
raise ValueError("viewer + image exceed the 8KB cartridge")
header = bytearray([0x55, 0xAA]) + bytes(8) # magic + 8 pointer bytes
vecs = bytearray()
targets = [CODE_BASE] + [stub_addr] * (VECTORS - 1)
for t in targets:
vecs += bytes([0xC3]) + struct.pack("<H", t) # JP t
rom = bytearray(header + vecs + code + bytes([0xC9])) # stub = RET
rom += bytes(data)
return bytes(rom) + bytes(CART_SIZE - len(rom))
def write_col(rom: bytes, path: str) -> str:
with open(path, "wb") as f:
f.write(rom)
return path

View file

@ -0,0 +1,25 @@
"""ColecoVision / Adam conversion dispatch.
Both machines use the same TMS9918A VDP as the TI-99/4A, so the Graphics Mode 2
image encoding (palette + 6144-byte pattern + 768 per-cell colours) is identical
-- we reuse the TI-99 GM2 encoder unchanged.
"""
from __future__ import annotations
from ... import imageprep
from ...ti99.convert import gm2 as _gm2, mono as _mono
_MODULES = {"gm2": _gm2, "mono": _mono}
MODES = ["gm2", "mono"]
def convert_image(path_or_img, mode="gm2", palette_name="tms9918",
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES.get(mode, _gm2)
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
conv = module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.viewer = "coleco"
return conv

10
lenser/coleco/exporter.py Normal file
View file

@ -0,0 +1,10 @@
"""Build a ColecoVision/Adam cartridge (.col) from a conversion."""
from __future__ import annotations
from . import cartridge
def export_col(conv, output_path, source_path=None, display="forever", seconds=0):
if not output_path.lower().endswith((".col", ".rom")):
output_path += ".col"
rom = cartridge.build_rom(conv.data, display=display, seconds=seconds)
return cartridge.write_col(rom, output_path)

114
lenser/coleco/viewer.py Normal file
View file

@ -0,0 +1,114 @@
"""Generates the ColecoVision/Adam cartridge viewer (Z80 machine code).
Sets the TMS9918A to Graphics Mode 2, builds the name table, copies the
6144-byte pattern from cartridge ROM to VRAM >0000, and expands the 768 per-cell
colour bytes x8 into the 6144-byte colour table at VRAM >2000 -- the same picture
the TI-99 viewer makes, but in Z80 with the ColecoVision VDP ports.
display: forever (hold), key (poll the controller's fire button then reset),
seconds (count VDP frame flags then reset). A cartridge has no OS to return to,
so key/seconds reset the machine (JP 0), which re-runs the cart and re-displays.
"""
from __future__ import annotations
from .z80 import Asm
VDP_DATA = 0xBE # data port (VRAM, auto-increment)
VDP_CTRL = 0xBF # control port (address / register / status)
VDP_REGS = [0x02, 0xC0, 0x0E, 0xFF, 0x03, 0x36, 0x07, 0x01] # Graphics Mode 2
PATTERN_BYTES = 6144
NCELLS = 768
RATE = 60 # NTSC frames/second
JOY_SEG = 0xC0 # OUT (>C0) selects the joystick / left-fire segment
JOY_PORT = 0xFC # IN (>FC) reads controller 1
SOUND_PORT = 0xFF # SN76489 sound chip
# SN76489 "attenuation = 15 (off)" latch byte for each of the 4 channels.
SOUND_OFF = [0x9F, 0xBF, 0xDF, 0xFF]
def _set_write_addr(a: Asm, addr: int):
a.ld_a(addr & 0xFF); a.out_n_a(VDP_CTRL)
a.ld_a(((addr >> 8) & 0x3F) | 0x40); a.out_n_a(VDP_CTRL)
def build(code_base: int, data_addr: int, display: str = "forever",
seconds: int = 0) -> bytes:
a = Asm(code_base)
a.di()
# ---- silence the SN76489 (our >55AA header skips the BIOS sound init, so the
# chip powers up holding a tone); set all 4 channels to attenuation off ----
for v in SOUND_OFF:
a.ld_a(v)
a.out_n_a(SOUND_PORT)
# ---- programme the 8 VDP registers ----
for reg, val in enumerate(VDP_REGS):
a.ld_a(val); a.out_n_a(VDP_CTRL)
a.ld_a(0x80 | reg); a.out_n_a(VDP_CTRL)
# ---- name table >3800 = 0,1,...,255 repeated 3 times (768 bytes) ----
_set_write_addr(a, 0x3800)
a.ld_a(0)
a.ld_c(3)
a.label("name_o")
a.ld_b(0) # B=0 -> DJNZ runs 256 times
a.label("name_i")
a.out_n_a(VDP_DATA)
a.inc_a()
a.djnz("name_i")
a.dec_c()
a.jr_nz("name_o")
# ---- pattern table >0000 = 6144 bytes from ROM (24 * 256) ----
_set_write_addr(a, 0x0000)
a.ld_hl(data_addr)
a.ld_c(PATTERN_BYTES // 256)
a.label("pat_o")
a.ld_b(0)
a.label("pat_i")
a.ld_a_hl()
a.out_n_a(VDP_DATA)
a.inc_hl()
a.djnz("pat_i")
a.dec_c()
a.jr_nz("pat_o")
# ---- colour table >2000 = each cell colour written 8x (768 -> 6144) ----
_set_write_addr(a, 0x2000)
a.ld_hl(data_addr + PATTERN_BYTES)
a.ld_c(NCELLS // 256)
a.label("col_o")
a.ld_b(0)
a.label("col_i")
a.ld_a_hl()
for _ in range(8):
a.out_n_a(VDP_DATA)
a.inc_hl()
a.djnz("col_i")
a.dec_c()
a.jr_nz("col_o")
# ---- hold the picture ----
if display == "key":
a.label("kwait")
a.ld_a(0); a.out_n_a(JOY_SEG) # select joystick / left-fire segment
a.in_a_n(JOY_PORT)
a.and_n(0x40) # bit 6 = fire button (active low)
a.jr_nz("kwait") # still high -> not pressed
a.jp(0x0000) # reset -> re-display
elif display == "seconds":
a.ld_de(max(1, min(0xFFFF, int(seconds) * RATE)))
a.label("swait")
a.in_a_n(VDP_CTRL) # read VDP status (clears frame flag)
a.and_n(0x80)
a.jr_z("swait") # no new frame yet
a.dec_de()
a.ld_a_d(); a.or_e()
a.jr_nz("swait")
a.jp(0x0000) # reset -> re-display
else: # forever
a.label("hang")
a.jr("hang")
return a.resolve()

112
lenser/coleco/z80.py Normal file
View file

@ -0,0 +1,112 @@
"""A tiny Z80 machine-code emitter (just what the ColecoVision/Adam viewer needs).
No Z80 assembler is installed, so -- as with the TMS9900 and 6809 emitters -- we
emit opcodes directly. Little-endian 16-bit operands; 8-bit relative jumps are
backpatched via labels.
"""
from __future__ import annotations
class Asm:
def __init__(self, base: int):
self.base = base
self.code = bytearray()
self.labels: dict[str, int] = {}
self._fix: list[tuple[int, str]] = [] # (pos of rel byte, label)
def pos(self) -> int:
return self.base + len(self.code)
def label(self, name: str):
self.labels[name] = self.pos()
def _b(self, *bs):
self.code += bytes(b & 0xFF for b in bs)
def _w(self, v): # 16-bit, little-endian
self.code += bytes([v & 0xFF, (v >> 8) & 0xFF])
# ---- 8-bit loads (immediate) ----
def ld_a(self, n): self._b(0x3E, n)
def ld_b(self, n): self._b(0x06, n)
def ld_c(self, n): self._b(0x0E, n)
def ld_d(self, n): self._b(0x16, n)
def ld_e(self, n): self._b(0x1E, n)
# ---- 16-bit loads (immediate) ----
def ld_hl(self, nn): self._b(0x21); self._w(nn)
def ld_de(self, nn): self._b(0x11); self._w(nn)
def ld_bc(self, nn): self._b(0x01); self._w(nn)
# ---- memory <-> A (extended addressing) ----
def ld_a_mem(self, addr): self._b(0x3A); self._w(addr) # LD A,(nn)
def ld_mem_a(self, addr): self._b(0x32); self._w(addr) # LD (nn),A
# ---- register moves ----
def ld_a_b(self): self._b(0x78)
def ld_a_c(self): self._b(0x79)
def ld_a_d(self): self._b(0x7A)
def ld_a_e(self): self._b(0x7B)
def ld_a_h(self): self._b(0x7C)
def ld_a_l(self): self._b(0x7D)
def ld_b_a(self): self._b(0x47)
def ld_c_a(self): self._b(0x4F)
def ld_a_hl(self): self._b(0x7E) # LD A,(HL)
def ld_hl_a(self): self._b(0x77) # LD (HL),A
# ---- I/O ----
def out_n_a(self, n): self._b(0xD3, n) # OUT (n),A
def in_a_n(self, n): self._b(0xDB, n) # IN A,(n)
# ---- arithmetic / logic ----
def inc_a(self): self._b(0x3C)
def dec_a(self): self._b(0x3D)
def dec_c(self): self._b(0x0D)
def inc_hl(self): self._b(0x23)
def dec_hl(self): self._b(0x2B)
def inc_de(self): self._b(0x13)
def dec_de(self): self._b(0x1B)
def dec_bc(self): self._b(0x0B)
def or_a(self): self._b(0xB7) # OR A (set flags from A)
def or_c(self): self._b(0xB1)
def or_e(self): self._b(0xB3)
def and_n(self, n): self._b(0xE6, n)
def cp_n(self, n): self._b(0xFE, n)
def add_hl_de(self): self._b(0x19)
# ---- block ops ----
def ldir(self): self._b(0xED, 0xB0) # (HL)->(DE), BC times
# ---- control ----
def di(self): self._b(0xF3)
def ei(self): self._b(0xFB)
def ret(self): self._b(0xC9)
def retn(self): self._b(0xED, 0x45)
def nop(self): self._b(0x00)
def jp(self, addr): self._b(0xC3); self._w(addr)
def jp_label(self, lbl):
self._b(0xC3); self._fix.append((len(self.code), lbl)); self._w(0)
def _jr(self, op, lbl):
self._b(op); self._fix.append((len(self.code), lbl)); self._b(0)
def jr(self, lbl): self._jr(0x18, lbl)
def jr_nz(self, lbl): self._jr(0x20, lbl)
def jr_z(self, lbl): self._jr(0x28, lbl)
def djnz(self, lbl): self._jr(0x10, lbl)
def resolve(self) -> bytes:
for pos, lbl in self._fix:
target = self.labels[lbl]
opcode = self.code[pos - 1] # the byte just before the operand
if opcode == 0xC3: # JP nn (absolute, 2 bytes)
self.code[pos] = target & 0xFF
self.code[pos + 1] = (target >> 8) & 0xFF
else: # JR / DJNZ (relative, 1 byte)
rel = target - (self.base + pos + 1)
if not -128 <= rel <= 127:
raise ValueError(f"relative jump to {lbl} out of range ({rel})")
self.code[pos] = rel & 0xFF
return bytes(self.code)