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/atari/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Atari 8-bit (Atari 400/800/XL/XE) image conversion and bootable disk export."""

101
lenser/atari/atr.py Normal file
View file

@ -0,0 +1,101 @@
"""Write a bootable Atari ``.atr`` disk image natively (no external tools).
A self-booting Atari disk needs no DOS: sector 1 begins with a 6-byte boot header
(flags, sector-count, load-address, init-address); the OS loads ``count`` 128-byte
sectors to the load address and JSRs ``load+6``. We pack the whole viewer+picture
blob that way, so inserting the disk shows the picture.
"""
from __future__ import annotations
SECTOR_SIZE = 128
TOTAL_SECTORS = 720 # single density, ~90K
LOAD_ADDR = 0x2000 # boot load address (blob origin)
DATA_ADDR = 0x4000 # where the bitmap must land (4K-aligned for ANTIC)
class AtrError(RuntimeError):
pass
def build_blob(stub: bytes, data: bytes) -> bytes:
"""Combine the assembled viewer ``stub`` (origin $2000, starts with the 6-byte
boot header) + padding + picture ``data`` (which must reside from $4000)."""
pad = (DATA_ADDR - LOAD_ADDR) - len(stub)
if pad < 0:
raise AtrError(f"viewer stub {len(stub)} bytes exceeds "
f"{DATA_ADDR - LOAD_ADDR} before $4000")
return stub + bytes(pad) + bytes(data)
def _write_atr_sectors(path: str, data: bytes) -> str:
"""Write ``data`` (already laid out as raw sectors) as a single-density ATR,
padded to the full 720-sector disk with the 16-byte ATR header."""
data = bytearray(data)
if len(data) > TOTAL_SECTORS * SECTOR_SIZE:
raise AtrError("slideshow exceeds the 720-sector disk capacity")
data += bytes(TOTAL_SECTORS * SECTOR_SIZE - len(data))
paragraphs = (TOTAL_SECTORS * SECTOR_SIZE) // 16
header = bytes([
0x96, 0x02,
paragraphs & 0xFF, (paragraphs >> 8) & 0xFF,
SECTOR_SIZE & 0xFF, (SECTOR_SIZE >> 8) & 0xFF,
(paragraphs >> 16) & 0xFF, (paragraphs >> 24) & 0xFF,
0, 0, 0, 0, 0, 0, 0, 0,
])
with open(path, "wb") as f:
f.write(header)
f.write(data)
return path
def write_slideshow_atr(path: str, stub: bytes, images: list[bytes],
boot_sectors: int, spi: int) -> str:
"""Write a self-booting slideshow ATR.
Sectors 1..boot_sectors hold ``stub`` (boot header byte 1 patched to
boot_sectors so the OS loads it all); each image then occupies ``spi``
consecutive 128-byte sectors (image i at sector boot_sectors + 1 + i*spi),
matching what the viewer SIO-reads.
"""
if len(stub) > boot_sectors * SECTOR_SIZE:
raise AtrError(f"slideshow viewer {len(stub)} bytes exceeds "
f"{boot_sectors} boot sectors")
blob = bytearray(stub)
blob[1] = boot_sectors # OS loads this many sectors
blob += bytes(boot_sectors * SECTOR_SIZE - len(blob))
for img in images:
if len(img) > spi * SECTOR_SIZE:
raise AtrError("image larger than its sector allotment")
blob += bytes(img) + bytes(spi * SECTOR_SIZE - len(img))
return _write_atr_sectors(path, bytes(blob))
def write_boot_atr(path: str, blob: bytes) -> str:
"""Write ``blob`` as a bootable single-density ATR. Patches the boot sector
count (byte 1) from the blob length."""
nsec = (len(blob) + SECTOR_SIZE - 1) // SECTOR_SIZE
if nsec > 255:
raise AtrError(f"boot blob needs {nsec} sectors (max 255)")
if nsec > TOTAL_SECTORS:
raise AtrError("boot blob exceeds disk capacity")
blob = bytearray(blob)
blob[1] = nsec # boot header sector count
blob += bytes((-len(blob)) % SECTOR_SIZE) # pad to whole sectors
data = bytearray(blob)
data += bytes(TOTAL_SECTORS * SECTOR_SIZE - len(data)) # pad disk to 720 sectors
paragraphs = (TOTAL_SECTORS * SECTOR_SIZE) // 16
header = bytes([
0x96, 0x02, # magic
paragraphs & 0xFF, (paragraphs >> 8) & 0xFF, # size (paragraphs) low
SECTOR_SIZE & 0xFF, (SECTOR_SIZE >> 8) & 0xFF,
(paragraphs >> 16) & 0xFF, (paragraphs >> 24) & 0xFF,
0, 0, 0, 0, 0, 0, 0, 0,
])
with open(path, "wb") as f:
f.write(header)
f.write(data)
return path

20
lenser/atari/car.py Normal file
View file

@ -0,0 +1,20 @@
"""Write an Atari .car cartridge image (CART header + ROM)."""
from __future__ import annotations
import struct
TYPE_STD_16K = 2 # "Standard 16 KB cartridge"
def write_car(rom: bytes, path: str, cart_type: int = TYPE_STD_16K) -> str:
"""Wrap a cart `rom` in the .car container (16-byte CART header + ROM).
The header holds the cartridge type and a checksum (sum of all ROM bytes)."""
header = bytearray(16)
header[0:4] = b"CART"
struct.pack_into(">I", header, 4, cart_type)
struct.pack_into(">I", header, 8, sum(rom) & 0xFFFFFFFF)
with open(path, "wb") as f:
f.write(header)
f.write(rom)
return path

View file

@ -0,0 +1,31 @@
"""Atari conversion dispatch."""
from __future__ import annotations
from ... import imageprep
from .. import palette as apal
from . import gr15
_MODULES = {"gr15": gr15}
for _name in ("gr9", "gr8", "gr15dli", "mono"):
try:
_mod = __import__(f"lenser.atari.convert.{_name}", fromlist=[_name])
_MODULES[_name] = _mod
except Exception:
pass
MODES = list(_MODULES.keys())
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
dither_mode="floyd", intensive=False,
prep_opt: imageprep.PrepOptions | None = None, base_color=None):
prep_opt = prep_opt or imageprep.PrepOptions()
module = _MODULES[mode]
border_rgb = apal.get_palette(palette_name)[0]
img_rgb = imageprep.prepare(
path_or_img, module.WIDTH, module.HEIGHT, module.PIXEL_ASPECT,
prep_opt, border_rgb=border_rgb,
)
return module.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)

View file

@ -0,0 +1,176 @@
"""Shared helpers for the Atari encoders."""
from __future__ import annotations
import numpy as np
DATA_ADDR = 0x4000 # bitmap base
COLOR_ADDR = 0x6000 # colour data base (fixed, after the bitmap)
SPLIT_LINE = 102 # lines that fit in the first 4K ($4000-$4FEF)
BYTES_PER_LINE = 40
LINES = 192
def split_screen(line_bytes: list[bytes]) -> bytes:
"""Lay out 192 screen lines with the 16-byte gap that pushes line 102 onto
the $5000 boundary (so no ANTIC line crosses a 4K boundary), then pad up to
COLOR_ADDR so colour data can follow at a fixed address."""
first = b"".join(line_bytes[:SPLIT_LINE]) # 4080 bytes -> $4000
second = b"".join(line_bytes[SPLIT_LINE:]) # 3600 bytes -> $5000
body = first + bytes(0x1000 - len(first)) + second # gap fills to $5000
pad = (COLOR_ADDR - DATA_ADDR) - len(body)
return body + bytes(pad)
def luminance_lab(img_rgb, plab):
"""Return (image, palette) recast into luminance-only CIELAB (L, 0, 0), so
matching is by brightness alone -- used by the single-hue modes."""
from ...palette import srgb_to_lab
L = srgb_to_lab(img_rgb)[..., 0]
img_mono = np.zeros(img_rgb.shape[:2] + (3,))
img_mono[..., 0] = L
plab_mono = np.zeros_like(plab)
plab_mono[:, 0] = plab[:, 0]
return img_mono, plab_mono
def choose_palette(img_lab: np.ndarray, plab: np.ndarray, k: int,
iters: int = 12) -> list[int]:
"""Pick the ``k`` palette register values (0..255) that best represent the
image, by palette-constrained k-means in CIELAB."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
D = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1) # (N,256)
# k-means++-ish greedy init.
chosen = [int(np.argmin(np.sum((plab - flat.mean(0)) ** 2, axis=-1)))]
for _ in range(k - 1):
md = D[:, chosen].min(axis=1)
improv = np.maximum(0.0, md[:, None] - D).sum(axis=0)
improv[chosen] = -1.0
chosen.append(int(np.argmax(improv)))
# Lloyd refinement, each centroid snapped to its best palette colour.
for _ in range(iters):
assign = np.argmin(D[:, chosen], axis=1)
new = []
for j in range(k):
mask = assign == j
if not mask.any():
new.append(chosen[j])
else:
new.append(int(np.argmin(D[mask].sum(axis=0))))
# keep distinct where possible
if new == chosen:
break
chosen = new
return chosen
def _seg_all(sub, c1all, c2):
"""Distance from each ``sub`` pixel to the segment between every palette colour
(c1all, shape (256,3)) and a fixed endpoint c2. Returns (256, Nsub)."""
seg = c2 - c1all # (256,3)
L = np.sum(seg * seg, axis=1) + 1e-9 # (256,)
rel = sub[None, :, :] - c1all[:, None, :] # (256,Nsub,3)
t = np.clip(np.sum(rel * seg[:, None, :], axis=2) / L[:, None], 0.0, 1.0)
proj = c1all[:, None, :] + t[:, :, None] * seg[:, None, :]
return np.sum((sub[None, :, :] - proj) ** 2, axis=2)
def relevant_candidates(img_lab, plab):
"""Palette colours that are the nearest match to some image pixel -- a small
set (the image's own gamut) to restrict the dither-aware search to."""
flat = img_lab.reshape(-1, 3).astype(np.float32)
if len(flat) > 4000:
flat = flat[::len(flat) // 4000]
d = np.sum((flat[:, None, :] - plab[None, :, :].astype(np.float32)) ** 2, axis=-1)
return np.unique(np.argmin(d, axis=1)).astype(np.int64)
def choose_palette_dither(img_lab, plab, k, init=None, n_sample=900, iters=5,
candidates=None):
"""Dither-aware palette: pick the ``k`` colours whose pairwise *segment* blends
(what error diffusion can reproduce) best cover the image -- so the colours
span the gamut instead of sitting at k-means centroids. Vectorised local
search (all candidates per slot at once) from a k-means start."""
from itertools import combinations
flat = img_lab.reshape(-1, 3)
sub = flat[::max(1, len(flat) // n_sample)] if len(flat) > n_sample else flat
colors = list(init) if init is not None else choose_palette(img_lab, plab, k)
cand = np.asarray(candidates if candidates is not None else range(256), np.int64)
cand_lab = plab[cand].astype(np.float64) # (C,3)
for _ in range(iters):
changed = False
for i in range(k):
others = [colors[j] for j in range(k) if j != i]
fixed = None
for x, y in combinations(others, 2):
s = _seg_all(sub, plab[x][None], plab[y])[0]
fixed = s if fixed is None else np.minimum(fixed, s)
m = None
for o in others:
d = _seg_all(sub, cand_lab, plab[o]) # (C, Nsub)
m = d if m is None else np.minimum(m, d)
if fixed is not None:
m = np.minimum(m, fixed[None, :])
err = m.sum(axis=1) # (C,)
for ci, c in enumerate(cand):
if c in others:
err[ci] = np.inf # avoid duplicate colours
best = int(cand[np.argmin(err)])
if best != colors[i]:
colors[i] = best
changed = True
if not changed:
break
return colors
def quantize_global(img_lab, plab, colors, dither_mode):
"""Dither the whole image to a fixed global set of palette indices."""
from ... import dither
H, W, _ = img_lab.shape
allowed = np.tile(np.array(colors, dtype=np.int64), (H, W, 1))
return dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
def pack_2bpp(val_image: np.ndarray) -> list[bytes]:
"""160-wide 2-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 4):
out.append((row[x] << 6) | (row[x + 1] << 4) | (row[x + 2] << 2) | row[x + 3])
lines.append(bytes(out))
return lines
def pack_4bpp(val_image: np.ndarray) -> list[bytes]:
"""80-wide 4-bits-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 2):
out.append((row[x] << 4) | row[x + 1])
lines.append(bytes(out))
return lines
def pack_1bpp(val_image: np.ndarray) -> list[bytes]:
"""320-wide 1-bit-per-pixel -> list of 192 x 40-byte lines."""
H, W = val_image.shape
lines = []
for y in range(H):
row = val_image[y]
out = bytearray()
for x in range(0, W, 8):
b = 0
for i in range(8):
b = (b << 1) | int(row[x + i])
out.append(b)
lines.append(bytes(out))
return lines

View file

@ -0,0 +1,51 @@
"""Atari GR.15 (ANTIC mode E): 160x192, 4 colours chosen globally from 256.
No per-cell colour limit, so this is a clean 4-colour dithered image.
"""
from __future__ import annotations
import numpy as np
from ... import palette as c64pal # for srgb_to_lab (shared)
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
# Dither-aware palette for error-diffusion modes: pick 4 colours whose blends
# span the image gamut (so dithering reproduces saturated/intermediate shades)
# instead of k-means centroids the dither can't reach.
if dither_mode in DIFFUSION_DITHERS:
colors = _common.choose_palette_dither(img_lab, plab, k=4)
else:
colors = _common.choose_palette(img_lab, plab, k=4)
colors.sort(key=lambda c: plab[c, 0]) # value 0 = darkest (background)
idx = _common.quantize_global(img_lab, plab, colors, dither_mode)
value_of = {c: v for v, c in enumerate(colors)}
val_image = np.vectorize(value_of.get)(idx).astype(np.uint8)
lines = _common.pack_2bpp(val_image)
data = _common.split_screen(lines) + bytes(colors) # 4 colour regs at $6000
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
err = (perceptual_error if dither_mode in DIFFUSION_DITHERS else mean_error)
return Conversion(
mode="gr15", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15", preview_rgb=preview,
error=err(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode, "colors": colors},
)

View file

@ -0,0 +1,84 @@
"""Atari GR.15 + DLI: 160x192, a fresh set of 4 colours every 2 scanlines.
A display-list interrupt rewrites the four colour registers for each 2-line band
(96 bands). Every *single* scanline is impossible -- four register writes don't
fit the inter-DLI window -- but every 2 lines leaves a comfortable budget, and
96x4 colours is still far beyond flat GR.15. The display list (with DLI bits on
the right lines) is generated here and shipped in the data block.
"""
from __future__ import annotations
import numpy as np
from ... import dither, palette as c64pal
from ...convert.base import (Conversion, mean_error, perceptual_error,
DIFFUSION_DITHERS)
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 160, 192
PIXEL_ASPECT = 2.0
BAND_H = 2
N_BANDS = HEIGHT // BAND_H # 96
COLOR_ADDR = 0x6000
DL_ADDR = 0x6400 # display list, after the colour table
def make_dlist() -> bytes:
"""ANTIC mode-E display list, 4K-split, DLI bit on the last line of each
2-line band (odd lines 1..189) so the handler sets up the next band."""
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank lines
dl += bytes([0x4e, 0x00, 0x40]) # line 0: LMS $4000 (no DLI)
for ln in range(1, 102): # lines 1..101
dl.append(0x8e if ln % 2 == 1 else 0x0e)
dl += bytes([0x4e, 0x00, 0x50]) # line 102: LMS $5000 (no DLI)
for ln in range(103, 192): # lines 103..191
dl.append(0x8e if (ln % 2 == 1 and ln != 191) else 0x0e)
dl += bytes([0x41, DL_ADDR & 0xFF, DL_ADDR >> 8]) # JVB -> start
return bytes(dl)
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_lab = c64pal.srgb_to_lab(img_rgb)
band_sets = np.zeros((N_BANDS, 4), dtype=np.int64)
aware = dither_mode in DIFFUSION_DITHERS
iters = 10 if intensive else 5
# restrict the per-band dither-aware search to the image's own gamut (fast).
cand = _common.relevant_candidates(img_lab, plab) if aware else None
for b in range(N_BANDS):
block = img_lab[b * BAND_H:(b + 1) * BAND_H].reshape(-1, 3)
cols = _common.choose_palette(block, plab, k=4, iters=iters)
if aware: # span each band's gamut so dithering blends to the true shade
cols = _common.choose_palette_dither(block, plab, k=4, init=cols,
iters=4 if intensive else 3,
candidates=cand)
cols.sort(key=lambda c: plab[c, 0])
band_sets[b] = cols
allowed = np.repeat((band_sets[np.arange(HEIGHT) // BAND_H])[:, None, :],
WIDTH, axis=1)
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
val = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
for y in range(HEIGHT):
lut = {int(c): v for v, c in enumerate(band_sets[y // BAND_H])}
val[y] = [lut.get(int(p), 0) for p in idx[y]]
bitmap = _common.split_screen(_common.pack_2bpp(val)) # 8192 -> $4000..$5FFF
coltab = band_sets.astype(np.uint8).tobytes() # 384 -> $6000
region = coltab + bytes((DL_ADDR - COLOR_ADDR) - len(coltab)) + make_dlist()
data = bitmap + region
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr15dli", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr15dli", preview_rgb=preview,
error=(perceptual_error if aware else mean_error)(idx, img_lab, plab),
meta={"palette": palette_name, "dither": dither_mode},
)

View file

@ -0,0 +1,40 @@
"""Atari GR.8 (ANTIC mode F): 320x192 hi-res, two tones of one hue.
Highest spatial resolution; carries tone by dithering between background and a
foreground luminance. ``base_color`` picks the hue (None = greyscale).
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 320, 192
PIXEL_ASPECT = 1.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
bg_reg = (hue << 4) | 0x00 # darkest of the hue
fg_reg = (hue << 4) | 0x0E # brightest of the hue
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
idx = _common.quantize_global(img_mono, plab_mono, [bg_reg, fg_reg], dither_mode)
val = (idx == fg_reg).astype(np.uint8)
data = _common.split_screen(_common.pack_1bpp(val)) + bytes([bg_reg, fg_reg])
preview = prgb[idx] # already 320 wide
return Conversion(
mode="gr8", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr8", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,48 @@
"""Atari GR.9 (GTIA): 80x192, 16 luminance shades of one hue.
Excellent greyscale (hue 0) or tinted monochrome (any of 16 hues) -- 16 real
shades, not just dithered. ``base_color`` selects the hue (0..15); None = grey.
"""
from __future__ import annotations
import numpy as np
from ...convert.base import Conversion, perceptual_error
from .. import palette as apal
from . import _common
WIDTH, HEIGHT = 80, 192
PIXEL_ASPECT = 4.0
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
hue = 0 if base_color is None else (int(base_color) & 0x0F)
plab = apal.palette_lab(palette_name)
prgb = apal.get_palette(palette_name).astype(np.uint8)
img_mono, plab_mono = _common.luminance_lab(img_rgb, plab)
ramp = apal.hue_ramp(hue) # 16 register values of this hue
idx = _common.quantize_global(img_mono, plab_mono, ramp, dither_mode)
val = (idx & 0x0F).astype(np.uint8) # GR.9 pixel = 4-bit luminance
# GTIA mode 9 takes the hue from COLBK and the luminance from each pixel. A
# COLBK of exactly $00, though, blanks the whole playfield to black -- the
# register must be non-zero to enable the display. For a tinted hue that is
# automatic ((hue<<4) != 0); for greyscale (hue 0) force a non-zero luminance
# nibble, which the mode ignores for output (luminance still comes from the
# pixels) but which switches the 16-shade display on.
colbk = (hue & 0x0F) << 4
if colbk == 0:
colbk = 0x0E
data = _common.split_screen(_common.pack_4bpp(val)) + bytes([colbk])
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
return Conversion(
mode="gr9", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
index_image=idx.astype(np.uint16), data=data, data_addr=_common.DATA_ADDR,
viewer="gr9", preview_rgb=preview,
error=perceptual_error(idx, img_mono, plab_mono),
meta={"palette": palette_name, "dither": dither_mode, "hue": hue},
)

View file

@ -0,0 +1,16 @@
"""Atari monochrome -- GR.9's 16 luminance shades, exposed as the standard
``mono`` mode for cross-platform parity. Greyscale by default; ``--mono-base``
tints it to one of the GTIA hues (16 real shades of that hue)."""
from __future__ import annotations
from . import gr9
WIDTH, HEIGHT, PIXEL_ASPECT = gr9.WIDTH, gr9.HEIGHT, gr9.PIXEL_ASPECT
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
intensive=False, base_color=None):
conv = gr9.convert(img_rgb, palette_name, dither_mode, intensive,
base_color=base_color)
conv.mode = "mono"
return conv

32
lenser/atari/exporter.py Normal file
View file

@ -0,0 +1,32 @@
"""Build a bootable Atari .atr from a conversion."""
from __future__ import annotations
from ..convert.base import Conversion
from . import atr, car
from .viewer.assemble import assemble_stub, build_cart_rom
def export_atr(conv: Conversion, output_path: str, source_path: str | None = None,
display: str = "forever", seconds: int = 0, video: str = "ntsc") -> str:
"""Write ``conv`` as a self-booting .atr at ``output_path``.
``display`` (forever/key/seconds) + ``seconds`` choose how long the viewer
holds the picture; on key/seconds it warm-starts the OS. ``video`` sets the
frame rate the seconds timer counts (50 PAL / 60 NTSC)."""
if not output_path.lower().endswith(".atr"):
output_path += ".atr"
stub = assemble_stub(conv.viewer, display=display, seconds=seconds, video=video)
blob = atr.build_blob(stub, conv.data)
return atr.write_boot_atr(output_path, blob)
def export_car(conv: Conversion, output_path: str, source_path: str | None = None,
display: str = "forever", seconds: int = 0, video: str = "ntsc") -> str:
"""Write ``conv`` as an autostarting 16K Atari .car cartridge (reuses the
disk viewer, so display-duration works the same)."""
if not output_path.lower().endswith(".car"):
output_path += ".car"
rom = build_cart_rom(conv.viewer, conv.data, display=display,
seconds=seconds, video=video)
return car.write_car(rom, output_path)

96
lenser/atari/palette.py Normal file
View file

@ -0,0 +1,96 @@
"""Atari 8-bit (GTIA) colour palette.
The GTIA produces 256 colour-register values: the high nibble is the hue (0 =
grey, 1..15 = colours around the NTSC wheel) and the low nibble is the luminance.
We generate an NTSC palette with a standard YIQ formula, but if the atari800
emulator's palette file is present we load that instead so the preview matches
exactly what the emulator displays.
"""
from __future__ import annotations
import math
import os
import numpy as np
from ..palette import srgb_to_lab # reuse the CIELAB conversion
# Candidate locations for atari800's bundled NTSC palette (768 raw RGB bytes).
_PAL_FILES = [
"/usr/share/atari800/Palettes/Real.act",
"/usr/share/atari800/default.pal",
"/usr/local/share/atari800/default.pal",
]
def _generate_ntsc() -> np.ndarray:
"""Generate a 256x3 (uint8) NTSC palette via a YIQ approximation."""
pal = np.zeros((256, 3), dtype=np.float64)
# Calibration roughly matching the common Atari NTSC look.
sat = 0.30
hue0 = -58.0 # phase of hue 1, degrees
for reg in range(256):
hue = (reg >> 4) & 0x0F
lum = reg & 0x0F
y = lum / 15.0
if hue == 0:
i = q = 0.0
else:
angle = math.radians(hue0 + (hue - 1) * (360.0 / 15.0))
i = sat * math.cos(angle)
q = sat * math.sin(angle)
r = y + 0.956 * i + 0.621 * q
g = y - 0.272 * i - 0.647 * q
b = y - 1.106 * i + 1.703 * q
pal[reg] = [r, g, b]
return np.clip(pal * 255.0 + 0.5, 0, 255).astype(np.uint8).astype(np.float64)
def _load_pal_file() -> np.ndarray | None:
for path in _PAL_FILES:
try:
with open(path, "rb") as f:
data = f.read()
if len(data) >= 768:
return np.frombuffer(data[:768], dtype=np.uint8).reshape(256, 3).astype(np.float64)
except OSError:
continue
return None
_CACHE: dict[str, np.ndarray] = {}
def get_palette(name: str = "ntsc") -> np.ndarray:
"""Return the 256x3 sRGB palette (float64, 0..255)."""
if "rgb" not in _CACHE:
_CACHE["rgb"] = _load_pal_file()
if _CACHE["rgb"] is None:
_CACHE["rgb"] = _generate_ntsc()
return _CACHE["rgb"]
def palette_lab(name: str = "ntsc") -> np.ndarray:
"""Return the 256 palette colours in CIELAB (256x3)."""
if "lab" not in _CACHE:
_CACHE["lab"] = srgb_to_lab(get_palette(name))
return _CACHE["lab"]
def hue_ramp(hue: int) -> list[int]:
"""The 16 register values of one hue (luminance 0..15) -- for GR.9 / mono."""
return [((hue & 0x0F) << 4) | lum for lum in range(16)]
def nearest_hue(rgb) -> int:
"""Atari hue (0..15) whose mid-luminance colour best matches ``rgb``."""
from ..palette import srgb_to_lab
lab = srgb_to_lab(np.asarray(rgb, dtype=np.float64))
pl = palette_lab()
best, best_d = 0, float("inf")
for h in range(16):
d = float(np.sum((pl[(h << 4) | 8] - lab) ** 2))
if d < best_d:
best, best_d = h, d
return best

View file

@ -0,0 +1 @@
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401

View file

@ -0,0 +1,156 @@
"""Assemble the Atari 6502 boot viewers with `xa` (origin $2000, no load prefix)."""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
SOURCES = {
"gr15": "gr15.s",
"gr9": "gr9.s",
"gr8": "gr8.s",
"gr15dli": "gr15dli.s",
}
_cache: dict[tuple, bytes] = {}
# How long the viewer holds the picture (see atari/viewer/awyt.i).
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
# Slideshow advance behaviour and per-mode multi-image viewer parameters
# (source, ANTIC mode byte, GPRIOR, colour-register layout).
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
SLIDESHOW_PARAMS = {
"gr15": (0x0E, 0x00, 0),
"gr9": (0x0F, 0x40, 1),
"gr8": (0x0F, 0x00, 2),
}
SLIDESHOW_SOURCES = dict.fromkeys(SLIDESHOW_PARAMS, "slideshow_static.s")
SLIDESHOW_SOURCES["gr15dli"] = "slideshow_dli.s" # DLI mode, its own engine
class AssemblerError(RuntimeError):
pass
def have_xa() -> bool:
return shutil.which("xa") is not None
def assemble_stub(viewer_key: str, display: str = "forever", seconds: int = 0,
video: str = "ntsc") -> bytes:
waitmode = WAIT_MODES.get(display, 0)
rate = 50 if video == "pal" else 60
key = (viewer_key, waitmode, int(seconds), rate)
if key in _cache:
return _cache[key]
if not have_xa():
raise AssemblerError(
"The 'xa' (xa65) assembler was not found on PATH.\n"
"Install it with: sudo apt install xa65")
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
# Wrapper sets the options then includes the real source; runs from VIEWER_DIR
# so the source's #include "awyt.i" resolves (xa looks relative to cwd).
wrapper = (
f"#define WAITMODE {waitmode}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f'#include "{SOURCES[viewer_key]}"\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 for {viewer_key}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
raw = f.read()
finally:
os.unlink(wrap)
_cache[key] = raw
return raw
def _xa(wrapper: str, what: str) -> bytes:
"""Assemble a generated wrapper with xa (run from VIEWER_DIR so #includes
resolve); return raw bytes."""
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 for {what}:\n{proc.stdout}{proc.stderr}")
with open(out, "rb") as f:
return f.read()
finally:
os.unlink(wrap)
def build_slideshow_stub(viewer_key: str, n_images: int, base_sec: int, spi: int,
advance: str = "both", seconds: int = 10,
loop: bool = True, video: str = "ntsc") -> bytes:
"""Assemble the multi-image slideshow viewer (origin $2000, no load prefix).
``base_sec`` is the disk sector of image 0 and ``spi`` the sectors per image
(both fixed by the ATR layout); the viewer SIO-reads image i from
base_sec + i*spi. ``advance``/``seconds`` set the per-slide dwell, ``loop``
whether it wraps.
"""
if viewer_key not in SLIDESHOW_SOURCES:
raise AssemblerError(f"no Atari slideshow viewer for mode {viewer_key}")
rate = 50 if video == "pal" else 60
common = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
f"#define WAITSECS {max(0, int(seconds))}\n"
f"#define RATE {rate}\n"
f"#define NIMAGES {n_images}\n"
f"#define LOOPFLAG {1 if loop else 0}\n"
f"#define BASESEC {base_sec}\n"
f"#define SPI {spi}\n")
if viewer_key in SLIDESHOW_PARAMS: # static gr15/gr9/gr8
dlmode, gprior, colormode = SLIDESHOW_PARAMS[viewer_key]
common += (f"#define DLMODE ${dlmode:02X}\n"
f"#define GPRIOR ${gprior:02X}\n"
f"#define COLORMODE {colormode}\n")
return _xa(common + f'#include "{SLIDESHOW_SOURCES[viewer_key]}"\n',
f"slideshow_{viewer_key}")
CART_SIZE = 0x4000 # 16K Atari cartridge ROM at $8000-$BFFF
def build_cart_rom(viewer_key: str, data: bytes, display: str = "forever",
seconds: int = 0, video: str = "ntsc") -> bytes:
"""Assemble the loader + the disk viewer stub + picture data into a 16K
cartridge ROM with the Atari run/init footer at $BFFA."""
stub = assemble_stub(viewer_key, display, seconds, video)
wrapper = (
f"#define STUB_PAGES {(len(stub) + 255) // 256}\n"
f"#define DATA_PAGES {(len(data) + 255) // 256}\n"
f"#define STUB_LEN {len(stub)}\n"
'#include "cart.s"\n')
loader = _xa(wrapper, "cart")
rom = bytearray(loader + stub + bytes(data))
if len(rom) > CART_SIZE:
raise AssemblerError(
f"viewer + image = {len(rom)} bytes, over the 16K cartridge limit")
rom += bytes(CART_SIZE - len(rom))
# Atari cartridge footer at $BFFA (ROM offset $3FFA).
rom[0x3FFA] = 0x00; rom[0x3FFB] = 0x80 # CARTCS run address = $8000
rom[0x3FFC] = 0x00 # cart present
rom[0x3FFD] = 0x04 # option byte: start the cartridge
rom[0x3FFE] = 0x00; rom[0x3FFF] = 0x80 # CARTAD init address = $8000
return bytes(rom)

View file

@ -0,0 +1,48 @@
; Shared "how long to show the picture" epilogue for the Atari viewers.
; Selected at assembly time by WAITMODE (set by atari/viewer/assemble.py):
; 0 forever -- loop, just defeating attract mode
; 1 until a key -- poll CH ($2FC), then warm-start the OS
; 2 WAITSECS secs -- count RTCLOK frames (RATE per second), then warm-start
; "Exit" warm-starts the OS ($E474); on the XL that brings back a usable system.
#if WAITMODE == 0
awloop:
lda #$00
sta $4d ; defeat attract mode
jmp awloop
#endif
#if WAITMODE == 1
lda #$ff
sta $2fc ; clear CH (last-key register; $FF = no key)
awloop:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq awloop
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC, not re-boot
jmp $e474 ; warm-start
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14 ; reset RTCLOK frame counter (16-bit in $13,$14)
awloop:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc awloop
bne awdone
lda $14
cmp #<(WAITSECS*RATE)
bcc awloop
awdone:
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC, not re-boot
jmp $e474 ; warm-start
#endif

View file

@ -0,0 +1,67 @@
; lenser -- Atari 16K cartridge loader ($8000-$BFFF).
;
; Reuses the disk viewer unchanged. The disk viewer "stub" (origin $2000, which
; begins with the 6-byte boot header and contains cont + the display list) and
; the picture data (origin $4000) are stored back-to-back in the cartridge ROM
; after this loader. We copy the stub to $2000 and the data to $4000, then jump
; to cont ($2006) -- exactly the state a disk boot would have produced.
;
; #defines set by viewer/assemble.py --
; STUB_PAGES / DATA_PAGES 256-byte page counts to copy
; STUB_LEN exact stub length (data follows it in ROM)
;
; assembled by atari/viewer/assemble.py via xa
* = $8000
S = $80
D = $82
entry:
; ---- copy the viewer stub to $2000 ----
lda #<stubsrc
sta S
lda #>stubsrc
sta S+1
lda #$00
sta D
lda #$20
sta D+1
ldx #STUB_PAGES
ldy #$00
sc:
lda (S),y
sta (D),y
iny
bne sc
inc S+1
inc D+1
dex
bne sc
; ---- copy the picture data to $4000 ----
lda #<datasrc
sta S
lda #>datasrc
sta S+1
lda #$00
sta D
lda #$40
sta D+1
ldx #DATA_PAGES
ldy #$00
dc:
lda (S),y
sta (D),y
iny
bne dc
inc S+1
inc D+1
dex
bne dc
jmp $2006 ; enter the disk viewer (cont)
stubsrc:
; viewer stub + picture data appended here by the packager
datasrc = stubsrc + STUB_LEN

View file

@ -0,0 +1,50 @@
; lenser -- Atari GR.15 (ANTIC mode E) viewer, self-booting
;
; 160x192, 4 colours chosen globally (no per-cell limit). Boots from sector 1:
; the OS loads this blob to $2000 and JSRs $2006. Appended data (from $4000):
; $4000 bitmap lines 0-101 (4080 bytes)
; $5000 bitmap lines 102-191 (3600 bytes) [split at the 4K ANTIC boundary]
; $6000 4 colour register values (value 0..3)
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched by the ATR writer)
.word $2000 ; load address
.word binit ; init address (DOSINI)
cont: ; $2006 -- OS JSRs here after loading
lda #$22
sta $22f ; SDMCTL = normal playfield + DL DMA
lda #<dlist
sta $230 ; SDLSTL
lda #>dlist
sta $231 ; SDLSTH
lda #$00
sta $26f ; GPRIOR = 0 (no GTIA mode)
; copy the 4 colour registers from $6000
lda $6000
sta $2c8 ; COLBAK (pixel value 0)
lda $6001
sta $2c4 ; COLPF0 (value 1)
lda $6002
sta $2c5 ; COLPF1 (value 2)
lda $6003
sta $2c6 ; COLPF2 (value 3)
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70 ; 24 blank scan lines
.byte $4e ; LMS + mode E
.word $4000
.dsb 101,$0e ; 101 more mode E lines (102 total from $4000)
.byte $4e ; LMS + mode E
.word $5000
.dsb 89,$0e ; 89 more mode E lines (90 total from $5000)
.byte $41 ; JVB
.word dlist

View file

@ -0,0 +1,100 @@
; lenser -- Atari GR.15 + DLI viewer, self-booting
; 160x192, 4 colours rewritten every 2 scanlines by a display-list interrupt.
; Appended data from $4000 ...
; $4000/$5000 bitmap (2 bits per pixel, 4K split)
; $6000 colour table, 96 bands x 4 register values
; $6400 display list (DLI bit on the last line of each 2-line band)
;
; assembled by atari/viewer/assemble.py via xa
CP = $cb ; zero-page pointer into the colour table
* = $2000
boot:
.byte 0
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont:
sei
lda #$22
sta $22f ; SDMCTL
lda #$00
sta $26f ; GPRIOR = 0
lda #$00
sta $230 ; SDLSTL = $6400 (DL shipped in the data)
lda #$64
sta $231
; DLI vector
lda #<dli
sta $200
lda #>dli
sta $201
; colour pointer starts at band 1 (band 0 set by the VBI)
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $2c8
lda $6001
sta $2c4
lda $6002
sta $2c5
lda $6003
sta $2c6
ldy #<vbi
ldx #>vbi
lda #$07
jsr $e45c ; SETVBV (deferred)
lda #$c0
sta $d40e ; NMIEN = DLI + VBI
cli
#include "awyt.i"
binit:
rts
vbi:
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $d01a
lda $6001
sta $d016
lda $6002
sta $d017
lda $6003
sta $d018
jmp $e462 ; XITVBV
dli:
pha
tya
pha
ldy #$00
sta $d40a ; WSYNC
lda (CP),y
sta $d01a
iny
lda (CP),y
sta $d016
iny
lda (CP),y
sta $d017
iny
lda (CP),y
sta $d018
lda CP
clc
adc #$04
sta CP
bcc nocarry
inc CP+1
nocarry:
pla
tay
pla
rti

42
lenser/atari/viewer/gr8.s Normal file
View file

@ -0,0 +1,42 @@
; lenser -- Atari GR.8 (ANTIC mode F) hi-res viewer, self-booting
; 320x192, two tones. Appended data from $4000 ...
; $4000/$5000 bitmap (1 bit per pixel, 4K split)
; $6000 two bytes, background reg then foreground reg
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont:
lda #$22
sta $22f ; SDMCTL
lda #<dlist
sta $230
lda #>dlist
sta $231
lda #$00
sta $26f ; GPRIOR = 0
lda $6000
sta $2c6 ; COLPF2 (background)
sta $2c8 ; COLBAK (border) = background
lda $6001
sta $2c5 ; COLPF1 (foreground luminance)
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70
.byte $4f ; LMS + mode F
.word $4000
.dsb 101,$0f
.byte $4f
.word $5000
.dsb 89,$0f
.byte $41
.word dlist

39
lenser/atari/viewer/gr9.s Normal file
View file

@ -0,0 +1,39 @@
; lenser -- Atari GR.9 (GTIA 16-luminance) viewer, self-booting
; 80x192, 16 shades of one hue. Appended data from $4000 ...
; $4000/$5000 bitmap (4 bits per pixel, 4K split)
; $6000 one byte, COLBAK = hue times 16
;
; assembled by atari/viewer/assemble.py via xa
* = $2000
boot:
.byte 0
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont:
lda #$22
sta $22f ; SDMCTL
lda #<dlist
sta $230
lda #>dlist
sta $231
lda #$40
sta $26f ; GPRIOR = GTIA mode 1 (GR.9)
lda $6000
sta $2c8 ; COLBAK sets the hue
#include "awyt.i"
binit:
rts
dlist:
.byte $70,$70,$70
.byte $4f ; LMS + mode F
.word $4000
.dsb 101,$0f
.byte $4f
.word $5000
.dsb 89,$0f
.byte $41
.word dlist

View file

@ -0,0 +1,248 @@
; lenser -- Atari GR.15 + DLI slideshow viewer (per-line colour).
;
; Self-booting (OS loads this to $2000, JSRs $2006). Steps through NIMAGES
; pictures stored as raw sectors -- image i at sectors BASESEC + i*SPI -- SIO-
; reading SPI sectors into $4000 (bitmap $4000/$5000, colour table $6000 of
; 96 bands x 4, display list $6400) and showing each with a display-list
; interrupt that rewrites the four colour registers every two scanlines.
;
; build-time #defines -- WAITMODE WAITSECS RATE NIMAGES LOOPFLAG BASESEC SPI.
CP = $cb ; zero-page pointer into the colour table
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched)
.word $2000
.word binit
cont: ; $2006 -- OS JSRs here after loading the stub
sei
lda #$00
sta $26f ; GPRIOR = 0
lda #$00
sta $230 ; SDLSTL = $6400 (the loaded display list)
lda #$64
sta $231
lda #<dli
sta $200
lda #>dli
sta $201
ldy #<vbi
ldx #>vbi
lda #$07
jsr $e45c ; SETVBV (deferred VBI)
lda #$00
sta ssidx
cli
ssmain:
lda #$00
sta $d40e ; NMIEN off (no DLI/VBI while loading)
sta $22f ; SDMCTL off (blank)
jsr readimg ; SIO-load image ssidx (IRQ stays on for SIOV)
; re-init the colour pointer + band-0 colours for the new image
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $2c8
lda $6001
sta $2c4
lda $6002
sta $2c5
lda $6003
sta $2c6
lda #$c0
sta $d40e ; NMIEN = DLI + VBI
lda #$22
sta $22f ; SDMCTL = playfield + DL DMA
jsr sswait
inc ssidx
lda ssidx
cmp #NIMAGES
bcc ssmain
#if LOOPFLAG == 1
lda #$00
sta ssidx
jmp ssmain
#else
sei
lda #$00
sta $d40e ; NMIEN off
lda #$40
sta $d40e ; restore VBI only (OS housekeeping)
lda #$00
sta $09
cli
jmp $e474 ; warm-start (exit)
#endif
binit:
rts
; ---- SIO read SPI sectors of image ssidx into $4000 ----
readimg:
lda #<BASESEC
sta secn
lda #>BASESEC
sta secn+1
ldx ssidx
beq rsbuf
radd:
clc
lda secn
adc #SPI
sta secn
bcc ra1
inc secn+1
ra1:
dex
bne radd
rsbuf:
lda #$00
sta $0304
lda #$40
sta $0305
lda #SPI
sta cnt
rloop:
lda #$31
sta $0300
lda #$01
sta $0301
lda #$52
sta $0302
lda #$40
sta $0303
lda #$1f
sta $0306
lda #$80
sta $0308
lda #$00
sta $0309
lda secn
sta $030a
lda secn+1
sta $030b
jsr $e459 ; SIOV
clc
lda $0304
adc #$80
sta $0304
bcc rb1
inc $0305
rb1:
inc secn
bne rb2
inc secn+1
rb2:
dec cnt
bne rloop
rts
; ---- wait (returns; defeats attract mode via $4d) ----
sswait:
#if WAITMODE == 1
lda #$ff
sta $2fc
sw1:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq sw1
rts
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14
sw2:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc sw2
bne sw2d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw2
sw2d:
rts
#endif
#if WAITMODE == 3
lda #$ff
sta $2fc
lda #$00
sta $12
sta $13
sta $14
sw3:
lda #$00
sta $4d
lda $2fc
cmp #$ff
bne sw3d
lda $13
cmp #>(WAITSECS*RATE)
bcc sw3
bne sw3d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw3
sw3d:
rts
#endif
ssidx: .byte 0
secn: .word 0
cnt: .byte 0
; reset colour pointer + band-0 colours each frame (from the loaded $6000 table)
vbi:
lda #$04
sta CP
lda #$60
sta CP+1
lda $6000
sta $d01a
lda $6001
sta $d016
lda $6002
sta $d017
lda $6003
sta $d018
jmp $e462 ; XITVBV
dli:
pha
tya
pha
ldy #$00
sta $d40a ; WSYNC
lda (CP),y
sta $d01a
iny
lda (CP),y
sta $d016
iny
lda (CP),y
sta $d017
iny
lda (CP),y
sta $d018
lda CP
clc
adc #$04
sta CP
bcc nocarry
inc CP+1
nocarry:
pla
tay
pla
rti

View file

@ -0,0 +1,272 @@
; lenser -- Atari static-playfield slideshow viewer (GR.15 / GR.9 / GR.8).
;
; Self-booting (OS loads this to $2000, JSRs $2006). Steps through NIMAGES
; pictures stored as raw sectors -- image i at sectors BASESEC + i*SPI -- SIO-
; reading SPI sectors and showing each before advancing (key / seconds / both).
;
; DOUBLE BUFFERED so the previous slide stays on screen while the next loads (no
; blank between slides). Two RAM buffers alternate as front/back --
; buffer 0 -- bitmap $4000/$5000, colour bytes $6000
; buffer 1 -- bitmap $7000/$8000, colour bytes $9000
; both kept below $A000 so they are RAM even when the BASIC ROM is enabled. The
; display list's two LMS addresses point at the front buffer; each slide is SIO-
; read into the *back* buffer while the front (the previous picture) keeps
; displaying, then -- during a vertical blank so the change isn't torn -- the LMS
; addresses and colour registers are switched to it. ss_hi = ssbuf*$30 is the
; high-byte offset ($00 buffer 0, $30 buffer 1) added to every buffer address.
;
; The mode is fixed for the whole slideshow, chosen by build-time #defines --
; DLMODE ANTIC mode byte ($0e GR.15, $0f GR.9/GR.8)
; GPRIOR GTIA priority/mode ($00, or $40 for GR.9)
; COLORMODE colour-register layout (0 GR.15 / 1 GR.9 / 2 GR.8)
; plus WAITMODE/WAITSECS/RATE, NIMAGES, LOOPFLAG, BASESEC, SPI (see assemble.py).
colptr = $cb ; zero-page pointer to the back buffer's colours
* = $2000
boot:
.byte 0 ; flags
.byte 0 ; sector count (patched by the ATR writer)
.word $2000 ; load address
.word binit ; init address (DOSINI)
cont: ; $2006 -- OS JSRs here after loading the stub
lda #<dlist
sta $230 ; SDLSTL
lda #>dlist
sta $231 ; SDLSTH
lda #GPRIOR
sta $26f ; GPRIOR
lda #$00
sta $22f ; SDMCTL = 0 (blank before the first image only)
sta ssidx
sta ssbuf ; first slide loads into buffer 0
ssmain:
jsr sethi ; ss_hi = ssbuf * $30
jsr readimg ; SIO-load image ssidx into the back buffer
; ---- wait for a vertical blank, then flip to the back buffer ----
; (the front buffer -- the previous slide -- has stayed on screen)
lda $14
vbwait:
cmp $14
beq vbwait ; RTCLOK ticked -> we are in the vertical blank
; display-list LMS -- region 1 = base, region 2 = base + $1000
lda #$00
sta lms1
sta lms2
lda #$40
clc
adc ss_hi
sta lms1+1 ; $40 / $70
lda #$50
clc
adc ss_hi
sta lms2+1 ; $50 / $80
; colour bytes are at base + $2000
lda #$00
sta colptr
lda #$60
clc
adc ss_hi
sta colptr+1 ; $60 / $90
#if COLORMODE == 0
ldy #$00
lda (colptr),y
sta $2c8 ; COLBAK (value 0)
ldy #$01
lda (colptr),y
sta $2c4 ; COLPF0 (value 1)
ldy #$02
lda (colptr),y
sta $2c5 ; COLPF1 (value 2)
ldy #$03
lda (colptr),y
sta $2c6 ; COLPF2 (value 3)
#endif
#if COLORMODE == 1
ldy #$00
lda (colptr),y
sta $2c8 ; COLBAK = hue (GR.9)
#endif
#if COLORMODE == 2
ldy #$00
lda (colptr),y
sta $2c6 ; COLPF2 background
sta $2c8 ; COLBAK border = background
ldy #$01
lda (colptr),y
sta $2c5 ; COLPF1 foreground
#endif
lda #$22
sta $22f ; SDMCTL on (a no-op after the first slide)
jsr sswait
lda ssbuf
eor #$01
sta ssbuf ; next slide loads into the other buffer
inc ssidx
lda ssidx
cmp #NIMAGES
bcc ssmain
#if LOOPFLAG == 1
lda #$00
sta ssidx
jmp ssmain
#else
lda #$00
sta $09 ; clear BOOT? so warmstart enters BASIC
jmp $e474 ; warm-start (exit)
#endif
binit:
rts
; ss_hi = ssbuf * $30 (back-buffer high-byte offset -- $00 buffer 0, $30 buffer 1)
sethi:
ldx #$00
lda ssbuf
beq sh0
ldx #$30
sh0:
stx ss_hi
rts
; ---- SIO read SPI sectors of image ssidx into the back buffer (base+$0000) ----
readimg:
lda #<BASESEC
sta secn
lda #>BASESEC
sta secn+1
ldx ssidx
beq rsbuf
radd:
clc
lda secn
adc #SPI
sta secn
bcc ra1
inc secn+1
ra1:
dex
bne radd
rsbuf:
lda #$00
sta $0304 ; DBUFLO = $00
lda #$40
clc
adc ss_hi
sta $0305 ; DBUFHI = $40 / $70 (back buffer base)
lda #SPI
sta cnt
rloop:
lda #$31
sta $0300 ; DDEVIC = disk
lda #$01
sta $0301 ; DUNIT 1
lda #$52
sta $0302 ; DCOMND R (read)
lda #$40
sta $0303 ; DSTATS = read direction
lda #$1f
sta $0306 ; DTIMLO
lda #$80
sta $0308 ; DBYTLO = 128
lda #$00
sta $0309 ; DBYTHI
lda secn
sta $030a ; DAUX1 sector low
lda secn+1
sta $030b ; DAUX2 sector high
jsr $e459 ; SIOV
clc
lda $0304
adc #$80
sta $0304 ; buffer += 128
bcc rb1
inc $0305
rb1:
inc secn
bne rb2
inc secn+1
rb2:
dec cnt
bne rloop
rts
; ---- wait (returns; defeats attract mode via $4d) ----
sswait:
#if WAITMODE == 1
lda #$ff
sta $2fc ; clear CH
sw1:
lda #$00
sta $4d
lda $2fc
cmp #$ff
beq sw1
rts
#endif
#if WAITMODE == 2
lda #$00
sta $12
sta $13
sta $14 ; reset RTCLOK
sw2:
lda #$00
sta $4d
lda $13
cmp #>(WAITSECS*RATE)
bcc sw2
bne sw2d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw2
sw2d:
rts
#endif
#if WAITMODE == 3
lda #$ff
sta $2fc
lda #$00
sta $12
sta $13
sta $14
sw3:
lda #$00
sta $4d
lda $2fc
cmp #$ff
bne sw3d ; any key ends the slide
lda $13
cmp #>(WAITSECS*RATE)
bcc sw3
bne sw3d
lda $14
cmp #<(WAITSECS*RATE)
bcc sw3
sw3d:
rts
#endif
ssidx: .byte 0
ssbuf: .byte 0 ; 0 or 1 -- which buffer the next slide loads into
ss_hi: .byte 0 ; ssbuf * $30 (back-buffer high-byte offset)
secn: .word 0
cnt: .byte 0
dlist:
.byte $70,$70,$70 ; 24 blank scan lines
.byte DLMODE+$40 ; LMS + mode
lms1:
.word $4000 ; region 1 base (patched to the front buffer)
.dsb 101,DLMODE
.byte DLMODE+$40 ; LMS + mode
lms2:
.word $5000 ; region 2 base (patched to the front buffer)
.dsb 89,DLMODE
.byte $41 ; JVB
.word dlist