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

1
lenser/vic20/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Commodore VIC-20 target for lenser."""

View file

@ -0,0 +1,19 @@
"""Commodore VIC-20 conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from . import hires, mono, multicolor
_MODULES = {"multicolor": multicolor, "hires": hires, "mono": mono}
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="multicolor", palette_name="vic",
dither_mode="floyd", intensive=False, prep_opt=None,
base_color=None):
mod = _MODULES.get(mode, multicolor)
prep_opt = prep_opt or imageprep.PrepOptions()
img_rgb = imageprep.prepare(path_or_img, mod.WIDTH, mod.HEIGHT,
mod.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
return mod.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,171 @@
"""VIC-20 hi-res image encoder.
The VIC-20 has no bitmap mode -- images are drawn from a programmable character
set. In hi-res text mode each 8x8 cell shows a GLOBAL background colour (the
0-bits) and one per-cell FOREGROUND colour (the 1-bits, from colour RAM, limited
to colours 0-7). A screen of 22x23 = 506 cells (176x184 px) needs at most 256
distinct 8x8 patterns (a char index is one byte), so the cell bitmaps are
clustered to 256 representative characters (k-means), exactly like the
Intellivision GRAM dictionary.
Model: global bg (0-15) + per-cell fg (0-7) + 256-char dictionary. For
error-diffusion dithers the colour choices are dither-aware (segment metric), so
bg and fg bracket each cell and dithering blends to the true shade.
Conversion.data carries the bytes the cartridge builder needs:
chardata 2048 bytes (256 chars x 8 rows, copied to $1400)
screen 506 bytes (char indices, copied to $1E00)
color 506 bytes (colour RAM nibbles, fg in low 3 bits, copied to $9600)
bg, border, aux global colour registers
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as vpal
WIDTH, HEIGHT = 176, 184
PIXEL_ASPECT = 1.2 # 176x184 on a ~4:3 screen -> pixels a touch tall
CELL_W, CELL_H = 8, 8
N_COLS, N_ROWS = 22, 23
N_CELLS = N_COLS * N_ROWS # 506
N_CHARS = 256
N_FG = 8 # foreground limited to colours 0-7
def _select_global_bg(cells, plab, seg, candidates):
"""Choose the global background (0-15) + per-cell fg (0-7) that minimise total
dither-aware error. Returns (bg, sets) where sets[ci] = [bg, fg]."""
best_total = np.inf
best = None
for bg in candidates:
avail = [c for c in range(N_FG) if c != bg]
sets, errors = base.select_cell_sets_dither(
cells, plab, avail, n_free=1, fixed=(bg,), seg=seg)
total = errors.sum()
if total < best_total:
best_total = total
best = (bg, sets)
return best
def _select_global_bg_nearest(dist, candidates):
"""Nearest-colour variant (ordered/none dithers)."""
best_total = np.inf
best = None
for bg in candidates:
avail = [c for c in range(N_FG) if c != bg]
sets, errors = base.select_cell_sets(dist, avail, n_free=1, fixed=(bg,))
total = errors.sum()
if total < best_total:
best_total = total
best = (bg, sets)
return best
def _cluster_chars(bitmaps, k=N_CHARS, iters=16, seed=0):
"""k-means on 64-dim {0,1} cell bitmaps -> k binary representative chars."""
pats = bitmaps.astype(np.float64)
uniq, counts = np.unique(bitmaps, axis=0, return_counts=True)
if len(uniq) <= k:
chars = np.zeros((k, 64), np.uint8)
chars[:len(uniq)] = uniq
lut = {tuple(p): i for i, p in enumerate(uniq)}
labels = np.array([lut[tuple(b)] for b in bitmaps])
return chars, labels
order = np.argsort(-counts)[:k]
cent = uniq[order].astype(np.float64)
rng = np.random.default_rng(seed)
for _ in range(iters):
d = ((pats[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
labels = d.argmin(1)
for j in range(k):
msk = labels == j
if msk.any():
cent[j] = pats[msk].mean(0)
else:
cent[j] = pats[rng.integers(len(pats))]
chars = (cent >= 0.5).astype(np.uint8)
d = ((pats[:, None, :] - chars[None, :, :].astype(np.float64)) ** 2).sum(-1)
labels = d.argmin(1)
return chars, labels
def convert(img_rgb, palette_name="vic", dither_mode="floyd",
intensive=False, base_color=None):
plab = vpal.palette_lab()
prgb = vpal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
dist = base.cell_distance(cells, plab)
bg_candidates = range(16) if intensive else vpal.BG_USABLE
if dither_mode in base.DIFFUSION_DITHERS:
seg = base.segment_distances(cells, plab)
bg, sets = _select_global_bg(cells, plab, seg, bg_candidates)
else:
bg, sets = _select_global_bg_nearest(dist, bg_candidates)
fg = sets[:, 1].astype(np.int64)
# dither each cell between the global bg and its fg (order [bg, fg])
allowed = base.per_pixel_allowed(sets, rows, cols, CELL_W, CELL_H, HEIGHT, WIDTH)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
bitmaps = np.zeros((N_CELLS, 64), np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
bitmaps[ci] = (block == fg[ci]).astype(np.uint8).reshape(-1)
chars, labels = _cluster_chars(bitmaps)
# Re-optimise the per-cell fg for the FINAL (clustered) pattern -- the shared
# char's fg pixels may want a different colour than the pre-clustering pick.
for ci in range(N_CELLS):
P = chars[labels[ci]].astype(bool) # 64 px, True = fg
if P.any():
fg[ci] = int(dist[ci][P][:, :N_FG].sum(0).argmin())
# build outputs + an exact preview
prev_idx = np.empty((HEIGHT, WIDTH), np.uint8)
screen = np.zeros(N_CELLS, np.uint8)
color = np.zeros(N_CELLS, np.uint8)
for ci in range(N_CELLS):
cr, cc = divmod(ci, cols)
ch = chars[labels[ci]].reshape(8, 8)
f = int(fg[ci])
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(ch == 1, f, bg)
screen[ci] = labels[ci] & 0xFF
color[ci] = f & 0x07
chardata = np.zeros(N_CHARS * 8, np.uint8)
for t in range(N_CHARS):
rb = chars[t].reshape(8, 8)
for r in range(8):
byte = 0
for x in range(8):
byte = (byte << 1) | int(rb[r, x])
chardata[t * 8 + r] = byte
border = bg if bg < 8 else 0
data = {"chardata": chardata, "screen": screen, "color": color,
"bg": int(bg), "border": int(border), "aux": 0}
preview = prgb[prev_idx]
disp_w = int(round(WIDTH * PIXEL_ASPECT))
xs = (np.arange(disp_w) * WIDTH) // disp_w
preview = preview[:, xs]
return base.Conversion(
mode="hires", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=prev_idx.astype(np.uint16), data=data, data_addr=0,
viewer="hires", preview_rgb=preview,
error=base.perceptual_error(prev_idx, img_lab, plab),
meta={"palette": "vic", "dither": dither_mode, "bg": int(bg)},
)

View file

@ -0,0 +1,83 @@
"""Commodore VIC-20 monochrome / tinted-mono mode.
176x184 hi-res matched by luminance: a single global background (black) and one
foreground colour (white, or a tinted base) used by every cell, so the picture is
carried entirely by the custom character shapes + dithering -- a clean two-tone
image with no per-cell colour budget to spend. Reuses the hires char clustering,
data layout and viewer.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal
from ...convert import base
from .. import palette as vpal
from . import hires
WIDTH, HEIGHT, PIXEL_ASPECT = hires.WIDTH, hires.HEIGHT, hires.PIXEL_ASPECT
def convert(img_rgb, palette_name="vic", dither_mode="floyd",
intensive=False, base_color=None):
plab = vpal.palette_lab()
prgb = vpal.get_palette().astype(np.uint8)
bg = 0 # black background
fg = base_color if base_color in range(1, 8) else 1 # white (or tinted)
ramp = sorted([bg, fg], key=lambda i: plab[i, 0])
idx, sets, rows, cols, err = base.mono_render(
img_rgb, plab, ramp, hires.WIDTH, hires.HEIGHT,
hires.CELL_W, hires.CELL_H, dither_mode, n_free=2)
# cell bitmaps (1 = foreground) reduced to the 256-char dictionary by a
# frequency codebook -- keeps the dithered detail (k-means centroids would
# invent near-solid 'average' chars -> block artefacts).
bitmaps = np.zeros((hires.N_CELLS, 64), np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
block = idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8]
bitmaps[ci] = (block == fg).astype(np.uint8).reshape(-1)
chars, labels = base.mono_codebook(bitmaps, hires.N_CHARS)
img_mono = np.zeros((hires.HEIGHT, hires.WIDTH, 3))
img_mono[..., 0] = c64pal.srgb_to_lab(img_rgb)[..., 0]
plab_mono = np.zeros_like(plab)
plab_mono[:, 0] = plab[:, 0]
prev_idx = np.empty((hires.HEIGHT, hires.WIDTH), np.uint8)
screen = np.zeros(hires.N_CELLS, np.uint8)
color = np.zeros(hires.N_CELLS, np.uint8)
for ci in range(hires.N_CELLS):
cr, cc = divmod(ci, cols)
ch = chars[labels[ci]].reshape(8, 8)
prev_idx[cr * 8:cr * 8 + 8, cc * 8:cc * 8 + 8] = np.where(ch == 1, fg, bg)
screen[ci] = labels[ci] & 0xFF
color[ci] = fg & 0x07
chardata = np.zeros(hires.N_CHARS * 8, np.uint8)
for t in range(hires.N_CHARS):
rb = chars[t].reshape(8, 8)
for r in range(8):
byte = 0
for x in range(8):
byte = (byte << 1) | int(rb[r, x])
chardata[t * 8 + r] = byte
data = {"chardata": chardata, "screen": screen, "color": color,
"bg": int(bg), "border": 0, "aux": 0}
preview = prgb[prev_idx]
disp_w = int(round(hires.WIDTH * hires.PIXEL_ASPECT))
xs = (np.arange(disp_w) * hires.WIDTH) // disp_w
preview = preview[:, xs]
return base.Conversion(
mode="mono", width=hires.WIDTH, height=hires.HEIGHT,
pixel_aspect=hires.PIXEL_ASPECT, index_image=prev_idx.astype(np.uint16),
data=data, data_addr=0, viewer="hires", preview_rgb=preview,
error=base.perceptual_error(prev_idx, img_mono, plab_mono),
meta={"palette": "vic", "dither": dither_mode, "base_color": base_color},
)

View file

@ -0,0 +1,241 @@
"""VIC-20 multicolor image encoder.
Multicolor character mode (colour RAM bit 3 set) gives each 4x8 cell FOUR colours
at half horizontal resolution. Three are GLOBAL registers and one is per-cell:
00 -> background ($900F bits 4-7, any of 16) global
01 -> border ($900F bits 0-2, colours 0-7) global
10 -> foreground (colour RAM low 3 bits, 0-7) per cell
11 -> auxiliary ($900E bits 4-7, any of 16) global
So the warm tones (orange/pink, only available as colours 8-15) can be used as
the global background/auxiliary -- exactly what hi-res can't do -- which makes
this the strong photo mode. We pick the three globals by a dither-aware search
(restricted to the image's relevant colours for speed), then the best per-cell
foreground, dither each cell among its four colours, and cluster the resulting
4x8 role patterns to a 256-character set.
Conversion.data: chardata(2048) screen(506) color(506) bg/border/aux globals.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert import base
from .. import palette as vpal
WIDTH, HEIGHT = 88, 184 # 22x23 cells of 4x8 (double-wide pixels)
PIXEL_ASPECT = 2.4 # logical pixel is 2x wide x the 1.2 cell aspect
CELL_W, CELL_H = 4, 8
N_COLS, N_ROWS = 22, 23
N_CELLS = N_COLS * N_ROWS # 506
N_CHARS = 256
N_FG = 8 # per-cell foreground limited to colours 0-7
def _relevant(cells, plab, k=8):
"""The k palette colours nearest the most cell pixels (search-space prune)."""
d = base.cell_distance(cells, plab) # (n, P, 16)
nearest = d.reshape(-1, 16).argmin(1)
counts = np.bincount(nearest, minlength=16)
return list(np.argsort(-counts)[:k])
def _global_shortlist(cells, plab, seg, dist, cand, dither_aware):
"""Rank every (bg, aux, border) candidate by total per-cell selection error and
return them sorted best-first as (bg, border, aux, sets) tuples. The segment
metric is a fast proxy but mis-ranks the globals (it over-credits blends that
actually muddy), so the caller re-scores the top few by real perceptual error."""
bcand = [c for c in cand if c < 8] or list(range(8))
out = []
seen = set()
for bg in cand:
for aux in cand:
for border in bcand:
key = (bg, aux, border)
if key in seen:
continue
seen.add(key)
if dither_aware:
sets, errors = base.select_cell_sets_dither(
cells, plab, range(N_FG), n_free=1,
fixed=(bg, border, aux), seg=seg)
else:
sets, errors = base.select_cell_sets(
dist, range(N_FG), n_free=1, fixed=(bg, border, aux))
out.append((errors.sum(), int(bg), int(border), int(aux), sets))
out.sort(key=lambda t: t[0])
return [(bg, border, aux, sets) for _, bg, border, aux, sets in out]
def _role_sets(sets):
"""Reorder selection [bg, border, aux, fg] -> role order [bg, border, fg, aux]
(roles 0=bg 1=border 2=fg 3=aux for the 2-bit pixel code)."""
return np.stack([sets[:, 0], sets[:, 1], sets[:, 3], sets[:, 2]], axis=1)
def _role_dither(sets, rows, cols, img_lab, plab, dither_mode):
"""Dither the image among each cell's four colours; returns (idx, role_sets)."""
role_sets = _role_sets(sets)
allowed = base.per_pixel_allowed(role_sets, rows, cols, CELL_W, CELL_H,
HEIGHT, WIDTH)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
return idx, role_sets
def _nearest_perr(role_sets, rows, cols, img_lab, plab):
"""Fast global-ranking proxy: perceptual error of the NEAREST-colour (no
dithering) reconstruction. Vectorised, so all candidate globals can be ranked
cheaply; floyd is too slow to run on every combo."""
allowed = base.per_pixel_allowed(role_sets, rows, cols, CELL_W, CELL_H,
HEIGHT, WIDTH)
colors = plab[allowed]
d = ((img_lab[:, :, None, :] - colors) ** 2).sum(-1)
ch = d.argmin(-1)
idx = np.take_along_axis(allowed, ch[..., None], axis=-1)[..., 0]
return base.perceptual_error(idx, img_lab, plab)
def _onehot(patterns):
"""(n, 32) role codes 0..3 -> (n, 128) one-hot, so clustering compares role
IDENTITY (0 vs 3 are equally different) rather than numeric code proximity."""
n, m = patterns.shape
oh = np.zeros((n, m, 4), np.float64)
oh[np.arange(n)[:, None], np.arange(m)[None, :], patterns] = 1.0
return oh.reshape(n, m * 4)
def _cluster_chars(patterns, k=N_CHARS, iters=16, seed=0):
"""k-means on one-hot role patterns -> k representative chars (codes 0..3)."""
m = patterns.shape[1]
uniq, counts = np.unique(patterns, axis=0, return_counts=True)
if len(uniq) <= k:
chars = np.zeros((k, m), np.uint8)
chars[:len(uniq)] = uniq
lut = {tuple(p): i for i, p in enumerate(uniq)}
labels = np.array([lut[tuple(b)] for b in patterns])
return chars, labels
oh = _onehot(patterns) # (n, 128)
oh_uniq = _onehot(uniq)
order = np.argsort(-counts)[:k]
cent = oh_uniq[order].copy()
rng = np.random.default_rng(seed)
for _ in range(iters):
d = ((oh[:, None, :] - cent[None, :, :]) ** 2).sum(-1)
labels = d.argmin(1)
for j in range(k):
msk = labels == j
if msk.any():
cent[j] = oh[msk].mean(0)
else:
cent[j] = oh[rng.integers(len(oh))]
# representative char = argmax role per pixel
chars = cent.reshape(k, m, 4).argmax(-1).astype(np.uint8)
d = ((oh[:, None, :] - _onehot(chars)[None, :, :]) ** 2).sum(-1)
labels = d.argmin(1)
return chars, labels
def convert(img_rgb, palette_name="vic", dither_mode="floyd",
intensive=False, base_color=None):
plab = vpal.palette_lab()
prgb = vpal.get_palette().astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
cells, rows, cols = base.cells_lab(img_lab, CELL_W, CELL_H)
k = 12 if intensive else 8
cand = _relevant(cells, plab, k)
dither_aware = dither_mode in base.DIFFUSION_DITHERS
dist = base.cell_distance(cells, plab)
seg = base.segment_distances(cells, plab) if dither_aware else None
shortlist = _global_shortlist(cells, plab, seg, dist, cand, dither_aware)
# Rank every candidate by a fast nearest-colour perceptual proxy (floyd is too
# slow to run on all ~hundreds of global combos), then run the FULL pipeline
# (floyd + char clustering + fg re-optimisation) on the most promising few and
# keep the lowest actual post-cluster perceptual error.
ranked = sorted(
((_nearest_perr(_role_sets(s), rows, cols, img_lab, plab), bg, bo, au, s)
for bg, bo, au, s in shortlist),
key=lambda t: t[0])
topn = 16 if intensive else 8
best = None
best_err = np.inf
for _proxy, cbg, cborder, caux, csets in ranked[:topn]:
idx, role_sets = _role_dither(csets, rows, cols, img_lab, plab, dither_mode)
out = _build(idx, role_sets, cbg, cborder, caux, rows, cols, dist,
img_lab, plab, iters=8)
if out[-1] < best_err:
best_err = out[-1]
best = (cbg, cborder, caux, out)
bg, border, aux, (prev_idx, screen, color, chardata, _err) = best
data = {"chardata": chardata, "screen": screen, "color": color,
"bg": int(bg), "border": int(border), "aux": int(aux),
"multicolor": True}
preview = prgb[prev_idx]
disp_w = int(round(WIDTH * PIXEL_ASPECT))
xs = (np.arange(disp_w) * WIDTH) // disp_w
preview = preview[:, xs]
return base.Conversion(
mode="multicolor", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=prev_idx.astype(np.uint16), data=data, data_addr=0,
viewer="multicolor", preview_rgb=preview, error=best_err,
meta={"palette": "vic", "dither": dither_mode,
"bg": int(bg), "border": int(border), "aux": int(aux)},
)
def _build(idx, role_sets, bg, border, aux, rows, cols, dist, img_lab, plab,
iters=16):
"""From a dithered image + per-cell colour roles, build the 256-char set and
the screen/colour/char data; returns (prev_idx, screen, color, chardata,
perceptual_error)."""
fg = role_sets[:, 2].astype(np.int64)
patterns = np.zeros((N_CELLS, CELL_W * CELL_H), np.uint8)
for cr in range(rows):
for cc in range(cols):
ci = cr * cols + cc
block = idx[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4] # (8,4)
r = role_sets[ci] # 4 colours
code = np.zeros((8, 4), np.uint8)
# first-match role for each pixel colour (handles duplicate colours)
for role in (3, 2, 1, 0):
code[block == r[role]] = role
patterns[ci] = code.reshape(-1)
chars, labels = _cluster_chars(patterns, iters=iters)
# re-optimise per-cell fg for the final (clustered) pattern's fg pixels
for ci in range(N_CELLS):
P = (chars[labels[ci]] == 2)
if P.any():
fg[ci] = int(dist[ci][P][:, :N_FG].sum(0).argmin())
prev_idx = np.empty((HEIGHT, WIDTH), np.uint8)
screen = np.zeros(N_CELLS, np.uint8)
color = np.zeros(N_CELLS, np.uint8)
chardata = np.zeros(N_CHARS * 8, np.uint8)
for ci in range(N_CELLS):
cr, cc = divmod(ci, cols)
pat = chars[labels[ci]].reshape(8, 4)
f = int(fg[ci])
lut = np.array([bg, border, f, aux])
prev_idx[cr * 8:cr * 8 + 8, cc * 4:cc * 4 + 4] = lut[pat]
screen[ci] = labels[ci] & 0xFF
color[ci] = (f & 0x07) | 0x08 # bit3 = multicolour
for t in range(N_CHARS):
rb = chars[t].reshape(8, 4)
for r in range(8):
byte = 0
for x in range(4):
byte = (byte << 2) | int(rb[r, x] & 3)
chardata[t * 8 + r] = byte
return (prev_idx, screen, color, chardata,
base.perceptual_error(prev_idx, img_lab, plab))

18
lenser/vic20/exporter.py Normal file
View file

@ -0,0 +1,18 @@
"""Build a Commodore VIC-20 autostart cartridge (.a0) from a conversion."""
from __future__ import annotations
import os
from .viewer import assemble
_EXTS = (".a0", ".crt", ".bin", ".rom")
def export_a0(conv, output_path, source_path=None, display="forever",
seconds=0, video="ntsc"):
if not output_path.lower().endswith(_EXTS):
output_path += ".a0"
rom = assemble.build_cart(conv.data)
with open(output_path, "wb") as f:
f.write(rom)
return output_path

49
lenser/vic20/palette.py Normal file
View file

@ -0,0 +1,49 @@
"""Commodore VIC-20 (VIC 6560/6561) 16-colour palette.
Calibrated against MAME's `vic20`: a display-setup cartridge cycled the global
background ($900F bits 4-7) over all 16 values while a blank-character cell was
sampled with the emulator's screen pixel reader (NORMAL mode -- $900F bit 3 = 1;
with bit 3 = 0 the chip is in REVERSE mode and blank cells show the foreground).
The VIC has 16 colours but the per-cell *foreground* (colour RAM, 3 bits) is
limited to the first 8; the global background / border / auxiliary may be any of
the 16. Colours 8-15 are lighter variants of 0-7.
"""
from __future__ import annotations
import numpy as np
from ..palette import srgb_to_lab
VIC = np.array([
(0x00, 0x00, 0x00), # 0 black
(0xFF, 0xFF, 0xFF), # 1 white
(0xF0, 0x00, 0x00), # 2 red
(0x00, 0xF0, 0xF0), # 3 cyan
(0x60, 0x00, 0x60), # 4 purple
(0x00, 0xA0, 0x00), # 5 green
(0x00, 0x00, 0xF0), # 6 blue
(0xD0, 0xD0, 0x00), # 7 yellow
(0xC0, 0xA0, 0x00), # 8 orange
(0xFF, 0xA0, 0x00), # 9 light orange
(0xF0, 0x80, 0x80), # 10 pink
(0x00, 0xFF, 0xFF), # 11 light cyan
(0xFF, 0x00, 0xFF), # 12 light purple
(0x00, 0xFF, 0x00), # 13 light green
(0x00, 0xA0, 0xFF), # 14 light blue
(0xFF, 0xFF, 0x00), # 15 light yellow
], dtype=np.float64)
# Foreground (colour RAM, 3-bit) is limited to the first 8 colours.
FG_USABLE = list(range(8))
# Background / border / auxiliary may be any of the 16.
BG_USABLE = list(range(16))
def get_palette() -> np.ndarray:
return VIC
def palette_lab() -> np.ndarray:
return srgb_to_lab(VIC)

View file

@ -0,0 +1 @@
"""VIC-20 6502 viewer (assembled by xa)."""

View file

@ -0,0 +1,82 @@
"""Assemble the VIC-20 viewer with `xa` and lay out the 8K autostart cartridge.
The cartridge occupies $A000-$BFFF (8192 bytes). The KERNAL recognises the
"A0CBM" signature at $A004 and jumps through the cold vector at $A000, so no 6502
reset vector is needed. MAME's `vic20 -cart` wants a full 8K image; smaller .a0
files fail with an I/O error.
ROM layout (fixed so the viewer can copy from constant addresses):
$A000 header + viewer code
$A800 CHARSRC character set (2048 bytes -> RAM $1400)
$B000 SCRSRC screen ( 506 bytes -> RAM $1E00)
$B200 COLSRC colour RAM ( 506 bytes -> RAM $9600)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
CART_BASE = 0xA000
CART_SIZE = 0x2000
CHARSRC = 0xA800
SCRSRC = 0xB000
COLSRC = 0xB200
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def _assemble(bg: int, border: int, aux: int) -> bytes:
if not have_xa():
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
wrapper = (f"* = ${CART_BASE:04X}\n"
f"#define CHARSRC ${CHARSRC:04X}\n"
f"#define SCRSRC ${SCRSRC:04X}\n"
f"#define COLSRC ${COLSRC:04X}\n"
f"#define BG {bg & 15}\n"
f"#define BORDER {border & 7}\n"
f"#define AUX {aux & 15}\n"
'#include "viewer.s"\n')
with tempfile.TemporaryDirectory() as td:
out = os.path.join(td, "v.bin")
fd, wrap = tempfile.mkstemp(suffix=".s", prefix="_wrap_", dir=VIEWER_DIR)
try:
with os.fdopen(fd, "w") as f:
f.write(wrapper)
proc = subprocess.run(["xa", "-o", out, os.path.basename(wrap)],
capture_output=True, text=True, cwd=VIEWER_DIR)
if proc.returncode != 0:
raise AssemblerError(f"xa failed:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
code = f.read()
finally:
os.unlink(wrap)
return code
def build_cart(data: dict) -> bytes:
"""data carries chardata(2048), screen(506), color(506) and bg/border/aux."""
code = _assemble(data["bg"], data["border"], data["aux"])
if len(code) > (CHARSRC - CART_BASE):
raise AssemblerError(f"viewer code is {len(code)} bytes, overruns CHARSRC")
chardata = bytes(bytearray(data["chardata"]))
screen = bytes(bytearray(data["screen"]))
color = bytes(bytearray(data["color"]))
rom = bytearray(b"\x00" * CART_SIZE)
rom[0:len(code)] = code
rom[CHARSRC - CART_BASE:CHARSRC - CART_BASE + len(chardata)] = chardata
rom[SCRSRC - CART_BASE:SCRSRC - CART_BASE + len(screen)] = screen
rom[COLSRC - CART_BASE:COLSRC - CART_BASE + len(color)] = color
return bytes(rom)

View file

@ -0,0 +1,95 @@
; VIC-20 image viewer (autostart 8K cartridge at $A000).
;
; The KERNAL does NOT initialise the VIC before launching an autostart cart, so
; this code programs every VIC register itself, copies the character set, screen
; and colour data from the cartridge ROM into RAM, then loops forever showing the
; picture.
;
; Layout in unexpanded RAM -- char set $1400-$1BFF (256 chars), screen $1E00,
; colour RAM $9600 (fixed). $9005 = $FD selects screen $1E00 + char base $1400.
;
; The build (assemble.py) appends the data blocks at the fixed ROM addresses
; CHARSRC / SCRSRC / COLSRC and fills in the BG / BORDER / AUX colour defines.
.word cold ; $A000 cold-start vector
.word cold ; $A002 warm-start vector
.byte $41,$30,$C3,$C2,$CD ; "A0CBM" autostart signature
; zero-page scratch (KERNAL-safe temporaries)
src = $fb ; $fb/$fc copy source pointer
dst = $fd ; $fd/$fe copy destination pointer
cold:
sei
cld
ldx #$ff
txs
; --- copy the 256-char set (2048 bytes = 8 pages) ROM -> $1400 ---
lda #<CHARSRC
sta src
lda #>CHARSRC
sta src+1
lda #$00
sta dst
lda #$14
sta dst+1
ldx #8 ; pages to copy
jsr copypages
; --- copy the screen (506 bytes -> 2 pages) ROM -> $1E00 ---
lda #<SCRSRC
sta src
lda #>SCRSRC
sta src+1
lda #$00
sta dst
lda #$1e
sta dst+1
ldx #2
jsr copypages
; --- copy colour RAM (506 bytes -> 2 pages) ROM -> $9600 ---
lda #<COLSRC
sta src
lda #>COLSRC
sta src+1
lda #$00
sta dst
lda #$96
sta dst+1
ldx #2
jsr copypages
; --- program the VIC ---
lda #$05
sta $9000 ; horizontal origin
lda #$19
sta $9001 ; vertical origin
lda #$96
sta $9002 ; 22 columns + screen address bit 9
lda #$2e
sta $9003 ; 23 rows, 8x8 chars
lda #$fd
sta $9005 ; screen $1E00 + char base $1400
lda #(AUX<<4)
sta $900e ; auxiliary colour (bits 4-7), volume 0
lda #((BG<<4)|$08|BORDER)
sta $900f ; bg (bits 4-7) | normal mode (bit3) | border (0-2)
loop:
jmp loop ; hold the picture forever
; copy X pages of 256 bytes from (src) to (dst)
copypages:
ldy #$00
cp1:
lda (src),y
sta (dst),y
iny
bne cp1
inc src+1
inc dst+1
dex
bne cp1
rts