First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
0
lenser/coco3/__init__.py
Normal file
0
lenser/coco3/__init__.py
Normal file
29
lenser/coco3/cartridge.py
Normal file
29
lenser/coco3/cartridge.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Build a CoCo 3 Program Pak cartridge ROM (.ccc): GIME viewer + 15360-byte image.
|
||||
|
||||
The CoCo 3 autostarts the cartridge at $C000. Layout: viewer code, then the
|
||||
linear GIME image, padded to a 16KB ROM."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import viewer
|
||||
|
||||
CART_BASE = 0xC000
|
||||
CART_SIZE = 0x4000 # 16 KB
|
||||
|
||||
|
||||
def build_rom(data: bytes, cres: int, inks, border: int) -> bytes:
|
||||
if len(data) != viewer.IMG_LEN:
|
||||
raise ValueError(f"unexpected image length {len(data)}")
|
||||
kw = dict(cres=cres, inks=inks, border=border)
|
||||
code = viewer.build(0, **kw) # pass 1: measure code length
|
||||
data_src = CART_BASE + len(code)
|
||||
code = viewer.build(data_src, **kw) # pass 2: real data address
|
||||
rom = code + bytes(data)
|
||||
if len(rom) > 0x3F00: # keep clear of the $FF00 I/O page
|
||||
raise ValueError("viewer + image exceed the cartridge ROM window")
|
||||
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
|
||||
19
lenser/coco3/convert/__init__.py
Normal file
19
lenser/coco3/convert/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Tandy CoCo 3 (GIME) conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import gr16, gr4, mono
|
||||
|
||||
_MODULES = {"gr16": gr16, "gr4": gr4, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="gr16", palette_name="gime",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES.get(mode, gr16)
|
||||
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)
|
||||
85
lenser/coco3/convert/_common.py
Normal file
85
lenser/coco3/convert/_common.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Shared CoCo 3 GIME encoder helpers: global palette selection + linear packing.
|
||||
|
||||
GIME native graphics modes have no per-cell colour limit: a flat palette of pens
|
||||
(16/4/2), each pen any of the 64 colours. Pick the best N-colour sub-palette,
|
||||
dither to it, then pack pens into the LINEAR 80-byte x 192-row screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from .. import palette as g
|
||||
|
||||
BYTES_PER_ROW, ROWS = 80, 192
|
||||
|
||||
|
||||
def choose_inks(img_lab, plab, n):
|
||||
"""Greedy forward selection of the ``n`` palette colours (0-63) minimising
|
||||
nearest-colour error over the image."""
|
||||
flat = img_lab.reshape(-1, 3)
|
||||
d = np.sum((flat[:, None, :] - plab[None, :, :]) ** 2, axis=-1)
|
||||
chosen, best = [], np.full(flat.shape[0], np.inf)
|
||||
for _ in range(n):
|
||||
cand = np.minimum(best[:, None], d).sum(0)
|
||||
for c in chosen:
|
||||
cand[c] = np.inf
|
||||
c = int(cand.argmin())
|
||||
chosen.append(c)
|
||||
best = np.minimum(best, d[:, c])
|
||||
return sorted(chosen)
|
||||
|
||||
|
||||
def render(img_rgb, n, dither_mode, ramp=None):
|
||||
"""Return (pen (H,W) 0..n-1, inks (n 6-bit colours), idx (H,W) palette
|
||||
indices, img_lab, plab, prgb)."""
|
||||
plab = g.palette_lab()
|
||||
prgb = g.get_palette().astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
|
||||
pal_idx = list(ramp) if ramp is not None else choose_inks(img_lab, plab, n)
|
||||
allowed = np.tile(np.array(pal_idx), (*img_lab.shape[:2], 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
lut = {p: k for k, p in enumerate(pal_idx)}
|
||||
pen = np.vectorize(lut.get)(idx).astype(np.uint8)
|
||||
return pen, list(pal_idx), idx.astype(np.uint16), img_lab, plab, prgb
|
||||
|
||||
|
||||
def _pack_16(pen):
|
||||
"""160x192 pens (0-15) -> 15360 bytes. 2 px/byte, high nibble = left."""
|
||||
scr = bytearray(BYTES_PER_ROW * ROWS)
|
||||
for y in range(ROWS):
|
||||
row = pen[y]
|
||||
base = y * BYTES_PER_ROW
|
||||
for bx in range(BYTES_PER_ROW):
|
||||
scr[base + bx] = (int(row[bx * 2]) << 4) | (int(row[bx * 2 + 1]) & 0x0F)
|
||||
return bytes(scr)
|
||||
|
||||
|
||||
def _pack_4(pen):
|
||||
"""320x192 pens (0-3) -> 15360 bytes. 4 px/byte, MSB = left."""
|
||||
scr = bytearray(BYTES_PER_ROW * ROWS)
|
||||
for y in range(ROWS):
|
||||
row = pen[y]
|
||||
base = y * BYTES_PER_ROW
|
||||
for bx in range(BYTES_PER_ROW):
|
||||
scr[base + bx] = ((int(row[bx * 4]) << 6) | (int(row[bx * 4 + 1]) << 4) |
|
||||
(int(row[bx * 4 + 2]) << 2) | int(row[bx * 4 + 3]))
|
||||
return bytes(scr)
|
||||
|
||||
|
||||
def _pack_2(pen):
|
||||
"""640x192 pens (0-1) -> 15360 bytes. 8 px/byte, MSB = left."""
|
||||
scr = bytearray(BYTES_PER_ROW * ROWS)
|
||||
for y in range(ROWS):
|
||||
row = pen[y]
|
||||
base = y * BYTES_PER_ROW
|
||||
for bx in range(BYTES_PER_ROW):
|
||||
byte = 0
|
||||
for k in range(8):
|
||||
byte |= (int(row[bx * 8 + k]) & 1) << (7 - k)
|
||||
scr[base + bx] = byte
|
||||
return bytes(scr)
|
||||
|
||||
|
||||
PACK = {0: _pack_2, 1: _pack_4, 2: _pack_16} # keyed by CRES
|
||||
24
lenser/coco3/convert/gr16.py
Normal file
24
lenser/coco3/convert/gr16.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""CoCo 3 GIME 160x192, 16 colours from the 64-colour palette (flagship photo
|
||||
mode -- wide pixels but the most colour)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 160, 192
|
||||
PIXEL_ASPECT = 2.0 # 160 wide on a 4:3 screen -> pixels twice as wide
|
||||
NCOL = 16
|
||||
CRES = 2
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="gime", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode)
|
||||
screen = _common.PACK[CRES](pen)
|
||||
return Conversion(
|
||||
mode="gr16", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx, data=screen, data_addr=0x4000, viewer="coco3",
|
||||
preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "gime", "dither": dither_mode, "cres": CRES,
|
||||
"inks": inks, "border": inks[0] if inks else 0},
|
||||
)
|
||||
24
lenser/coco3/convert/gr4.py
Normal file
24
lenser/coco3/convert/gr4.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""CoCo 3 GIME 320x192, 4 colours from the 64-colour palette (more resolution,
|
||||
fewer colours than gr16)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 320, 192
|
||||
PIXEL_ASPECT = 1.0
|
||||
NCOL = 4
|
||||
CRES = 1
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="gime", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
pen, inks, idx, img_lab, plab, prgb = _common.render(img_rgb, NCOL, dither_mode)
|
||||
screen = _common.PACK[CRES](pen)
|
||||
return Conversion(
|
||||
mode="gr4", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx, data=screen, data_addr=0x4000, viewer="coco3",
|
||||
preview_rgb=prgb[idx], error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "gime", "dither": dither_mode, "cres": CRES,
|
||||
"inks": inks, "border": inks[0] if inks else 0},
|
||||
)
|
||||
45
lenser/coco3/convert/mono.py
Normal file
45
lenser/coco3/convert/mono.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""CoCo 3 GIME monochrome: 640x192, 2 colours (black + white) -- the highest-
|
||||
resolution CoCo 3 mode, tone carried by dithering. ``--mono-base`` tints the
|
||||
second tone."""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as g
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 640, 192
|
||||
PIXEL_ASPECT = 0.5
|
||||
CRES = 0
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="gime", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = g.palette_lab()
|
||||
black = min(g.GREYS, key=lambda i: plab[i, 0])
|
||||
if base_color in range(64) and base_color != black:
|
||||
ramp = sorted({black, int(base_color)}, key=lambda i: plab[i, 0])
|
||||
else:
|
||||
ramp = sorted(g.GREYS, key=lambda i: plab[i, 0])
|
||||
ramp = [ramp[0], ramp[-1]] # black + white
|
||||
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
mono_lab = np.zeros_like(img_lab); mono_lab[..., 0] = img_lab[..., 0]
|
||||
plab_mono = np.zeros_like(plab); plab_mono[:, 0] = plab[:, 0]
|
||||
allowed = np.tile(np.array(ramp), (*mono_lab.shape[:2], 1))
|
||||
idx = dither.quantize(mono_lab, allowed, plab_mono, dither_mode).astype(np.int64)
|
||||
lut = {p: k for k, p in enumerate(ramp)}
|
||||
pen = np.vectorize(lut.get)(idx).astype(np.uint8)
|
||||
|
||||
screen = _common.PACK[CRES](pen)
|
||||
prgb = g.get_palette().astype(np.uint8)
|
||||
return Conversion(
|
||||
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=screen, data_addr=0x4000,
|
||||
viewer="coco3", preview_rgb=prgb[idx.astype(np.uint16)],
|
||||
error=perceptual_error(idx, mono_lab, plab_mono),
|
||||
meta={"palette": "gime", "dither": dither_mode, "cres": CRES,
|
||||
"inks": ramp, "border": ramp[0]},
|
||||
)
|
||||
13
lenser/coco3/exporter.py
Normal file
13
lenser/coco3/exporter.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Build a CoCo 3 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, video="ntsc"):
|
||||
if not output_path.lower().endswith((".ccc", ".rom")):
|
||||
output_path += ".ccc"
|
||||
rom = cartridge.build_rom(bytes(conv.data), conv.meta["cres"],
|
||||
conv.meta["inks"], conv.meta["border"])
|
||||
return cartridge.write_ccc(rom, output_path)
|
||||
34
lenser/coco3/palette.py
Normal file
34
lenser/coco3/palette.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Tandy CoCo 3 GIME RGB palette.
|
||||
|
||||
The GIME produces 64 colours: a 6-bit palette value, bits ``R1 G1 B1 R0 G0 B0``,
|
||||
so each of R/G/B is a 2-bit level (x0x55 -> 0, 0x55, 0xAA, 0xFF). RGB is computed
|
||||
exactly as MAME's gime_device::get_rgb_color, so the encoder matches the emulator.
|
||||
A palette register ($FFB0-$FFBF) holds this 6-bit value, so the colour index IS
|
||||
the byte written to hardware.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
|
||||
def _rgb(c: int):
|
||||
r = (((c >> 4) & 2) | ((c >> 2) & 1)) * 0x55
|
||||
g = (((c >> 3) & 2) | ((c >> 1) & 1)) * 0x55
|
||||
b = (((c >> 2) & 2) | ((c >> 0) & 1)) * 0x55
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
PALETTE = np.array([_rgb(c) for c in range(64)], dtype=np.float64)
|
||||
|
||||
# Neutral grey ramp (R==G==B): 6-bit values 0, 7, 56, 63.
|
||||
GREYS = [c for c in range(64) if PALETTE[c, 0] == PALETTE[c, 1] == PALETTE[c, 2]]
|
||||
|
||||
|
||||
def get_palette() -> np.ndarray:
|
||||
return PALETTE
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(PALETTE)
|
||||
70
lenser/coco3/viewer.py
Normal file
70
lenser/coco3/viewer.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""CoCo 3 GIME hi-res viewer (Motorola 6809 machine code, Program Pak ROM).
|
||||
|
||||
Autostarts from the cartridge at $C000 in CoCo-compatible mode, then switches the
|
||||
GIME into a native graphics mode. Because turning on GIME mode (writing $FF90)
|
||||
can change the memory map under the running code, the setup runs in two stages:
|
||||
|
||||
1. At $C000 (cart ROM): mask interrupts, copy the 15360-byte image down to RAM
|
||||
at $4000, program the GIME palette + mode/geometry/video-base registers
|
||||
(these are safe while still in legacy mode), then plant a tiny stub in RAM.
|
||||
2. Jump to the RAM stub, which writes $FF90 (COCO=0 -> GIME graphics on) and
|
||||
idles. Running from RAM means the map change can't pull the code out from
|
||||
under us.
|
||||
|
||||
The GIME shows a LINEAR 80-byte x 192-row screen from physical video base
|
||||
$14000, which is exactly where CPU $4000 lives with the MMU disabled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..coco.mc6809 import Asm
|
||||
|
||||
CART_BASE = 0xC000
|
||||
IMG_CPU = 0x4000 # image copied here (RAM, visible to GIME video)
|
||||
IMG_LEN = 15360 # 80 bytes/row * 192 rows
|
||||
IMG_END = IMG_CPU + IMG_LEN # $7C00
|
||||
STUB_CPU = 0x3E00 # tiny "enable GIME + idle" stub (RAM)
|
||||
|
||||
# physical video base for CPU $4000 with the MMU disabled. CoCo 3 maps the 64K
|
||||
# CPU space to blocks $38-$3F (bank+0x38); on the 512K machine bank 2 ($4000) is
|
||||
# block $3A = physical $74000. base = FF9D<<11 | FF9E<<3.
|
||||
FF9D = 0xE8 # $74000 >> 11
|
||||
FF9E = 0x00
|
||||
|
||||
|
||||
def build(data_src: int, cres: int, inks, border: int) -> bytes:
|
||||
"""data_src: cart address of the image bytes; cres: GIME colour-resolution
|
||||
bits (0=2col, 1=4col, 2=16col); inks: pen->6-bit-colour list; border: 6-bit."""
|
||||
a = Asm(CART_BASE)
|
||||
a.orcc(0x50) # mask IRQ + FIRQ
|
||||
|
||||
# copy the image from cart ROM down to RAM at $4000
|
||||
a.ldx_imm(data_src)
|
||||
a.ldu_imm(IMG_CPU)
|
||||
a.label("copy")
|
||||
a.lda_postinc("x")
|
||||
a.sta_postinc("u")
|
||||
a.cmpu_imm(IMG_END)
|
||||
a.bne("copy")
|
||||
|
||||
# GIME palette: 16 pen registers $FFB0-$FFBF (unused pens -> 0)
|
||||
for pen in range(16):
|
||||
a.lda_imm(inks[pen] if pen < len(inks) else 0)
|
||||
a.sta_ext(0xFFB0 + pen)
|
||||
a.lda_imm(border & 0x3F)
|
||||
a.sta_ext(0xFF9A) # border colour
|
||||
|
||||
a.lda_imm(0x80)
|
||||
a.sta_ext(0xFF98) # VMODE: graphics, 1 line/row
|
||||
a.lda_imm(0x14 | (cres & 0x03))
|
||||
a.sta_ext(0xFF99) # VRES: 192 lines, 80 bytes/row, CRES
|
||||
a.lda_imm(FF9D)
|
||||
a.sta_ext(0xFF9D) # video base hi
|
||||
a.lda_imm(FF9E)
|
||||
a.sta_ext(0xFF9E) # video base lo
|
||||
|
||||
# plant the RAM stub: lda #0 ; sta $FF90 ; bra * (enable GIME, then idle)
|
||||
for off, byte in enumerate((0x86, 0x00, 0xB7, 0xFF, 0x90, 0x20, 0xFE)):
|
||||
a.lda_imm(byte)
|
||||
a.sta_ext(STUB_CPU + off)
|
||||
a.jmp_ext(STUB_CPU)
|
||||
return a.resolve()
|
||||
Loading…
Add table
Add a link
Reference in a new issue