First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
1
lenser/coco/__init__.py
Normal file
1
lenser/coco/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""TRS-80 Color Computer (MC6847 VDG) image conversion and cartridge export."""
|
||||
31
lenser/coco/cartridge.py
Normal file
31
lenser/coco/cartridge.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Build a TRS-80 CoCo Program Pak cartridge ROM (.ccc) holding viewer + image.
|
||||
|
||||
The CoCo autostarts the cartridge at $C000. Layout: viewer code, then the
|
||||
6144-byte PMODE 4 image, padded to an 8KB ROM ($C000-$DFFF)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import viewer
|
||||
|
||||
CART_BASE = 0xC000
|
||||
CART_SIZE = 0x2000 # 8 KB
|
||||
|
||||
|
||||
def build_rom(data: bytes, vdg: int = 0xF8, display: str = "forever",
|
||||
seconds: int = 0) -> bytes:
|
||||
if len(data) != viewer.SCREEN_BYTES:
|
||||
raise ValueError(f"unexpected image length {len(data)}")
|
||||
vk = dict(vdg=vdg, display=display, seconds=seconds)
|
||||
code = viewer.build(0, **vk) # pass 1: measure code length
|
||||
data_src = CART_BASE + len(code)
|
||||
code = viewer.build(data_src, **vk) # pass 2: real data address
|
||||
rom = code + bytes(data)
|
||||
if len(rom) > CART_SIZE:
|
||||
raise ValueError("viewer + image exceed the 8KB cartridge")
|
||||
return rom + bytes(CART_SIZE - len(rom))
|
||||
|
||||
|
||||
def write_ccc(rom: bytes, path: str) -> str:
|
||||
with open(path, "wb") as f:
|
||||
f.write(rom)
|
||||
return path
|
||||
16
lenser/coco/convert/__init__.py
Normal file
16
lenser/coco/convert/__init__.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""TRS-80 Color Computer conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
from ... import imageprep
|
||||
from . import pmode3, pmode4, mono
|
||||
|
||||
_MODULES = {"pmode4": pmode4, "pmode3": pmode3, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="pmode4", palette_name="mc6847",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES[mode]
|
||||
img_rgb = imageprep.prepare(path_or_img, module.WIDTH, module.HEIGHT,
|
||||
module.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
|
||||
return module.convert(img_rgb, palette_name, dither_mode, intensive, base_color=base_color)
|
||||
15
lenser/coco/convert/mono.py
Normal file
15
lenser/coco/convert/mono.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""CoCo monochrome -- PMODE 4's 256x192 black & white, exposed as the standard
|
||||
``mono`` mode for cross-platform parity (tone carried by dithering)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import pmode4
|
||||
|
||||
WIDTH, HEIGHT, PIXEL_ASPECT = pmode4.WIDTH, pmode4.HEIGHT, pmode4.PIXEL_ASPECT
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mc6847", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
conv = pmode4.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color)
|
||||
conv.mode = "mono"
|
||||
return conv
|
||||
44
lenser/coco/convert/pmode3.py
Normal file
44
lenser/coco/convert/pmode3.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""CoCo PMODE 3: 128x192, 4 colours from one MC6847 colour set.
|
||||
|
||||
128 wide on a 4:3 screen -> 2:1 pixels (like C64 multicolor). The 4 colours are
|
||||
fixed per CSS set (green/yellow/blue/red or buff/cyan/magenta/orange); we dither
|
||||
to whichever set reproduces the image with lower error and tell the viewer which.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from ...palette import srgb_to_lab
|
||||
from .. import palette as cpal
|
||||
|
||||
WIDTH, HEIGHT = 128, 192
|
||||
PIXEL_ASPECT = 2.0
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mc6847", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
img_lab = srgb_to_lab(img_rgb)
|
||||
allowed = np.tile(np.array([0, 1, 2, 3]), (HEIGHT, WIDTH, 1))
|
||||
|
||||
best = None
|
||||
for vdg, rgb in cpal.PMODE3_SETS.items():
|
||||
plab = srgb_to_lab(rgb)
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
err = perceptual_error(idx, img_lab, plab)
|
||||
if best is None or err < best[0]:
|
||||
best = (err, vdg, rgb, plab, idx)
|
||||
|
||||
err, vdg, rgb, plab, idx = best
|
||||
data = cpal.pack_pmode3(idx) # 6144-byte video buffer
|
||||
preview = rgb.astype(np.uint8)[idx]
|
||||
|
||||
return Conversion(
|
||||
mode="pmode3", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=0,
|
||||
viewer="pmode3", preview_rgb=preview,
|
||||
error=err,
|
||||
meta={"palette": "mc6847", "dither": dither_mode, "vdg": vdg},
|
||||
)
|
||||
41
lenser/coco/convert/pmode4.py
Normal file
41
lenser/coco/convert/pmode4.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""CoCo PMODE 4: 256x192, 1 bit/pixel, black & white (buff on black).
|
||||
|
||||
The CoCo's high-resolution monochrome mode -- 256x192 is exactly 4:3, so square
|
||||
pixels. Tone is carried by dithering, like Apple HGR mono.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as cpal
|
||||
|
||||
WIDTH, HEIGHT = 256, 192
|
||||
PIXEL_ASPECT = 1.0
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mc6847", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
from ...palette import srgb_to_lab
|
||||
plab = cpal.mono_lab()
|
||||
L = srgb_to_lab(img_rgb)[..., 0]
|
||||
img_mono = np.zeros((HEIGHT, WIDTH, 3))
|
||||
img_mono[..., 0] = L
|
||||
plab_mono = np.zeros((2, 3))
|
||||
plab_mono[:, 0] = plab[:, 0]
|
||||
|
||||
allowed = np.tile(np.array([0, 1]), (HEIGHT, WIDTH, 1))
|
||||
idx = dither.quantize(img_mono, allowed, plab_mono, dither_mode).astype(np.uint8)
|
||||
|
||||
data = cpal.pack_pmode4(idx) # 6144-byte video buffer
|
||||
preview = cpal.MONO.astype(np.uint8)[idx]
|
||||
|
||||
return Conversion(
|
||||
mode="pmode4", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=0,
|
||||
viewer="pmode4", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_mono, plab_mono),
|
||||
meta={"palette": "mc6847", "dither": dither_mode, "vdg": 0xF8},
|
||||
)
|
||||
11
lenser/coco/exporter.py
Normal file
11
lenser/coco/exporter.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Build a TRS-80 CoCo cartridge (.ccc) from a conversion."""
|
||||
from __future__ import annotations
|
||||
from . import cartridge
|
||||
|
||||
|
||||
def export_ccc(conv, output_path, source_path=None, display="forever", seconds=0):
|
||||
if not output_path.lower().endswith((".ccc", ".rom")):
|
||||
output_path += ".ccc"
|
||||
vdg = conv.meta.get("vdg", 0xF8) if conv.meta else 0xF8
|
||||
rom = cartridge.build_rom(conv.data, vdg=vdg, display=display, seconds=seconds)
|
||||
return cartridge.write_ccc(rom, output_path)
|
||||
99
lenser/coco/mc6809.py
Normal file
99
lenser/coco/mc6809.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""A tiny Motorola 6809 machine-code emitter (just what the CoCo viewer needs).
|
||||
|
||||
No 6809 assembler is installed, so -- as with the TI's TMS9900 emitter -- we emit
|
||||
opcodes directly. Supports labels + 8-bit relative-branch backpatching.
|
||||
Index-register codes for ,R+ postbytes: X=0x80, Y=0xA0, U=0xC0, S=0xE0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_IDX = {"x": 0x80, "y": 0xA0, "u": 0xC0, "s": 0xE0}
|
||||
|
||||
|
||||
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(bs)
|
||||
|
||||
def _w(self, v): # 16-bit, big-endian
|
||||
self.code += bytes([(v >> 8) & 0xFF, v & 0xFF])
|
||||
|
||||
# ---- inherent ----
|
||||
def nop(self): self._b(0x12)
|
||||
def clra(self): self._b(0x4F)
|
||||
def clrb(self): self._b(0x5F)
|
||||
def deca(self): self._b(0x4A)
|
||||
def decb(self): self._b(0x5A)
|
||||
def inca(self): self._b(0x4C)
|
||||
def incb(self): self._b(0x5C)
|
||||
def rts(self): self._b(0x39)
|
||||
def sync(self): self._b(0x13)
|
||||
|
||||
# ---- immediate ----
|
||||
def lda_imm(self, v): self._b(0x86, v & 0xFF)
|
||||
def ldb_imm(self, v): self._b(0xC6, v & 0xFF)
|
||||
def anda_imm(self, v): self._b(0x84, v & 0xFF)
|
||||
def ora_imm(self, v): self._b(0x8A, v & 0xFF)
|
||||
def cmpa_imm(self, v): self._b(0x81, v & 0xFF)
|
||||
def cmpb_imm(self, v): self._b(0xC1, v & 0xFF)
|
||||
def orcc(self, v): self._b(0x1A, v & 0xFF)
|
||||
def andcc(self, v): self._b(0x1C, v & 0xFF)
|
||||
|
||||
def ldd_imm(self, v): self._b(0xCC); self._w(v)
|
||||
def subd_imm(self, v): self._b(0x83); self._w(v)
|
||||
def ldx_imm(self, v): self._b(0x8E); self._w(v)
|
||||
def ldu_imm(self, v): self._b(0xCE); self._w(v)
|
||||
def ldy_imm(self, v): self._b(0x10, 0x8E); self._w(v)
|
||||
def lds_imm(self, v): self._b(0x10, 0xCE); self._w(v)
|
||||
def cmpx_imm(self, v): self._b(0x8C); self._w(v)
|
||||
def cmpu_imm(self, v): self._b(0x11, 0x83); self._w(v)
|
||||
|
||||
# ---- extended (16-bit address) ----
|
||||
def lda_ext(self, a): self._b(0xB6); self._w(a)
|
||||
def sta_ext(self, a): self._b(0xB7); self._w(a)
|
||||
def ldb_ext(self, a): self._b(0xF6); self._w(a)
|
||||
def stb_ext(self, a): self._b(0xF7); self._w(a)
|
||||
def std_ext(self, a): self._b(0xFD); self._w(a)
|
||||
def jmp_ext(self, a): self._b(0x7E); self._w(a)
|
||||
def jsr_ext(self, a): self._b(0xBD); self._w(a)
|
||||
def jmp_ind(self, a): self._b(0x6E, 0x9F); self._w(a) # JMP [addr] (indirect)
|
||||
|
||||
# ---- indexed auto-increment: LDA ,R+ / STA ,R+ ----
|
||||
def lda_postinc(self, r): self._b(0xA6, _IDX[r])
|
||||
def sta_postinc(self, r): self._b(0xA7, _IDX[r])
|
||||
def ldb_postinc(self, r): self._b(0xE6, _IDX[r])
|
||||
def stb_postinc(self, r): self._b(0xE7, _IDX[r])
|
||||
|
||||
# ---- 8-bit relative branches ----
|
||||
def _branch(self, op, label):
|
||||
self._b(op)
|
||||
self._fix.append((len(self.code), label))
|
||||
self._b(0x00) # placeholder
|
||||
|
||||
def bra(self, label): self._branch(0x20, label)
|
||||
def bne(self, label): self._branch(0x26, label)
|
||||
def beq(self, label): self._branch(0x27, label)
|
||||
def bpl(self, label): self._branch(0x2A, label)
|
||||
def bmi(self, label): self._branch(0x2B, label)
|
||||
def bcc(self, label): self._branch(0x24, label)
|
||||
def bcs(self, label): self._branch(0x25, label)
|
||||
|
||||
def resolve(self) -> bytes:
|
||||
for pos, label in self._fix:
|
||||
target = self.labels[label]
|
||||
rel = target - (self.base + pos + 1)
|
||||
if not -128 <= rel <= 127:
|
||||
raise ValueError(f"branch to {label} out of range ({rel})")
|
||||
self.code[pos] = rel & 0xFF
|
||||
return bytes(self.code)
|
||||
69
lenser/coco/palette.py
Normal file
69
lenser/coco/palette.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""TRS-80 Color Computer MC6847 VDG palette + pixel packing.
|
||||
|
||||
PMODE 4 (256x192) is 2-colour: black + the foreground of the selected colour set
|
||||
(CSS=0 green, CSS=1 "buff" ~ off-white). We use CSS=1 (buff on black) for clean
|
||||
monochrome, like Apple HGR mono.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
# Approximate sRGB for the MC6847 colours.
|
||||
BLACK = (0, 0, 0)
|
||||
BUFF = (255, 255, 255)
|
||||
GREEN = (38, 194, 64)
|
||||
YELLOW = (255, 240, 112)
|
||||
BLUE = (40, 62, 211)
|
||||
RED = (180, 38, 40)
|
||||
CYAN = (52, 198, 160)
|
||||
MAGENTA = (200, 70, 180)
|
||||
ORANGE = (224, 116, 36)
|
||||
|
||||
# PMODE 4 monochrome (CSS=1): index 0 = black, 1 = buff.
|
||||
MONO = np.array([BLACK, BUFF], dtype=np.float64)
|
||||
|
||||
# PMODE 3 (CG6) 4-colour sets, selected by the CSS bit. The 2-bit pixel value
|
||||
# 0..3 indexes the set. VDG byte ($FF22) high nibble = E (A/G,GM2,GM1=1, GM0=0).
|
||||
PMODE3_SETS = {
|
||||
0xE0: np.array([GREEN, YELLOW, BLUE, RED], dtype=np.float64), # CSS=0
|
||||
0xE8: np.array([BUFF, CYAN, MAGENTA, ORANGE], dtype=np.float64), # CSS=1
|
||||
}
|
||||
|
||||
|
||||
def mono_lab() -> np.ndarray:
|
||||
return srgb_to_lab(MONO)
|
||||
|
||||
|
||||
def pack_pmode3(val: np.ndarray) -> bytes:
|
||||
"""Pack a (192,128) 0..3 array into 6144 bytes, 4 pixels/byte (2bpp),
|
||||
leftmost pixel in the high bits."""
|
||||
h, w = val.shape
|
||||
out = bytearray(w // 4 * h)
|
||||
k = 0
|
||||
for y in range(h):
|
||||
row = val[y]
|
||||
for x in range(0, w, 4):
|
||||
out[k] = ((row[x] & 3) << 6) | ((row[x + 1] & 3) << 4) | \
|
||||
((row[x + 2] & 3) << 2) | (row[x + 3] & 3)
|
||||
k += 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def pack_pmode4(idx: np.ndarray) -> bytes:
|
||||
"""Pack a (192,256) 0/1 array into 6144 bytes, 8 pixels/byte, bit7 leftmost,
|
||||
1 = foreground (the VDG reads this straight from video RAM)."""
|
||||
h, w = idx.shape
|
||||
out = bytearray(w // 8 * h)
|
||||
k = 0
|
||||
for y in range(h):
|
||||
row = idx[y]
|
||||
for x in range(0, w, 8):
|
||||
b = 0
|
||||
for i in range(8):
|
||||
b = (b << 1) | (1 if row[x + i] else 0)
|
||||
out[k] = b
|
||||
k += 1
|
||||
return bytes(out)
|
||||
71
lenser/coco/viewer.py
Normal file
71
lenser/coco/viewer.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Generates the CoCo cartridge viewer (Motorola 6809 machine code).
|
||||
|
||||
Runs from the Program Pak ROM at $C000 (the CoCo autostarts it). Sets the VDG
|
||||
graphics mode via PIA1 $FF22 and the SAM video registers (PMODE 3 and 4 are both
|
||||
6144-byte SAM mode 6; only the $FF22 byte differs), copies the 6144-byte image
|
||||
from cart ROM down to the video page at $0E00, then holds it.
|
||||
|
||||
display: forever (hold), key (poll the keyboard then reset), seconds (count 60 Hz
|
||||
field-syncs then reset). A Program Pak can't cleanly return to BASIC, so key and
|
||||
seconds reset the machine (which re-displays the picture).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .mc6809 import Asm
|
||||
|
||||
CART_BASE = 0xC000
|
||||
VIDEO = 0x0E00
|
||||
SCREEN_BYTES = 6144
|
||||
RATE = 60 # NTSC field-syncs per second
|
||||
|
||||
|
||||
def build(data_src: int, vdg: int = 0xF8, display: str = "forever",
|
||||
seconds: int = 0) -> bytes:
|
||||
a = Asm(CART_BASE)
|
||||
a.orcc(0x50) # mask IRQ + FIRQ; we run standalone
|
||||
|
||||
a.lda_imm(vdg)
|
||||
a.sta_ext(0xFF22) # VDG graphics mode (PMODE 3 = $E0/$E8, PMODE 4 = $F8)
|
||||
|
||||
# SAM video mode 6 (V2=1, V1=1, V0=0). SAM regs toggle by address; data ignored.
|
||||
a.sta_ext(0xFFC5) # V2 set
|
||||
a.sta_ext(0xFFC3) # V1 set
|
||||
a.sta_ext(0xFFC0) # V0 clear
|
||||
|
||||
# SAM video offset = 7 ($0E00): F0,F1,F2 set; F3..F6 clear.
|
||||
a.sta_ext(0xFFC7); a.sta_ext(0xFFC9); a.sta_ext(0xFFCB)
|
||||
a.sta_ext(0xFFCC); a.sta_ext(0xFFCE); a.sta_ext(0xFFD0); a.sta_ext(0xFFD2)
|
||||
|
||||
# copy SCREEN_BYTES from cart ROM (data_src) to the video page
|
||||
a.ldx_imm(data_src)
|
||||
a.ldu_imm(VIDEO)
|
||||
a.label("copy")
|
||||
a.lda_postinc("x")
|
||||
a.sta_postinc("u")
|
||||
a.cmpu_imm(VIDEO + SCREEN_BYTES)
|
||||
a.bne("copy")
|
||||
|
||||
# ---- hold the picture ----
|
||||
if display == "key":
|
||||
a.clra()
|
||||
a.sta_ext(0xFF02) # drive all keyboard columns low
|
||||
a.label("kwait")
|
||||
a.lda_ext(0xFF00) # row sense
|
||||
a.ora_imm(0x80) # ignore the joystick-compare bit 7
|
||||
a.cmpa_imm(0xFF)
|
||||
a.beq("kwait") # all rows high -> no key, keep waiting
|
||||
a.jmp_ind(0xFFFE) # reset
|
||||
elif display == "seconds":
|
||||
a.ldd_imm(max(1, min(0xFFFF, int(seconds) * RATE)))
|
||||
a.label("swait")
|
||||
a.lda_ext(0xFF03) # PIA0 CB1 (field-sync) status
|
||||
a.bpl("swait") # bit 7 clear -> no new field yet
|
||||
a.lda_ext(0xFF02) # read PB data to clear the field-sync flag
|
||||
a.subd_imm(1)
|
||||
a.bne("swait")
|
||||
a.jmp_ind(0xFFFE) # reset
|
||||
else: # forever
|
||||
a.label("hang")
|
||||
a.bra("hang")
|
||||
return a.resolve()
|
||||
Loading…
Add table
Add a link
Reference in a new issue