First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
3
lenser/__init__.py
Normal file
3
lenser/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""8 Bit Lenser -- convert modern images into Commodore 64 disk images with a viewer."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
lenser/a2600/__init__.py
Normal file
1
lenser/a2600/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 2600 (VCS) image conversion and cartridge export."""
|
||||
19
lenser/a2600/convert/__init__.py
Normal file
19
lenser/a2600/convert/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Atari 2600 conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
from ... import imageprep
|
||||
from . import pf
|
||||
|
||||
# "pf" = flicker-free 3-colour/scanline; "pf_il" = temporal interlace (two
|
||||
# frames at 60Hz blend to ~4-6 perceived colours/scanline, at the cost of flicker);
|
||||
# "mono" = pf restricted to the TIA's greys (one hue's 8 luminances).
|
||||
MODES = ["pf", "pf_il", "mono"]
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="pf", palette_name="tia",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None, base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
img_rgb = imageprep.prepare(path_or_img, pf.WIDTH, pf.HEIGHT,
|
||||
pf.PIXEL_ASPECT, prep_opt, border_rgb=(0, 0, 0))
|
||||
return pf.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color, interlace=(mode == "pf_il"),
|
||||
gray=(mode == "mono"))
|
||||
144
lenser/a2600/convert/pf.py
Normal file
144
lenser/a2600/convert/pf.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Atari 2600 playfield image: 40x192, 3 colours per scanline.
|
||||
|
||||
No framebuffer -- the picture is a table the "racing the beam" kernel feeds to
|
||||
the TIA one scanline at a time: a 40-pixel asymmetric playfield (left 20 + right
|
||||
20) plus a shared playfield colour (COLUPF) and TWO background colours -- COLUBK
|
||||
is rewritten mid-line, so the left and right 20px halves each get their own
|
||||
background. Per line we jointly pick the shared foreground and the two
|
||||
backgrounds that minimise dithered error, then dither each half between its two
|
||||
colours.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither
|
||||
from ...convert.base import Conversion, perceptual_error, _box_blur
|
||||
from ...palette import srgb_to_lab
|
||||
from .. import palette as tpal
|
||||
|
||||
WIDTH, HEIGHT = 40, 192
|
||||
HALF = WIDTH // 2
|
||||
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT) # very wide pixels
|
||||
|
||||
|
||||
def _best_triples(img_lab, plab, split_gain=0.12, cand=None):
|
||||
"""Per scanline choose a shared foreground + a background for each 20px half.
|
||||
|
||||
For a fixed foreground f, each half's best error is min over its background
|
||||
bg of sum_px min(d[px,f], d[px,bg]); we pick the f minimising left+right.
|
||||
To avoid a gratuitous vertical seam down the centre, the two halves keep a
|
||||
UNIFIED background unless splitting it reduces the line's error by more than
|
||||
``split_gain`` (so a seam only appears where the image really differs L/R).
|
||||
``cand`` (palette indices) restricts the usable colours, e.g. to the greys.
|
||||
Returns (fg, bgL, bgR) index arrays."""
|
||||
fg = np.zeros(HEIGHT, np.int64)
|
||||
bgL = np.zeros(HEIGHT, np.int64)
|
||||
bgR = np.zeros(HEIGHT, np.int64)
|
||||
forbid = None
|
||||
if cand is not None:
|
||||
forbid = np.full(plab.shape[0], np.inf)
|
||||
forbid[np.asarray(cand)] = 0.0
|
||||
for y in range(HEIGHT):
|
||||
d = np.sum((img_lab[y][:, None, :] - plab[None, :, :]) ** 2, axis=-1) # (40,P)
|
||||
if forbid is not None:
|
||||
d = d + forbid[None, :] # forbid non-candidate colours
|
||||
dl = d[:HALF].T # (P, 20)
|
||||
dr = d[HALF:].T
|
||||
# G?[f,bg] = sum_px min(d[f,px], d[bg,px])
|
||||
GL = np.minimum(dl[:, None, :], dl[None, :, :]).sum(-1) # (P,P)
|
||||
GR = np.minimum(dr[:, None, :], dr[None, :, :]).sum(-1)
|
||||
total = GL.min(1) + GR.min(1)
|
||||
f = int(total.argmin())
|
||||
fg[y] = f
|
||||
split_err = total[f]
|
||||
uni = GL[f] + GR[f] # same bg for both halves
|
||||
bg_uni = int(uni.argmin())
|
||||
uni_err = float(uni[bg_uni])
|
||||
if split_err < uni_err * (1.0 - split_gain):
|
||||
bgL[y] = int(GL[f].argmin())
|
||||
bgR[y] = int(GR[f].argmin())
|
||||
else:
|
||||
bgL[y] = bgR[y] = bg_uni # unified -> no seam on this line
|
||||
return fg, bgL, bgR
|
||||
|
||||
|
||||
def _encode_frame(img_rgb, plab, dither_mode, cand=None):
|
||||
"""Encode one frame -> (9-table data bytes, idx image (192x40 palette indices))."""
|
||||
img_lab = srgb_to_lab(img_rgb)
|
||||
fg, bgL, bgR = _best_triples(img_lab, plab, cand=cand)
|
||||
|
||||
allowed = np.empty((HEIGHT, WIDTH, 2), np.int64)
|
||||
allowed[:, :HALF, 0] = bgL[:, None]; allowed[:, :HALF, 1] = fg[:, None]
|
||||
allowed[:, HALF:, 0] = bgR[:, None]; allowed[:, HALF:, 1] = fg[:, None]
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
bit = (idx == fg[:, None]).astype(np.uint8) # 1 -> playfield
|
||||
|
||||
pf0L = np.zeros(HEIGHT, np.uint8); pf1L = np.zeros(HEIGHT, np.uint8)
|
||||
pf2L = np.zeros(HEIGHT, np.uint8); pf0R = np.zeros(HEIGHT, np.uint8)
|
||||
pf1R = np.zeros(HEIGHT, np.uint8); pf2R = np.zeros(HEIGHT, np.uint8)
|
||||
colubkL = np.zeros(HEIGHT, np.uint8); colubkR = np.zeros(HEIGHT, np.uint8)
|
||||
colupf = np.zeros(HEIGHT, np.uint8)
|
||||
for y in range(HEIGHT):
|
||||
pf0L[y], pf1L[y], pf2L[y] = tpal.pack20(bit[y, :HALF])
|
||||
pf0R[y], pf1R[y], pf2R[y] = tpal.pack20(bit[y, HALF:])
|
||||
colubkL[y] = tpal.color_byte(int(bgL[y]))
|
||||
colubkR[y] = tpal.color_byte(int(bgR[y]))
|
||||
colupf[y] = tpal.color_byte(int(fg[y]))
|
||||
data = b"".join(bytes(t) for t in
|
||||
(pf0L, pf1L, pf2L, pf0R, pf1R, pf2R, colubkL, colubkR, colupf))
|
||||
return data, idx
|
||||
|
||||
|
||||
def _widen(rgb):
|
||||
disp_w = int(round(WIDTH * PIXEL_ASPECT))
|
||||
xs = (np.arange(disp_w) * WIDTH) // disp_w
|
||||
return rgb[:, xs]
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="tia", dither_mode="floyd",
|
||||
intensive=False, base_color=None, interlace=False, gray=False):
|
||||
plab = tpal.palette_lab()
|
||||
pal = tpal.PALETTE.astype(np.float64)
|
||||
img_lab = srgb_to_lab(img_rgb)
|
||||
# mono mode restricts the TIA to one hue's 8 luminances (hue 0 = greys, or
|
||||
# the hue of base_color for a tinted mono); colour index = hue*8 + lum.
|
||||
cand = None
|
||||
if gray:
|
||||
hue = 0 if base_color is None else (int(base_color) // 8)
|
||||
cand = list(range(hue * 8, hue * 8 + 8))
|
||||
|
||||
if interlace:
|
||||
# Frame A approximates the image; frame B is encoded so the per-pixel
|
||||
# blend (averaged in linear light, the way 60Hz flicker is perceived)
|
||||
# lands on the target -- giving ~4-6 perceived colours per scanline.
|
||||
from ...palette import srgb_to_linear, linear_to_srgb
|
||||
dataA, idxA = _encode_frame(img_rgb, plab, dither_mode, cand=cand)
|
||||
renA = pal[idxA]
|
||||
linT, linA = srgb_to_linear(img_rgb.astype(np.float64)), srgb_to_linear(renA)
|
||||
targetB = linear_to_srgb(np.clip(2 * linT - linA, 0, 1))
|
||||
dataB, idxB = _encode_frame(targetB, plab, dither_mode, cand=cand)
|
||||
renB = pal[idxB]
|
||||
blend = np.clip(linear_to_srgb((srgb_to_linear(renA) +
|
||||
srgb_to_linear(renB)) / 2), 0, 255)
|
||||
blend_lab = srgb_to_lab(blend)
|
||||
# perceptual (blurred) error -- credits the 60Hz flicker blend the eye averages
|
||||
err = float(np.sqrt(((_box_blur(blend_lab) - _box_blur(img_lab)) ** 2)
|
||||
.sum(-1)).mean())
|
||||
return Conversion(
|
||||
mode="pf_il", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idxA.astype(np.uint16), data=dataA + dataB, data_addr=0,
|
||||
viewer="a2600", preview_rgb=_widen(blend.astype(np.uint8)),
|
||||
error=err, meta={"palette": "tia", "dither": dither_mode, "interlace": True},
|
||||
)
|
||||
|
||||
data, idx = _encode_frame(img_rgb, plab, dither_mode, cand=cand)
|
||||
return Conversion(
|
||||
mode="mono" if gray else "pf", width=WIDTH, height=HEIGHT,
|
||||
pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=0,
|
||||
viewer="a2600", preview_rgb=_widen(tpal.PALETTE.astype(np.uint8)[idx]),
|
||||
error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "tia", "dither": dither_mode},
|
||||
)
|
||||
12
lenser/a2600/exporter.py
Normal file
12
lenser/a2600/exporter.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Build an Atari 2600 cartridge (.a26) from a conversion."""
|
||||
from __future__ import annotations
|
||||
from .viewer.assemble import build_cart
|
||||
|
||||
|
||||
def export_a26(conv, output_path, source_path=None, display="forever", seconds=0):
|
||||
if not output_path.lower().endswith((".a26", ".bin")):
|
||||
output_path += ".a26"
|
||||
rom = build_cart(conv.data, display=display, seconds=seconds)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(rom)
|
||||
return output_path
|
||||
81
lenser/a2600/palette.py
Normal file
81
lenser/a2600/palette.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Atari 2600 TIA (NTSC) palette + playfield bit packing.
|
||||
|
||||
The TIA colour byte is (hue << 4) | (lum << 1): 16 hues x 8 luminances = 128
|
||||
colours (bit 0 unused, so register values are even 0..254). We generate the
|
||||
NTSC palette with a YIQ formula -- close enough to be recognisable; the encoder
|
||||
matches against it in CIELAB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
TIA_NTSC = [
|
||||
(0,0,0), (44,44,44), (83,83,83), (119,119,119),
|
||||
(154,154,154), (188,188,188), (222,222,222), (255,255,255),
|
||||
(33,11,0), (73,53,0), (109,90,0), (145,126,0),
|
||||
(179,161,44), (213,195,83), (246,229,119), (255,255,154),
|
||||
(60,0,0), (97,35,0), (133,74,0), (168,110,26),
|
||||
(202,146,66), (235,180,103), (255,214,139), (255,247,173),
|
||||
(74,0,0), (111,18,0), (146,58,29), (181,96,68),
|
||||
(215,132,105), (248,167,141), (255,201,175), (255,234,209),
|
||||
(75,0,0), (112,5,43), (147,48,81), (182,86,118),
|
||||
(215,122,153), (249,157,187), (255,191,221), (255,225,254),
|
||||
(62,0,56), (99,1,93), (135,45,129), (170,83,164),
|
||||
(204,119,198), (237,154,232), (255,189,255), (255,222,255),
|
||||
(36,0,97), (75,7,133), (112,49,168), (147,87,202),
|
||||
(182,123,235), (215,159,255), (248,193,255), (255,226,255),
|
||||
(0,0,118), (43,21,153), (82,61,187), (118,99,221),
|
||||
(153,134,254), (188,169,255), (221,203,255), (254,236,255),
|
||||
(0,0,117), (10,38,152), (51,77,187), (89,113,220),
|
||||
(125,149,253), (160,183,255), (195,217,255), (228,250,255),
|
||||
(0,15,94), (0,56,130), (25,93,165), (65,129,199),
|
||||
(102,164,233), (138,198,255), (173,232,255), (206,255,255),
|
||||
(0,32,53), (0,71,91), (10,108,127), (52,143,162),
|
||||
(90,178,196), (126,212,229), (161,245,255), (195,255,255),
|
||||
(0,41,0), (0,80,38), (12,116,77), (53,152,114),
|
||||
(91,186,149), (127,220,183), (162,253,217), (196,255,250),
|
||||
(0,44,0), (0,82,0), (29,118,24), (69,154,64),
|
||||
(106,188,102), (141,221,137), (176,255,172), (210,255,206),
|
||||
(0,38,0), (14,77,0), (56,114,0), (93,149,24),
|
||||
(129,183,64), (164,217,101), (198,250,137), (232,255,171),
|
||||
(7,24,0), (50,64,0), (88,102,0), (124,137,0),
|
||||
(159,172,43), (193,206,82), (226,239,118), (255,255,153),
|
||||
(42,5,0), (80,48,0), (117,86,0), (152,122,6),
|
||||
(186,157,48), (220,191,86), (253,225,122), (255,255,158),
|
||||
]
|
||||
|
||||
PALETTE = np.array(TIA_NTSC, dtype=np.float64) # (128,3) sRGB, calibrated from MAME
|
||||
|
||||
|
||||
def color_byte(index: int) -> int:
|
||||
"""TIA register value (even 0..254) for palette index hue*8+lum."""
|
||||
hue, lum = divmod(index, 8)
|
||||
return (hue << 4) | (lum << 1)
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(PALETTE)
|
||||
|
||||
|
||||
# ---- playfield packing -----------------------------------------------------
|
||||
# The 20 playfield pixels (left to right) map to the PF registers in this order:
|
||||
# px 0-3 PF0 bits 4,5,6,7
|
||||
# px 4-11 PF1 bits 7,6,5,4,3,2,1,0
|
||||
# px 12-19 PF2 bits 0,1,2,3,4,5,6,7
|
||||
|
||||
def pack20(bits) -> tuple[int, int, int]:
|
||||
"""20 pixel on/off values (leftmost first) -> (PF0, PF1, PF2) bytes."""
|
||||
pf0 = pf1 = pf2 = 0
|
||||
for i in range(4):
|
||||
if bits[i]:
|
||||
pf0 |= 1 << (4 + i)
|
||||
for i in range(8):
|
||||
if bits[4 + i]:
|
||||
pf1 |= 1 << (7 - i)
|
||||
for i in range(8):
|
||||
if bits[12 + i]:
|
||||
pf2 |= 1 << i
|
||||
return pf0, pf1, pf2
|
||||
1
lenser/a2600/viewer/__init__.py
Normal file
1
lenser/a2600/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
138
lenser/a2600/viewer/a2600.s
Normal file
138
lenser/a2600/viewer/a2600.s
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
; lenser -- Atari 2600 (VCS) image viewer kernel (6502, "racing the beam").
|
||||
;
|
||||
; No framebuffer -- per visible scanline the kernel feeds the TIA a 40-pixel
|
||||
; asymmetric playfield (left 20 + right 20, rewritten mid-line) plus a shared
|
||||
; playfield colour (COLUPF) and two backgrounds -- COLUBK is set for the left
|
||||
; half during HBLANK then rewritten mid-line for the right half, so each
|
||||
; scanline shows 3 colours (left bg + right bg + foreground). The nine 192-byte
|
||||
; data tables are page-aligned (set by the cartridge builder) so LDA tab,Y never
|
||||
; crosses a page and the kernel stays cycle-exact.
|
||||
;
|
||||
; #defines from the wrapper -- PF0L,PF1L,PF2L,PF0R,PF1R,PF2R,BKL,BKR,PFT (table
|
||||
; bases), WAITMODE (0 forever / 1 fire / 2 seconds), WAITSECS.
|
||||
;
|
||||
; assembled by a2600/viewer/assemble.py via xa
|
||||
|
||||
VSYNC = $00
|
||||
VBLANK = $01
|
||||
WSYNC = $02
|
||||
NUSIZ0 = $04
|
||||
COLUPF = $08
|
||||
COLUBK = $09
|
||||
CTRLPF = $0A
|
||||
PF0 = $0D
|
||||
PF1 = $0E
|
||||
PF2 = $0F
|
||||
INPT4 = $3C
|
||||
FRLO = $80 ; frame counter (seconds mode)
|
||||
FRHI = $81
|
||||
PARITY = $82 ; interlace frame parity (IL mode)
|
||||
|
||||
* = $f000
|
||||
start:
|
||||
sei
|
||||
cld
|
||||
ldx #0
|
||||
txa
|
||||
clr:
|
||||
sta $00,x
|
||||
inx
|
||||
bne clr ; clear $00-$FF (RAM + TIA)
|
||||
ldx #$ff
|
||||
txs
|
||||
|
||||
frame:
|
||||
lda #2
|
||||
sta VSYNC
|
||||
sta WSYNC
|
||||
sta WSYNC
|
||||
sta WSYNC ; 3 VSYNC lines
|
||||
lda #0
|
||||
sta VSYNC
|
||||
lda #2
|
||||
sta VBLANK
|
||||
sta CTRLPF ; reflect off (we draw both halves explicitly)
|
||||
lda #0
|
||||
sta CTRLPF
|
||||
#if IL
|
||||
; temporal interlace -- alternate the two 4K banks (frame A / frame B)
|
||||
; each frame; the kernel is identical in both banks so execution
|
||||
; continues seamlessly and only the data tables differ.
|
||||
lda PARITY
|
||||
eor #1
|
||||
sta PARITY
|
||||
beq selbank0
|
||||
lda $1ff9 ; F8 hotspot -> select bank 1 (AD F9 1F)
|
||||
jmp bankdone
|
||||
selbank0:
|
||||
lda $1ff8 ; F8 hotspot -> select bank 0 (AD F8 1F)
|
||||
bankdone:
|
||||
#endif
|
||||
ldx #36
|
||||
vbl:
|
||||
sta WSYNC
|
||||
dex
|
||||
bne vbl
|
||||
lda #0
|
||||
sta VBLANK
|
||||
|
||||
; ---- visible image, 192 cycle-exact scanlines ----
|
||||
ldy #0
|
||||
kloop:
|
||||
sta WSYNC
|
||||
lda BKL,y ; left background (HBLANK)
|
||||
sta COLUBK
|
||||
lda PFT,y ; shared foreground
|
||||
sta COLUPF
|
||||
lda PF0L,y
|
||||
sta PF0
|
||||
lda PF1L,y
|
||||
sta PF1
|
||||
lda PF2L,y
|
||||
sta PF2
|
||||
lda PF0R,y ; right playfield PF0 (early, before mid-line)
|
||||
sta PF0
|
||||
lda BKR,y ; right background (lands at the half boundary)
|
||||
sta COLUBK
|
||||
lda PF1R,y
|
||||
sta PF1
|
||||
lda PF2R,y
|
||||
sta PF2
|
||||
iny
|
||||
cpy #192
|
||||
bne kloop
|
||||
|
||||
lda #0
|
||||
sta PF0
|
||||
sta PF1
|
||||
sta PF2
|
||||
sta COLUBK
|
||||
|
||||
; ---- hold / exit ----
|
||||
#if WAITMODE == 1
|
||||
lda INPT4
|
||||
bmi over ; bit7 set = fire not pressed
|
||||
jmp start ; pressed -> reset (re-display)
|
||||
#endif
|
||||
#if WAITMODE == 2
|
||||
inc FRLO
|
||||
bne fr_ok
|
||||
inc FRHI
|
||||
fr_ok:
|
||||
lda FRHI
|
||||
cmp #>(WAITSECS*60)
|
||||
bcc over
|
||||
lda FRLO
|
||||
cmp #<(WAITSECS*60)
|
||||
bcc over
|
||||
jmp start
|
||||
#endif
|
||||
over:
|
||||
lda #2
|
||||
sta VBLANK
|
||||
ldx #30
|
||||
ovl:
|
||||
sta WSYNC
|
||||
dex
|
||||
bne ovl
|
||||
jmp frame
|
||||
92
lenser/a2600/viewer/assemble.py
Normal file
92
lenser/a2600/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Assemble the Atari 2600 kernel with `xa` and lay out the cartridge.
|
||||
|
||||
A static image is a 4K cart (one kernel + nine 192-byte tables). An interlace
|
||||
image is an 8K F8-bankswitch cart: two 4K banks, each holding the SAME kernel
|
||||
plus one of the two table sets (frame A / frame B). The kernel toggles the bank
|
||||
once per frame, so the two frames alternate at 60Hz and blend to ~4-6 perceived
|
||||
colours per scanline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
WAIT_MODES = {"forever": 0, "fire": 1, "key": 1, "seconds": 2}
|
||||
|
||||
# page-aligned data tables ($F100..$F900)
|
||||
TABLES = {"PF0L": 0xF100, "PF1L": 0xF200, "PF2L": 0xF300, "PF0R": 0xF400,
|
||||
"PF1R": 0xF500, "PF2R": 0xF600, "BKL": 0xF700, "BKR": 0xF800,
|
||||
"PFT": 0xF900}
|
||||
ORDER = ["PF0L", "PF1L", "PF2L", "PF0R", "PF1R", "PF2R", "BKL", "BKR", "PFT"]
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def _assemble_kernel(display: str, seconds: int, interlace: bool) -> bytes:
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
|
||||
"Install it with: sudo apt install xa65")
|
||||
waitmode = WAIT_MODES.get(display, 0)
|
||||
defs = "".join(f"#define {n} ${a:04X}\n" for n, a in TABLES.items())
|
||||
wrapper = (defs +
|
||||
f"#define WAITMODE {waitmode}\n"
|
||||
f"#define WAITSECS {max(0, int(seconds))}\n"
|
||||
f"#define IL {1 if interlace else 0}\n"
|
||||
'#include "a2600.s"\n')
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = os.path.join(td, "k.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:
|
||||
kernel = f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
if len(kernel) > 0x100:
|
||||
raise AssemblerError(f"kernel is {len(kernel)} bytes, overruns the table "
|
||||
"page at $F100")
|
||||
return kernel
|
||||
|
||||
|
||||
def _lay_bank(kernel: bytes, data: bytes) -> bytearray:
|
||||
"""One 4096-byte bank: kernel at $F000, nine tables, reset/IRQ vectors."""
|
||||
rom = bytearray(b"\x00" * 4096)
|
||||
rom[0:len(kernel)] = kernel
|
||||
tables = [data[i * 192:(i + 1) * 192] for i in range(9)]
|
||||
for name, tab in zip(ORDER, tables):
|
||||
off = TABLES[name] - 0xF000
|
||||
rom[off:off + 192] = tab
|
||||
rom[0xFFC] = 0x00 # reset vector lo -> $F000
|
||||
rom[0xFFD] = 0xF0
|
||||
rom[0xFFE] = 0x00 # IRQ/BRK vector
|
||||
rom[0xFFF] = 0xF0
|
||||
return rom
|
||||
|
||||
|
||||
def build_cart(data: bytes, display: str = "forever", seconds: int = 0) -> bytes:
|
||||
"""data = nine 192-byte tables (static, 4K cart) or eighteen (two sets ->
|
||||
interlace 8K F8 cart). Returns the cart image with reset vectors set."""
|
||||
if len(data) == 9 * 192:
|
||||
kernel = _assemble_kernel(display, seconds, interlace=False)
|
||||
return bytes(_lay_bank(kernel, data))
|
||||
if len(data) == 18 * 192:
|
||||
kernel = _assemble_kernel(display, seconds, interlace=True)
|
||||
bank0 = _lay_bank(kernel, data[:9 * 192])
|
||||
bank1 = _lay_bank(kernel, data[9 * 192:])
|
||||
return bytes(bank0 + bank1)
|
||||
raise ValueError(f"expected 1728 or 3456 bytes of tables, got {len(data)}")
|
||||
1
lenser/a5200/__init__.py
Normal file
1
lenser/a5200/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 5200 SuperSystem target for lenser."""
|
||||
23
lenser/a5200/convert/__init__.py
Normal file
23
lenser/a5200/convert/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Atari 5200 conversion dispatch.
|
||||
|
||||
The 5200 has the same ANTIC + GTIA graphics hardware (and the same 256-colour GTIA
|
||||
palette) as the Atari 400/800, so it reuses the Atari 8-bit encoders unchanged.
|
||||
GR.9 doubles as the monochrome / tinted-mono mode (16 real luminance shades of one
|
||||
hue). GR.15+DLI is omitted: per-scanline DLI colour changes need OS NMI vectoring
|
||||
the 5200 lacks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...atari.convert import convert_image as _atari_convert
|
||||
|
||||
MODES = ["gr15", "gr9", "gr8", "mono"]
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="gr15", palette_name="ntsc",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
if mode not in MODES:
|
||||
mode = "gr15"
|
||||
return _atari_convert(path_or_img, mode=mode, palette_name="ntsc",
|
||||
dither_mode=dither_mode, intensive=intensive,
|
||||
prep_opt=prep_opt, base_color=base_color)
|
||||
17
lenser/a5200/exporter.py
Normal file
17
lenser/a5200/exporter.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Build an Atari 5200 cartridge (.a52) from a conversion."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .viewer import assemble
|
||||
|
||||
_EXTS = (".a52", ".bin", ".car", ".rom")
|
||||
|
||||
|
||||
def export_a52(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="ntsc"):
|
||||
if not output_path.lower().endswith(_EXTS):
|
||||
output_path += ".a52"
|
||||
rom = assemble.build_cart(conv.mode, bytes(conv.data), display=display,
|
||||
seconds=seconds, video=video)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(rom)
|
||||
return output_path
|
||||
1
lenser/a5200/viewer/__init__.py
Normal file
1
lenser/a5200/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 5200 6502 viewer (assembled by xa)."""
|
||||
131
lenser/a5200/viewer/assemble.py
Normal file
131
lenser/a5200/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Assemble the Atari 5200 viewer with `xa` and lay out the 32K cartridge.
|
||||
|
||||
The cartridge fills $4000-$BFFF. The 5200 BIOS jumps to the address at $BFFE-F
|
||||
(duplicated at $BFE8-9, with $BFFC=0 / $BFFD=$FF) -- verified in MAME a5200.
|
||||
|
||||
ANTIC DMAs the bitmap and display list straight from cartridge ROM, so we only
|
||||
place them at fixed cart addresses and point ANTIC at them:
|
||||
$4000 viewer code
|
||||
$4100 GTIA register script (offset, value pairs, $FF-terminated)
|
||||
$4200 display list (ANTIC reads it here)
|
||||
$6000 bitmap (the converter's 4K-split blob -> $6000 / $7000)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
CART_BASE = 0x4000
|
||||
CART_SIZE = 0x8000
|
||||
SCRIPT_ADDR = 0x4100
|
||||
DLIST_ADDR = 0x4200
|
||||
BITMAP_ADDR = 0x6000 # 4K-aligned: blob $4000/$5000 -> $6000/$7000
|
||||
SPLIT_LINE = 102 # matches atari _common.split_screen
|
||||
LINES = 192
|
||||
|
||||
# ANTIC mode byte + GTIA register script per display mode. Script entries are
|
||||
# (GTIA offset from $C000, colour value); colours come from the converter blob.
|
||||
ANTIC_MODE = {"gr15": 0x0E, "gr8": 0x0F, "gr9": 0x0F}
|
||||
|
||||
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
|
||||
|
||||
|
||||
def _gtia_script(mode: str, colors) -> bytes:
|
||||
c = list(colors)
|
||||
if mode == "gr15": # 4 colours: COLBK, COLPF0, COLPF1, COLPF2
|
||||
regs = [(0x1A, c[0]), (0x16, c[1]), (0x17, c[2]), (0x18, c[3]), (0x1B, 0x00)]
|
||||
elif mode == "gr8": # 2 colours: background (COLPF2+COLBK), fg (COLPF1)
|
||||
regs = [(0x18, c[0]), (0x1A, c[0]), (0x17, c[1]), (0x1B, 0x00)]
|
||||
else: # gr9: hue in COLBK, GTIA mode 9 via PRIOR
|
||||
regs = [(0x1A, c[0]), (0x1B, 0x40)]
|
||||
out = bytearray()
|
||||
for off, val in regs:
|
||||
out += bytes([off & 0xFF, val & 0xFF])
|
||||
out.append(0xFF) # terminator
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _dlist(mode: str) -> bytes:
|
||||
m = ANTIC_MODE[mode]
|
||||
dl = bytearray([0x70, 0x70, 0x70]) # 24 blank scan lines
|
||||
dl += bytes([0x40 | m, 0x00, 0x60]) # LMS -> $6000
|
||||
dl += bytes([m] * (SPLIT_LINE - 1)) # 102 lines from $6000
|
||||
dl += bytes([0x40 | m, 0x00, 0x70]) # LMS -> $7000
|
||||
dl += bytes([m] * (LINES - SPLIT_LINE - 1)) # 90 lines from $7000
|
||||
dl += bytes([0x41, DLIST_ADDR & 0xFF, DLIST_ADDR >> 8]) # JVB -> dlist (loop)
|
||||
return bytes(dl)
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def _assemble(display: str = "forever", seconds: int = 0,
|
||||
video: str = "ntsc") -> bytes:
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
|
||||
"Install it with: sudo apt install xa65")
|
||||
waitmode = WAIT_MODES.get(display, 0)
|
||||
rate = 50 if video == "pal" else 60
|
||||
wrapper = (f"#define SCRIPT ${SCRIPT_ADDR:04X}\n"
|
||||
f"#define DLIST ${DLIST_ADDR:04X}\n"
|
||||
f"#define WAITMODE {waitmode}\n"
|
||||
f"#define WAITSECS {max(1, int(seconds))}\n"
|
||||
f"#define RATE {rate}\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:
|
||||
return f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
|
||||
|
||||
def build_cart(mode: str, data: bytes, display: str = "forever",
|
||||
seconds: int = 0, video: str = "ntsc") -> bytes:
|
||||
"""data = the atari converter blob (bitmap 4K-split at offset 0/0x1000, colour
|
||||
register bytes from offset 0x2000)."""
|
||||
if mode not in ANTIC_MODE:
|
||||
raise ValueError(f"unsupported 5200 mode {mode}")
|
||||
code = _assemble(display, seconds, video)
|
||||
bitmap = data[:0x2000]
|
||||
colors = data[0x2000:]
|
||||
script = _gtia_script(mode, colors)
|
||||
dlist = _dlist(mode)
|
||||
|
||||
rom = bytearray(b"\xff" * CART_SIZE)
|
||||
def place(addr, blob):
|
||||
off = addr - CART_BASE
|
||||
rom[off:off + len(blob)] = blob
|
||||
place(CART_BASE, code)
|
||||
place(SCRIPT_ADDR, script)
|
||||
place(DLIST_ADDR, dlist)
|
||||
place(BITMAP_ADDR, bitmap)
|
||||
|
||||
# cartridge header (verified in MAME a5200)
|
||||
def put16(addr, val):
|
||||
off = addr - CART_BASE
|
||||
rom[off] = val & 0xFF
|
||||
rom[off + 1] = (val >> 8) & 0xFF
|
||||
put16(0xBFFE, CART_BASE) # start address -- BIOS jumps here
|
||||
put16(0xBFE8, CART_BASE) # duplicate (validation)
|
||||
rom[0xBFFC - CART_BASE] = 0x00
|
||||
rom[0xBFFD - CART_BASE] = 0xFF
|
||||
return bytes(rom)
|
||||
53
lenser/a5200/viewer/awyt5200.i
Normal file
53
lenser/a5200/viewer/awyt5200.i
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
; Atari 5200 "how long to show the picture" epilogue (no OS, pure hardware).
|
||||
; Selected at assembly time by WAITMODE (set by a5200/viewer/assemble.py):
|
||||
; 0 forever -- just loop
|
||||
; 1 until a button -- poll the keypad (POKEY) and triggers (GTIA), then reset
|
||||
; 2 WAITSECS secs -- count frames via ANTIC VCOUNT, then reset
|
||||
; "Exit" jumps through the BIOS reset vector, which re-runs the cartridge and so
|
||||
; re-displays the picture (a cartridge console has no OS to return to).
|
||||
|
||||
#if WAITMODE == 0
|
||||
awloop:
|
||||
jmp awloop
|
||||
#endif
|
||||
|
||||
#if WAITMODE == 1
|
||||
lda #$03
|
||||
sta $e80f ; POKEY SKCTL -- enable keypad scanning
|
||||
awloop:
|
||||
lda $c010 ; GTIA TRIG0 (fire button); 0 = pressed
|
||||
and #$01
|
||||
beq awexit
|
||||
lda $e80f ; POKEY SKSTAT; bit2 = 0 while a keypad key is down
|
||||
and #$04
|
||||
bne awloop
|
||||
awexit:
|
||||
jmp ($fffc) ; BIOS reset -> re-display
|
||||
#endif
|
||||
|
||||
#if WAITMODE == 2
|
||||
lda #<(WAITSECS*RATE)
|
||||
sta $80
|
||||
lda #>(WAITSECS*RATE)
|
||||
sta $81
|
||||
awloop:
|
||||
lda $80
|
||||
ora $81
|
||||
beq awexit ; counted all the frames
|
||||
vw1:
|
||||
lda $d40b ; ANTIC VCOUNT
|
||||
bne vw1 ; wait for top of frame (VCOUNT = 0)
|
||||
vw2:
|
||||
lda $d40b
|
||||
beq vw2 ; wait until it leaves the top -> one frame elapsed
|
||||
lda $80
|
||||
sec
|
||||
sbc #$01
|
||||
sta $80
|
||||
lda $81
|
||||
sbc #$00
|
||||
sta $81
|
||||
jmp awloop
|
||||
awexit:
|
||||
jmp ($fffc) ; BIOS reset -> re-display
|
||||
#endif
|
||||
51
lenser/a5200/viewer/viewer.s
Normal file
51
lenser/a5200/viewer/viewer.s
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
; Atari 5200 image viewer -- self-contained, no OS.
|
||||
;
|
||||
; The 5200 has no operating system (only a small BIOS that jumps straight to the
|
||||
; cartridge), so this code programmes ANTIC and GTIA hardware registers directly.
|
||||
; ANTIC DMAs the bitmap and display list straight out of cartridge ROM, so nothing
|
||||
; needs copying to RAM -- the viewer only applies the GTIA colour registers, points
|
||||
; ANTIC at the display list, enables DMA, and holds.
|
||||
;
|
||||
; GTIA is at $C000 on the 5200 (not $D000 as on the 400/800); ANTIC is at $D400.
|
||||
;
|
||||
; #defines from viewer/assemble.py --
|
||||
; DLIST cartridge address of the display list
|
||||
; SCRIPT cartridge address of the GTIA register script -- (offset, value)
|
||||
; byte pairs applied as $C000+offset = value, terminated by $FF
|
||||
|
||||
* = $4000
|
||||
|
||||
start:
|
||||
sei
|
||||
cld
|
||||
ldx #$ff
|
||||
txs
|
||||
lda #$00
|
||||
sta $d40e ; NMIEN = 0 -- no interrupts
|
||||
sta $d400 ; DMACTL = 0 while we set up
|
||||
|
||||
; ---- apply the GTIA register script (offset, value pairs) ----
|
||||
ldx #$00
|
||||
sloop:
|
||||
lda SCRIPT,x
|
||||
cmp #$ff
|
||||
beq sdone
|
||||
tay ; Y = register offset within GTIA
|
||||
inx
|
||||
lda SCRIPT,x
|
||||
sta $c000,y ; GTIA register = value
|
||||
inx
|
||||
jmp sloop
|
||||
sdone:
|
||||
; ---- point ANTIC at the display list ----
|
||||
lda #<DLIST
|
||||
sta $d402 ; DLISTL
|
||||
lda #>DLIST
|
||||
sta $d403 ; DLISTH
|
||||
|
||||
; ---- enable DMA -- normal playfield + display-list DMA ----
|
||||
lda #$22
|
||||
sta $d400 ; DMACTL
|
||||
|
||||
; ---- hold the picture (forever / until a button / N seconds) ----
|
||||
#include "awyt5200.i"
|
||||
1
lenser/a7800/__init__.py
Normal file
1
lenser/a7800/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 7800 ProSystem target for lenser (MARIA display processor)."""
|
||||
30
lenser/a7800/convert/__init__.py
Normal file
30
lenser/a7800/convert/__init__.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Atari 7800 conversion dispatch.
|
||||
|
||||
The 7800's MARIA chip has its own architecture (display lists + zones + objects),
|
||||
nothing like ANTIC/GTIA -- but its 160A graphics mode is 2 bits/pixel, 4 px/byte,
|
||||
exactly the packing of ANTIC mode E (Atari GR.15). So the per-mode encoders pack
|
||||
160x192 2bpp bitmaps the same way GR.15 does and reuse the Atari 256-colour NTSC
|
||||
palette + dither-aware selection; the MARIA-specific display lists are built by
|
||||
the cartridge packer (viewer/assemble.py).
|
||||
|
||||
Modes: ``c160`` = 25-colour 160A (8 MARIA palettes, per-segment palette choice);
|
||||
``mono`` = luminance two-tone / tinted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import c160, mono
|
||||
|
||||
_MODULES = {"c160": c160, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="c160", palette_name="ntsc",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES.get(mode, c160)
|
||||
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)
|
||||
127
lenser/a7800/convert/c160.py
Normal file
127
lenser/a7800/convert/c160.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Atari 7800 MARIA 160A colour mode -- 160x192, 4 px/byte (2bpp).
|
||||
|
||||
MARIA gives 8 palettes of 3 colours each plus one shared background = up to 25
|
||||
colours on screen. The line is split into 8 objects of 20 px (5 bytes) each, and
|
||||
every object may use a different palette, so each 20 px segment can pick the
|
||||
3-colour palette (+ shared background) that fits it best -- chosen globally by
|
||||
clustering the segments into 8 palettes. The 2bpp bitmap packing is identical to
|
||||
Atari GR.15, so the Atari encoder helpers are reused.
|
||||
|
||||
Conversion.data = bitmap(7680) + seg_palettes(192*8) + colours(25):
|
||||
bitmap 192 lines x 40 bytes, 2bpp, value 0..3 per pixel
|
||||
seg_palettes one palette index (0..7) per 20px segment (8 per line)
|
||||
colours [BACKGRND, P0C1,P0C2,P0C3, P1C1..P7C3] (MARIA register order)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import palette as c64pal
|
||||
from ...convert.base import Conversion, perceptual_error, DIFFUSION_DITHERS
|
||||
from ...atari import palette as apal
|
||||
from ...atari.convert import _common
|
||||
|
||||
WIDTH, HEIGHT = 160, 192
|
||||
PIXEL_ASPECT = 2.0
|
||||
SEG_W = 40 # pixels per object (10 bytes, 2bpp)
|
||||
N_SEG = WIDTH // SEG_W # 4 objects per line (MARIA DMA budget)
|
||||
N_PAL = 8 # MARIA palettes
|
||||
|
||||
|
||||
def _pack_bitmap(val_image):
|
||||
return b"".join(bytes(b) for b in _common.pack_2bpp(val_image))
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
|
||||
intensive=False, base_color=None, candidates=None, _mode="c160"):
|
||||
plab = apal.palette_lab("ntsc")
|
||||
prgb = apal.get_palette("ntsc").astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
|
||||
bg, palettes, seg_pal, idx = _solve(img_lab, plab, dither_mode, intensive,
|
||||
candidates)
|
||||
|
||||
# value image (0..3) per pixel: 0 = background, 1..3 = the segment palette's
|
||||
# three colours; build it from idx (palette indices) + the per-segment palette.
|
||||
val = np.zeros((HEIGHT, WIDTH), np.uint8)
|
||||
for row in range(HEIGHT):
|
||||
for s in range(N_SEG):
|
||||
x0 = s * SEG_W
|
||||
pal = [bg] + palettes[seg_pal[row, s]]
|
||||
lut = {c: v for v, c in enumerate(pal)}
|
||||
block = idx[row, x0:x0 + SEG_W]
|
||||
val[row, x0:x0 + SEG_W] = [lut[int(c)] for c in block]
|
||||
|
||||
bitmap = _pack_bitmap(val)
|
||||
seg_bytes = bytes(seg_pal.reshape(-1).astype(np.uint8))
|
||||
colours = bytearray([bg])
|
||||
for p in range(N_PAL):
|
||||
colours += bytes(palettes[p])
|
||||
data = bitmap + seg_bytes + bytes(colours)
|
||||
|
||||
err = perceptual_error(idx, img_lab, plab)
|
||||
return Conversion(
|
||||
mode=_mode, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=0,
|
||||
viewer="a7800", preview_rgb=np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1),
|
||||
error=err, meta={"palette": "ntsc", "dither": dither_mode, "bg": bg},
|
||||
)
|
||||
|
||||
|
||||
def _pick(pixels_lab, plab, k, diff, candidates):
|
||||
"""Pick k palette colours best representing the given pixels."""
|
||||
if diff:
|
||||
return _common.choose_palette_dither(pixels_lab, plab, k=k,
|
||||
candidates=candidates)
|
||||
if candidates is not None:
|
||||
sub = plab[candidates]
|
||||
return [candidates[i] for i in _common.choose_palette(pixels_lab, sub, k=k)]
|
||||
return _common.choose_palette(pixels_lab, plab, k=k)
|
||||
|
||||
|
||||
def _solve(img_lab, plab, dither_mode, intensive, candidates=None):
|
||||
"""Choose a shared background + 8 three-colour palettes by clustering the
|
||||
image's 40px segments by colour (so each palette is tuned to a group of
|
||||
similar segments), assign each segment its cluster's palette, and dither.
|
||||
Returns (bg, palettes[8][3], seg_pal, idx)."""
|
||||
from ... import dither as _dith
|
||||
diff = dither_mode in DIFFUSION_DITHERS
|
||||
H, W, _ = img_lab.shape
|
||||
|
||||
# shared background = darkest of a small global palette
|
||||
gp = _pick(img_lab, plab, 8, diff, candidates)
|
||||
bg = min(gp, key=lambda c: plab[c, 0])
|
||||
|
||||
# cluster the H*N_SEG segments by their mean colour into N_PAL groups
|
||||
seg_means = img_lab.reshape(H, N_SEG, SEG_W, 3).mean(axis=2) # (H,N_SEG,3)
|
||||
feats = seg_means.reshape(-1, 3)
|
||||
rng = np.random.default_rng(0)
|
||||
cent = feats[rng.choice(len(feats), N_PAL, replace=False)].copy()
|
||||
labels = np.zeros(len(feats), np.int64)
|
||||
for _ in range(12):
|
||||
labels = ((feats[:, None] - cent[None]) ** 2).sum(-1).argmin(1)
|
||||
for g in range(N_PAL):
|
||||
m = labels == g
|
||||
if m.any():
|
||||
cent[g] = feats[m].mean(0)
|
||||
seg_pal = labels.reshape(H, N_SEG)
|
||||
|
||||
# each palette = 3 colours tuned to the pixels of its cluster's segments
|
||||
seg_px = img_lab.reshape(H, N_SEG, SEG_W, 3)
|
||||
palettes = []
|
||||
for g in range(N_PAL):
|
||||
mask = seg_pal == g # (H,N_SEG)
|
||||
if not mask.any():
|
||||
palettes.append([bg, bg, bg])
|
||||
continue
|
||||
px = seg_px[mask].reshape(-1, 1, 3) # (Npix,1,3)
|
||||
palettes.append(_pick(px, plab, 3, diff, candidates))
|
||||
|
||||
# dither the whole image with each segment restricted to {bg} + its palette
|
||||
sets = [[bg] + palettes[g] for g in range(N_PAL)]
|
||||
allowed = np.zeros((H, W, 4), np.int64)
|
||||
for r in range(H):
|
||||
for s in range(N_SEG):
|
||||
allowed[r, s * SEG_W:s * SEG_W + SEG_W] = sets[seg_pal[r, s]]
|
||||
idx = _dith.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
return bg, palettes, seg_pal, idx
|
||||
36
lenser/a7800/convert/mono.py
Normal file
36
lenser/a7800/convert/mono.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""Atari 7800 monochrome / tinted-mono (MARIA 160A restricted to one hue).
|
||||
|
||||
Reuses the c160 machinery but restricts the colour pool to the 16 luminances of a
|
||||
single hue (hue 0 = greyscale), so the 8 palettes become up to ~16 grey levels and
|
||||
the per-segment palette choice yields a smooth, detailed luminance image.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import palette as c64pal
|
||||
from ...convert.base import DIFFUSION_DITHERS, perceptual_error
|
||||
from ...atari import palette as apal
|
||||
from . import c160
|
||||
|
||||
WIDTH, HEIGHT, PIXEL_ASPECT = c160.WIDTH, c160.HEIGHT, c160.PIXEL_ASPECT
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="ntsc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
# mono is carried by dithering -> needs error diffusion
|
||||
if dither_mode not in DIFFUSION_DITHERS:
|
||||
dither_mode = "floyd"
|
||||
hue = 0 if base_color is None else (int(base_color) & 0x0F)
|
||||
candidates = list(range(hue * 16, hue * 16 + 16)) # 16 lums of this hue
|
||||
conv = c160.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color, candidates=candidates, _mode="mono")
|
||||
# report error in LUMINANCE space (a greyscale image must not be scored
|
||||
# against the colour original, as the colour modes are)
|
||||
plab = apal.palette_lab("ntsc").copy()
|
||||
plab[:, 1:] = 0.0
|
||||
img_l = c64pal.srgb_to_lab(img_rgb)
|
||||
img_l[..., 1:] = 0.0
|
||||
conv.error = perceptual_error(np.asarray(conv.index_image), img_l, plab)
|
||||
conv.meta["base_color"] = base_color
|
||||
return conv
|
||||
19
lenser/a7800/exporter.py
Normal file
19
lenser/a7800/exporter.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Build an Atari 7800 cartridge (.a78) from a conversion."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .viewer import assemble
|
||||
|
||||
_EXTS = (".a78", ".bin")
|
||||
|
||||
|
||||
def export_a78(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="ntsc"):
|
||||
if not output_path.lower().endswith(_EXTS):
|
||||
output_path += ".a78"
|
||||
title = os.path.splitext(os.path.basename(source_path or output_path))[0]
|
||||
rom = assemble.build_cart(bytes(conv.data), title=title)
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(rom)
|
||||
return output_path
|
||||
1
lenser/a7800/viewer/__init__.py
Normal file
1
lenser/a7800/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Atari 7800 6502/MARIA viewer (assembled by xa)."""
|
||||
139
lenser/a7800/viewer/assemble.py
Normal file
139
lenser/a7800/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""Assemble the Atari 7800 viewer with `xa` and lay out the 48K .a78 cartridge.
|
||||
|
||||
MARIA reads the display-list list (DLL), the per-line display lists and the bitmap
|
||||
by DMA straight from cartridge ROM, so the packer just places them at fixed cart
|
||||
addresses and the viewer points MARIA at the DLL.
|
||||
|
||||
ROM layout ($4000-$FFFF, 48K):
|
||||
$4000 viewer code
|
||||
$4100 colour register script (reg, value pairs, $FF-terminated)
|
||||
$8000 bitmap 192 lines x 40 bytes (2bpp)
|
||||
$A000 display lists 192 x 34 bytes (8 objects + end marker per line)
|
||||
$BA00 DLL 192 x 3 bytes (one 1-line zone per line)
|
||||
$FFFA 6502 vectors (NMI/RESET/IRQ -> $4000)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
CART_BASE = 0x4000
|
||||
CART_SIZE = 0xC000 # 48K
|
||||
SCRIPT_ADDR = 0x4100
|
||||
BITMAP_ADDR = 0x8000
|
||||
DL_BASE = 0xA000
|
||||
DLL_ADDR = 0xBA00
|
||||
|
||||
WIDTH, LINES = 160, 192
|
||||
BYTES_PER_LINE = 40
|
||||
SEG_W_BYTES = 10 # 10 bytes = 40 px per object
|
||||
N_SEG = 4
|
||||
SEG_W_PX = 40
|
||||
DL_LEN = N_SEG * 4 + 2 # 4 objects (4 bytes) + 2-byte end = 18
|
||||
|
||||
# MARIA colour-register addresses in the order the converter emits them:
|
||||
# BACKGRND, then P0C1..P0C3, P1C1.., ... P7C1..P7C3.
|
||||
COLOR_REGS = [0x20]
|
||||
for _p in range(8):
|
||||
COLOR_REGS += [0x21 + 4 * _p, 0x22 + 4 * _p, 0x23 + 4 * _p]
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def _assemble() -> 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"#define SCRIPT ${SCRIPT_ADDR:04X}\n"
|
||||
f"#define DLL ${DLL_ADDR:04X}\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:
|
||||
return f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
|
||||
|
||||
def _a78_header(rom_len: int, title: str) -> bytes:
|
||||
h = bytearray(128)
|
||||
h[0] = 1 # header version
|
||||
h[1:1 + 9] = b"ATARI7800"
|
||||
h[17:17 + 32] = title.encode("ascii", "replace")[:32].ljust(32, b"\x00")
|
||||
h[49:53] = struct.pack(">I", rom_len) # ROM size (excl. header)
|
||||
# cart type 0 = plain linear; controllers = joystick; NTSC
|
||||
h[55] = 1
|
||||
h[56] = 1
|
||||
h[57] = 0
|
||||
h[100:100 + 28] = b"ACTUAL CART DATA STARTS HERE"
|
||||
return bytes(h)
|
||||
|
||||
|
||||
def build_cart(data: bytes, title: str = "8bitlenser") -> bytes:
|
||||
"""data = converter blob: bitmap(7680) + seg_palettes(192*8) + colours(25)."""
|
||||
bitmap = data[:LINES * BYTES_PER_LINE]
|
||||
seg_pal = data[LINES * BYTES_PER_LINE:LINES * BYTES_PER_LINE + LINES * N_SEG]
|
||||
colours = data[LINES * BYTES_PER_LINE + LINES * N_SEG:]
|
||||
|
||||
code = _assemble()
|
||||
rom = bytearray(b"\x00" * CART_SIZE)
|
||||
|
||||
def place(addr, blob):
|
||||
off = addr - CART_BASE
|
||||
rom[off:off + len(blob)] = blob
|
||||
|
||||
place(CART_BASE, code)
|
||||
|
||||
# colour register script: (reg, value) pairs, $FF terminator
|
||||
script = bytearray()
|
||||
for reg, val in zip(COLOR_REGS, colours):
|
||||
script += bytes([reg, val])
|
||||
script.append(0xFF)
|
||||
place(SCRIPT_ADDR, script)
|
||||
|
||||
place(BITMAP_ADDR, bitmap)
|
||||
|
||||
# per-line display lists (8 objects of 5 bytes, each its own palette)
|
||||
dls = bytearray()
|
||||
for line in range(LINES):
|
||||
for s in range(N_SEG):
|
||||
gfx = BITMAP_ADDR + line * BYTES_PER_LINE + s * SEG_W_BYTES
|
||||
pal = seg_pal[line * N_SEG + s] & 0x07
|
||||
width = (-SEG_W_BYTES) & 0x1F # two's-complement byte count
|
||||
dls += bytes([gfx & 0xFF, (pal << 5) | width, gfx >> 8, s * SEG_W_PX])
|
||||
dls += bytes([0x00, 0x00]) # end of DL
|
||||
place(DL_BASE, dls)
|
||||
|
||||
# display-list list: one 1-line zone per line
|
||||
dll = bytearray()
|
||||
for line in range(LINES):
|
||||
dl = DL_BASE + line * DL_LEN
|
||||
dll += bytes([0x00, dl >> 8, dl & 0xFF]) # offset 0 (1 line), DL hi, lo
|
||||
place(DLL_ADDR, dll)
|
||||
|
||||
# 6502 vectors -> viewer entry ($4000)
|
||||
for v in (0xFFFA, 0xFFFC, 0xFFFE):
|
||||
off = v - CART_BASE
|
||||
rom[off] = CART_BASE & 0xFF
|
||||
rom[off + 1] = CART_BASE >> 8
|
||||
|
||||
return _a78_header(len(rom), title) + bytes(rom)
|
||||
47
lenser/a7800/viewer/viewer.s
Normal file
47
lenser/a7800/viewer/viewer.s
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
; Atari 7800 image viewer -- programs MARIA, then holds.
|
||||
;
|
||||
; MARIA DMAs the display-list list (DLL), the per-line display lists (DLs) and the
|
||||
; bitmap straight out of cartridge ROM, so the viewer only loads MARIA's colour
|
||||
; registers, points it at the DLL, turns DMA on, and loops. MARIA registers live
|
||||
; in zero page ($20-$3F).
|
||||
;
|
||||
; #defines from viewer/assemble.py --
|
||||
; SCRIPT address of the colour register script -- (reg, value) byte pairs
|
||||
; written as $0000+reg = value, terminated by $FF
|
||||
; DLL address of the display-list list
|
||||
|
||||
* = $4000
|
||||
|
||||
reset:
|
||||
sei
|
||||
cld
|
||||
ldx #$ff
|
||||
txs
|
||||
lda #$00
|
||||
sta $3c ; CTRL = 0 -- DMA off while we set up
|
||||
|
||||
; ---- load MARIA colour registers from the script ----
|
||||
ldx #$00
|
||||
sloop:
|
||||
lda SCRIPT,x
|
||||
cmp #$ff
|
||||
beq sdone
|
||||
tay ; Y = MARIA register ($20..$3F)
|
||||
inx
|
||||
lda SCRIPT,x
|
||||
sta $0000,y
|
||||
inx
|
||||
jmp sloop
|
||||
sdone:
|
||||
; ---- point MARIA at the display-list list ----
|
||||
lda #>DLL
|
||||
sta $2c ; DPPH
|
||||
lda #<DLL
|
||||
sta $30 ; DPPL
|
||||
|
||||
; ---- enable DMA (160A, no DLI) ----
|
||||
lda #$40
|
||||
sta $3c ; CTRL
|
||||
|
||||
loop:
|
||||
jmp loop ; the packer fills the reset vectors at $FFFA
|
||||
0
lenser/amiga/__init__.py
Normal file
0
lenser/amiga/__init__.py
Normal file
23
lenser/amiga/convert/__init__.py
Normal file
23
lenser/amiga/convert/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Commodore Amiga conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import lowres, mono
|
||||
|
||||
# NOTE: HAM6 (4096 colours) was implemented and looks superb, but MAME's
|
||||
# preliminary Amiga can't render 6-bitplane/HAM modes cleanly (fixed-position
|
||||
# black bands at the screen edges), so only the MAME-verified 5-plane low-res
|
||||
# (32 colours) and mono modes are shipped.
|
||||
_MODULES = {"lowres": lowres, "mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="lowres", palette_name="amiga",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES.get(mode, lowres)
|
||||
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)
|
||||
130
lenser/amiga/convert/_common.py
Normal file
130
lenser/amiga/convert/_common.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Amiga encoders: HAM6 (4096 colours), flat low-res (<=32 of 4096), greyscale.
|
||||
|
||||
HAM (Hold-And-Modify) is the showpiece: 6 bitplanes where the top 2 bits choose
|
||||
whether a pixel is one of 16 base palette colours or modifies one R/G/B channel
|
||||
of the pixel to its left -- giving up to 4096 colours on screen. We pick 16 base
|
||||
colours, then walk each scanline left-to-right choosing per pixel the option
|
||||
(set / modify R / modify G / modify B) closest to the target.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither, palette as c64pal
|
||||
from ...convert.base import _box_blur
|
||||
from .. import palette as apal
|
||||
|
||||
W, H = 320, 200
|
||||
|
||||
|
||||
def _perr(final_rgb, img_rgb):
|
||||
a = _box_blur(c64pal.srgb_to_lab(final_rgb.astype(np.float64)))
|
||||
b = _box_blur(c64pal.srgb_to_lab(img_rgb.astype(np.float64)))
|
||||
return float(np.sqrt(((a - b) ** 2).sum(-1)).mean())
|
||||
|
||||
|
||||
def planar_split(codes, nplanes):
|
||||
"""codes (H,W) -> nplanes contiguous bitplanes (40 bytes/line, MSB left)."""
|
||||
out = bytearray()
|
||||
for p in range(nplanes):
|
||||
bit = ((codes >> p) & 1).astype(np.uint8)
|
||||
out += np.packbits(bit, axis=1).reshape(-1).tobytes() # (H,40)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _kmeans_keys(img_lab, plab, k, iters=12):
|
||||
rng = np.random.default_rng(0)
|
||||
flat = img_lab.reshape(-1, 3)
|
||||
pts = flat[rng.choice(len(flat), min(6000, len(flat)), replace=False)]
|
||||
# k-means++ style seeding so every centroid is a real, distinct colour
|
||||
cen = pts[rng.integers(len(pts))][None].copy()
|
||||
for _ in range(k - 1):
|
||||
d = np.min(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1)
|
||||
cen = np.vstack([cen, pts[int(d.argmax())]]) # farthest-point seed
|
||||
for _ in range(iters):
|
||||
lab = np.argmin(((pts[:, None, :] - cen[None]) ** 2).sum(2), 1)
|
||||
for j in range(k):
|
||||
m = pts[lab == j]
|
||||
cen[j] = m.mean(0) if len(m) else pts[rng.integers(len(pts))]
|
||||
# snap each centroid to the nearest 4096 palette key
|
||||
keys = [int(np.argmin(((plab - c) ** 2).sum(1))) for c in cen]
|
||||
return keys
|
||||
|
||||
|
||||
def ham_encode(img_rgb, dither_mode):
|
||||
plab = apal.palette_lab() # (4096,3)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
base_keys = _kmeans_keys(img_lab, plab, 16)
|
||||
base_lab = plab[base_keys]
|
||||
base_rgb4 = [(k >> 8 & 15, k >> 4 & 15, k & 15) for k in base_keys]
|
||||
|
||||
t4 = np.clip(np.rint(img_rgb / 17.0), 0, 15).astype(np.int64) # 4-bit targets
|
||||
# base option (independent of the held colour) precomputed for all pixels
|
||||
bd = ((img_lab[:, :, None, :] - base_lab[None, None]) ** 2).sum(3) # (H,W,16)
|
||||
base_best = bd.argmin(2)
|
||||
base_cost = bd.min(2)
|
||||
|
||||
codes = np.zeros((H, W), np.uint8)
|
||||
final = np.zeros((H, W, 3), np.uint8)
|
||||
P = plab
|
||||
for y in range(H):
|
||||
tl = img_lab[y]; t4y = t4[y]
|
||||
bb = base_best[y]; bc = base_cost[y]
|
||||
pr = pg = pb = 0 # hardware holds black at line start
|
||||
for x in range(W):
|
||||
t0, t1, t2 = tl[x, 0], tl[x, 1], tl[x, 2]
|
||||
bi = int(bb[x]); best = bc[x]
|
||||
ctrl = 0; data = bi; nr, ng, nb = base_rgb4[bi]
|
||||
# force an absolute "set" on the first pixel so the line establishes a
|
||||
# colour regardless of the held-colour start (an all-modify run from
|
||||
# the wrong start would otherwise stay dark -- HAM left-edge bug).
|
||||
if x > 0:
|
||||
tr, tg, tb = int(t4y[x, 0]), int(t4y[x, 1]), int(t4y[x, 2])
|
||||
for c_ctrl, c_data, kr, kg, kb in (
|
||||
(2, tr, tr, pg, pb), (3, tg, pr, tg, pb), (1, tb, pr, pg, tb)):
|
||||
pk = P[(kr << 8) | (kg << 4) | kb]
|
||||
dr = pk[0] - t0; dg = pk[1] - t1; db = pk[2] - t2
|
||||
cc = dr * dr + dg * dg + db * db
|
||||
if cc < best:
|
||||
best = cc; ctrl = c_ctrl; data = c_data; nr, ng, nb = kr, kg, kb
|
||||
codes[y, x] = (ctrl << 4) | data
|
||||
pr, pg, pb = nr, ng, nb
|
||||
final[y, x, 0] = pr * 17; final[y, x, 1] = pg * 17; final[y, x, 2] = pb * 17
|
||||
|
||||
planes = planar_split(codes, 6)
|
||||
colors = [apal.color_word(k) for k in base_keys] # 16 base registers
|
||||
return planes, colors, final, _perr(final, img_rgb)
|
||||
|
||||
|
||||
def flat_encode(img_rgb, n_colors, dither_mode, mono=False, base_color=None):
|
||||
plab = apal.palette_lab()
|
||||
prgb = apal.get_palette().astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb)
|
||||
nplanes = (n_colors - 1).bit_length() # 32->5, 16->4
|
||||
|
||||
if mono:
|
||||
keys = list(apal.GREYS) # 16 greys
|
||||
if base_color in range(4096):
|
||||
keys = sorted({keys[0], int(base_color), keys[-1]}, key=lambda i: plab[i, 0])
|
||||
keys = (keys + keys)[:n_colors]
|
||||
work = np.zeros_like(img_lab); work[..., 0] = img_lab[..., 0]
|
||||
pw = np.zeros_like(plab); pw[:, 0] = plab[:, 0]
|
||||
else:
|
||||
keys = _kmeans_keys(img_lab, plab, n_colors)
|
||||
work, pw = img_lab, plab
|
||||
|
||||
allowed = np.tile(np.array(keys[:n_colors]), (H, W, 1))
|
||||
qidx = dither.quantize(work, allowed, pw, dither_mode).astype(np.int64)
|
||||
# map palette key -> pen index
|
||||
lut = {k: i for i, k in enumerate(keys[:n_colors])}
|
||||
pen = np.vectorize(lut.get)(qidx).astype(np.uint8)
|
||||
|
||||
planes = planar_split(pen, nplanes)
|
||||
colors = [apal.color_word(k) for k in keys[:n_colors]]
|
||||
final = prgb[qidx]
|
||||
if mono: # measure greyscale against luminance
|
||||
g = img_rgb.mean(2, keepdims=True).repeat(3, 2)
|
||||
err = _perr(final, g.astype(np.uint8))
|
||||
else:
|
||||
err = _perr(final, img_rgb)
|
||||
return planes, colors, final, err
|
||||
21
lenser/amiga/convert/lowres.py
Normal file
21
lenser/amiga/convert/lowres.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Amiga low resolution: 320x200, 32 colours from the 4096-colour palette."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...convert.base import Conversion
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 320, 200
|
||||
PIXEL_ASPECT = 1.0
|
||||
NCOL = 32
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="amiga", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
planes, colors, preview, err = _common.flat_encode(img_rgb, NCOL, dither_mode)
|
||||
return Conversion(
|
||||
mode="lowres", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=None,
|
||||
data={"planes": planes, "colors": colors, "nplanes": 5, "ham": False},
|
||||
data_addr=0, viewer="amiga", preview_rgb=preview, error=err,
|
||||
meta={"palette": "amiga", "dither": dither_mode},
|
||||
)
|
||||
22
lenser/amiga/convert/mono.py
Normal file
22
lenser/amiga/convert/mono.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Amiga monochrome: 320x200, 16 grey levels (the Amiga has true 16 greys)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...convert.base import Conversion
|
||||
from . import _common
|
||||
|
||||
WIDTH, HEIGHT = 320, 200
|
||||
PIXEL_ASPECT = 1.0
|
||||
NCOL = 16
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="amiga", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
planes, colors, preview, err = _common.flat_encode(
|
||||
img_rgb, NCOL, dither_mode, mono=True, base_color=base_color)
|
||||
return Conversion(
|
||||
mode="mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=None,
|
||||
data={"planes": planes, "colors": colors, "nplanes": 4, "ham": False},
|
||||
data_addr=0, viewer="amiga", preview_rgb=preview, error=err,
|
||||
meta={"palette": "amiga", "dither": dither_mode},
|
||||
)
|
||||
41
lenser/amiga/copper.py
Normal file
41
lenser/amiga/copper.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Build an Amiga Copper list that displays a static 320x200 low-res screen.
|
||||
|
||||
The Copper runs every frame and re-loads the bitplane pointers + registers, so
|
||||
the picture stays stable. Each instruction is a MOVE (register offset, value);
|
||||
the list ends with WAIT $FFFF,$FFFE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
DIWSTRT, DIWSTOP, DDFSTRT, DDFSTOP = 0x08E, 0x090, 0x092, 0x094
|
||||
BPLCON0, BPLCON1, BPLCON2 = 0x100, 0x102, 0x104
|
||||
BPL1MOD, BPL2MOD = 0x108, 0x10A
|
||||
BPLPT = 0x0E0 # BPL1PTH; +4 per plane
|
||||
COLOR0 = 0x180
|
||||
|
||||
|
||||
def list_len(nplanes, ncolors) -> int:
|
||||
pairs = 9 + nplanes * 2 + ncolors # DIW*2, DDF*2, BPLCON0/1/2, BPLMOD*2 = 9
|
||||
return pairs * 4 + 4 # + end WAIT
|
||||
|
||||
|
||||
def build(nplanes, ham, plane_addrs, colors) -> bytes:
|
||||
bplcon0 = (nplanes << 12) | (0x0800 if ham else 0) | 0x0200
|
||||
moves = [
|
||||
(DIWSTRT, 0x2C81), (DIWSTOP, 0xF4C1),
|
||||
(DDFSTRT, 0x0038), (DDFSTOP, 0x00D0),
|
||||
(BPLCON0, bplcon0), (BPLCON1, 0x0000), (BPLCON2, 0x0024),
|
||||
(BPL1MOD, 0x0000), (BPL2MOD, 0x0000),
|
||||
]
|
||||
for p, addr in enumerate(plane_addrs):
|
||||
moves.append((BPLPT + p * 4, (addr >> 16) & 0xFFFF))
|
||||
moves.append((BPLPT + p * 4 + 2, addr & 0xFFFF))
|
||||
for i, c in enumerate(colors):
|
||||
moves.append((COLOR0 + i * 2, c & 0x0FFF))
|
||||
|
||||
out = bytearray()
|
||||
for reg, val in moves:
|
||||
out += struct.pack(">HH", reg & 0x1FE, val & 0xFFFF)
|
||||
out += struct.pack(">HH", 0xFFFF, 0xFFFE) # end of copper list
|
||||
return bytes(out)
|
||||
13
lenser/amiga/exporter.py
Normal file
13
lenser/amiga/exporter.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Build a bootable Amiga .adf floppy from a conversion."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import viewer
|
||||
|
||||
|
||||
def export_adf(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="ntsc"):
|
||||
if not output_path.lower().endswith(".adf"):
|
||||
output_path += ".adf"
|
||||
d = conv.data
|
||||
adf = viewer.build_adf(d["planes"], d["colors"], d["nplanes"], d["ham"])
|
||||
return viewer.write_adf(adf, output_path)
|
||||
30
lenser/amiga/palette.py
Normal file
30
lenser/amiga/palette.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Commodore Amiga (OCS/ECS) colour palette.
|
||||
|
||||
12-bit colour: 4 bits per channel (x17 -> 0..255), 4096 colours. A colour
|
||||
register value is %0000 RRRR GGGG BBBB. We index the master palette by the
|
||||
packed 12-bit key (R<<8)|(G<<4)|B, which is also the register word.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
_r = (np.arange(4096) >> 8) & 0xF
|
||||
_g = (np.arange(4096) >> 4) & 0xF
|
||||
_b = np.arange(4096) & 0xF
|
||||
PALETTE = (np.stack([_r, _g, _b], axis=1) * 17).astype(np.float64) # 4096 x 3
|
||||
|
||||
GREYS = [(v << 8) | (v << 4) | v for v in range(16)] # 16 grey keys
|
||||
|
||||
|
||||
def color_word(key: int) -> int:
|
||||
return key & 0x0FFF
|
||||
|
||||
|
||||
def get_palette() -> np.ndarray:
|
||||
return PALETTE
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(PALETTE)
|
||||
242
lenser/amiga/viewer.py
Normal file
242
lenser/amiga/viewer.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
"""Build a bootable Amiga .adf that displays a low-res / HAM image.
|
||||
|
||||
The boot block (first 1024 bytes) holds a 68000 routine that Kickstart runs at
|
||||
boot: it reuses the boot trackdisk IORequest to read the Copper list + bitplanes
|
||||
from the floppy into chip RAM at $20000, points the Copper there, enables
|
||||
bitplane DMA, kills interrupts (so the OS can't reclaim the display), and idles.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from . import copper
|
||||
|
||||
LOAD = 0x20000 # chip-RAM load address for copper list + bitplanes
|
||||
PLANE_SIZE = 40 * 200 # 8000 bytes per bitplane
|
||||
ADF_SIZE = 901120 # 880K (80*2*11*512)
|
||||
|
||||
|
||||
def _boot_code(datalen: int) -> bytes:
|
||||
"""68000 boot routine (entry: a1 = trackdisk IORequest)."""
|
||||
c = bytearray()
|
||||
c += bytes.fromhex("2C780004") # movea.l $4.w,a6 (ExecBase)
|
||||
c += bytes.fromhex("337C0002001C") # move.w #2,$1C(a1) CMD_READ
|
||||
c += b"\x23\x7C" + struct.pack(">I", datalen) + b"\x00\x24" # move.l #len,$24(a1)
|
||||
c += b"\x23\x7C" + struct.pack(">I", LOAD) + b"\x00\x28" # move.l #LOAD,$28(a1)
|
||||
c += b"\x23\x7C" + struct.pack(">I", 1024) + b"\x00\x2C" # move.l #1024,$2C(a1)
|
||||
c += bytes.fromhex("4EAEFE38") # jsr -456(a6) DoIO
|
||||
c += bytes.fromhex("4BF900DFF000") # lea $dff000,a5
|
||||
c += bytes.fromhex("3B7C7FFF009A") # move.w #$7FFF,$9A(a5) INTENA off
|
||||
c += bytes.fromhex("3B7C7FFF009C") # move.w #$7FFF,$9C(a5) INTREQ clr
|
||||
c += bytes.fromhex("3B7C7FFF0096") # move.w #$7FFF,$96(a5) DMACON off
|
||||
c += b"\x2B\x7C" + struct.pack(">I", LOAD) + b"\x00\x80" # move.l #LOAD,$80(a5) COP1LC
|
||||
c += bytes.fromhex("3B7C83800096") # move.w #$8380,$96(a5) DMACON on
|
||||
c += bytes.fromhex("3B7C00000088") # move.w #0,$88(a5) COPJMP1 strobe
|
||||
c += bytes.fromhex("60FE") # bra *
|
||||
return bytes(c)
|
||||
|
||||
|
||||
def _bootsum(block: bytes) -> int:
|
||||
s = 0
|
||||
for i in range(0, 1024, 4):
|
||||
s += int.from_bytes(block[i:i + 4], "big")
|
||||
if s > 0xFFFFFFFF:
|
||||
s = (s + 1) & 0xFFFFFFFF
|
||||
return (~s) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def build_adf(planes: bytes, colors, nplanes: int, ham: bool) -> bytes:
|
||||
clen = copper.list_len(nplanes, len(colors))
|
||||
plane_addrs = [LOAD + clen + p * PLANE_SIZE for p in range(nplanes)]
|
||||
cop = copper.build(nplanes, ham, plane_addrs, colors)
|
||||
assert len(cop) == clen
|
||||
blob = cop + planes
|
||||
datalen = (len(blob) + 511) // 512 * 512 # whole sectors for trackdisk
|
||||
|
||||
code = _boot_code(datalen)
|
||||
boot = bytearray(1024)
|
||||
boot[0:4] = b"DOS\x00"
|
||||
boot[12:12 + len(code)] = code # Kickstart jumps to offset 12
|
||||
boot[4:8] = b"\x00\x00\x00\x00"
|
||||
struct.pack_into(">I", boot, 4, _bootsum(bytes(boot)))
|
||||
|
||||
adf = bytearray(ADF_SIZE)
|
||||
adf[0:1024] = boot
|
||||
adf[1024:1024 + len(blob)] = blob # copper + bitplanes follow
|
||||
return bytes(adf)
|
||||
|
||||
|
||||
def write_adf(adf: bytes, path: str) -> str:
|
||||
with open(path, "wb") as f:
|
||||
f.write(adf)
|
||||
return path
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# slideshow
|
||||
# --------------------------------------------------------------------------- #
|
||||
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
|
||||
|
||||
|
||||
class _M68k:
|
||||
"""Tiny 68000 emitter with label/branch fixups (hand-encoded opcodes, but the
|
||||
displacements are computed, not counted by hand)."""
|
||||
|
||||
def __init__(self):
|
||||
self.code = bytearray()
|
||||
self.labels: dict[str, int] = {}
|
||||
self.fixups: list[tuple[int, str]] = [] # (disp-word position, label)
|
||||
|
||||
def hexs(self, h): self.code.extend(bytes.fromhex(h)); return self
|
||||
def w(self, v): self.code.extend(struct.pack(">H", v & 0xFFFF)); return self
|
||||
def l(self, v): self.code.extend(struct.pack(">I", v & 0xFFFFFFFF)); return self
|
||||
def label(self, name): self.labels[name] = len(self.code); return self
|
||||
|
||||
def br(self, opc_hex, name): # Bcc.w / BSR.w / DBcc (opcode + disp16)
|
||||
self.code.extend(bytes.fromhex(opc_hex))
|
||||
self.fixups.append((len(self.code), name))
|
||||
self.w(0)
|
||||
return self
|
||||
|
||||
def resolve(self) -> bytes:
|
||||
for pos, name in self.fixups:
|
||||
disp = self.labels[name] - pos # PC base = the displacement word
|
||||
struct.pack_into(">h", self.code, pos, disp)
|
||||
return bytes(self.code)
|
||||
|
||||
|
||||
def _ss_boot_code(slot: int, n_images: int, waitmode: int, waitsecs: int,
|
||||
speed: int, loop: bool) -> bytes:
|
||||
"""68000 slideshow boot routine (entry: a1 = boot trackdisk IORequest).
|
||||
|
||||
Reads image i (slot bytes from floppy offset 1024 + i*slot) into chip RAM at
|
||||
$20000, points the Copper there and strobes it, waits, then advances.
|
||||
"""
|
||||
m = _M68k()
|
||||
m.hexs("2C780004") # movea.l $4.w,a6 (ExecBase)
|
||||
m.hexs("4BF900DFF000") # lea $dff000,a5
|
||||
# ---- read ALL images into chip RAM (interrupts still on, so DoIO works) ----
|
||||
# image i: slot bytes from floppy offset 1024+i*slot -> RAM $20000 + i*slot
|
||||
m.hexs("7E00") # moveq #0,d7 (image index)
|
||||
m.label("read")
|
||||
m.hexs("2007") # move.l d7,d0
|
||||
m.hexs("C0FC").w(slot) # mulu #slot,d0 d0 = i*slot
|
||||
m.hexs("2A00") # move.l d0,d5 save i*slot
|
||||
m.hexs("0680").l(LOAD) # add.l #$20000,d0
|
||||
m.hexs("23400028") # move.l d0,$28(a1) dest = $20000+i*slot
|
||||
m.hexs("2005") # move.l d5,d0
|
||||
m.hexs("0680").l(1024) # add.l #1024,d0
|
||||
m.hexs("2340002C") # move.l d0,$2C(a1) offset = 1024+i*slot
|
||||
m.hexs("337C0002001C") # move.w #2,$1C(a1) CMD_READ
|
||||
m.hexs("237C").l(slot).hexs("0024") # move.l #slot,$24(a1) length
|
||||
m.hexs("4EAEFE38") # jsr -456(a6) DoIO
|
||||
m.hexs("5287") # addq.l #1,d7
|
||||
m.hexs("0C87").l(n_images) # cmpi.l #n,d7
|
||||
m.br("6D00", "read") # blt.w read
|
||||
# ---- take over the display -- kill interrupts/DMA so the OS can't reclaim it
|
||||
m.hexs("3B7C7FFF009A") # INTENA off
|
||||
m.hexs("3B7C7FFF009C") # INTREQ clear
|
||||
m.hexs("3B7C7FFF0096") # DMACON off
|
||||
m.hexs("7E00") # moveq #0,d7
|
||||
m.label("main")
|
||||
m.hexs("2007") # move.l d7,d0
|
||||
m.hexs("C0FC").w(slot) # mulu #slot,d0
|
||||
m.hexs("0680").l(LOAD) # add.l #$20000,d0 COP list = $20000+i*slot
|
||||
m.hexs("2B400080") # move.l d0,$80(a5) COP1LC
|
||||
m.hexs("3B7C83800096") # move.w #$8380,$96(a5) DMACON on
|
||||
m.hexs("3B7C00000088") # move.w #0,$88(a5) COPJMP1 strobe
|
||||
m.br("6100", "wait") # bsr.w wait
|
||||
m.hexs("5287") # addq.l #1,d7
|
||||
m.hexs("0C87").l(n_images) # cmpi.l #n,d7
|
||||
m.br("6D00", "main") # blt.w main
|
||||
if loop:
|
||||
m.hexs("7E00") # moveq #0,d7
|
||||
m.br("6000", "main") # bra.w main
|
||||
else:
|
||||
m.label("idle")
|
||||
m.br("6000", "idle") # bra *
|
||||
|
||||
# ---- wait ----
|
||||
m.label("wait")
|
||||
if waitmode == 1: # key = left mouse button ($BFE001 bit6, 0=down)
|
||||
m.label("wk")
|
||||
m.hexs("123900BFE001") # move.b $bfe001,d1
|
||||
m.hexs("08010006") # btst #6,d1
|
||||
m.br("6600", "wk") # bne.w wk (loop while not pressed)
|
||||
m.hexs("4E75") # rts
|
||||
elif waitmode == 2: # seconds = delay loop
|
||||
m.hexs("243C").l(waitsecs) # move.l #secs,d2
|
||||
m.label("wso")
|
||||
m.hexs("363C").w(speed) # move.w #speed,d3
|
||||
m.label("wsm")
|
||||
m.hexs("323CFFFF") # move.w #$FFFF,d1
|
||||
m.label("wsi")
|
||||
m.br("51C9", "wsi") # dbra d1,wsi
|
||||
m.br("51CB", "wsm") # dbra d3,wsm
|
||||
m.hexs("5382") # subq.l #1,d2
|
||||
m.br("6600", "wso") # bne.w wso
|
||||
m.hexs("4E75") # rts
|
||||
else: # both = delay loop + mouse poll
|
||||
m.hexs("243C").l(waitsecs)
|
||||
m.label("wbo")
|
||||
m.hexs("363C").w(speed)
|
||||
m.label("wbm")
|
||||
m.hexs("123900BFE001") # move.b $bfe001,d1
|
||||
m.hexs("08010006") # btst #6,d1
|
||||
m.br("6700", "wbd") # beq.w wbd (pressed -> done)
|
||||
m.hexs("323CFFFF") # move.w #$FFFF,d1
|
||||
m.label("wbi")
|
||||
m.br("51C9", "wbi") # dbra d1,wbi
|
||||
m.br("51CB", "wbm") # dbra d3,wbm
|
||||
m.hexs("5382") # subq.l #1,d2
|
||||
m.br("6600", "wbo") # bne.w wbo
|
||||
m.label("wbd")
|
||||
m.hexs("4E75") # rts
|
||||
return m.resolve()
|
||||
|
||||
|
||||
def image_blob(planes: bytes, colors, nplanes: int, ham: bool,
|
||||
base: int = LOAD) -> bytes:
|
||||
"""The data for one image: copper list (with bitplane pointers and colours)
|
||||
followed by the bitplanes -- loaded verbatim to ``base``. The bitplane
|
||||
pointers in the copper are absolute, so each slide is built for the RAM
|
||||
address it will live at."""
|
||||
clen = copper.list_len(nplanes, len(colors))
|
||||
plane_addrs = [base + clen + p * PLANE_SIZE for p in range(nplanes)]
|
||||
return copper.build(nplanes, ham, plane_addrs, colors) + planes
|
||||
|
||||
|
||||
def build_slideshow_adf(images, advance="both", seconds=10, loop=True,
|
||||
video="ntsc") -> bytes:
|
||||
"""Build a bootable slideshow ADF. ``images`` is a list of conv.data dicts
|
||||
(planes/colors/nplanes/ham); each is laid out in its own sector-aligned slot
|
||||
so the boot can read image i from a fixed floppy offset."""
|
||||
# blob length is base-independent, so size first, then rebuild each blob for
|
||||
# the RAM address ($20000 + i*slot) it will be loaded to and cycled from.
|
||||
sizes = [len(image_blob(im["planes"], im["colors"], im["nplanes"], im["ham"]))
|
||||
for im in images]
|
||||
slot = (max(sizes) + 511) // 512 * 512
|
||||
if 1024 + len(images) * slot > ADF_SIZE:
|
||||
raise ValueError("slideshow exceeds the 880K floppy")
|
||||
if LOAD + len(images) * slot > 0x80000:
|
||||
raise ValueError("slideshow exceeds Amiga chip RAM")
|
||||
blobs = [image_blob(im["planes"], im["colors"], im["nplanes"], im["ham"],
|
||||
base=LOAD + i * slot)
|
||||
for i, im in enumerate(images)]
|
||||
speed = 11 if video != "pal" else 13 # delay outer ~1s at 7MHz
|
||||
code = _ss_boot_code(slot, len(blobs), SS_WAITMODE[advance],
|
||||
max(0, int(seconds)), speed, loop)
|
||||
if len(code) > 1024 - 12:
|
||||
raise ValueError("slideshow boot code overruns the 1024-byte boot block")
|
||||
|
||||
boot = bytearray(1024)
|
||||
boot[0:4] = b"DOS\x00"
|
||||
boot[12:12 + len(code)] = code # Kickstart jumps to offset 12
|
||||
struct.pack_into(">I", boot, 4, _bootsum(bytes(boot)))
|
||||
|
||||
adf = bytearray(ADF_SIZE)
|
||||
adf[0:1024] = boot
|
||||
for i, b in enumerate(blobs):
|
||||
off = 1024 + i * slot
|
||||
adf[off:off + len(b)] = b
|
||||
return bytes(adf)
|
||||
10
lenser/ansi/__init__.py
Normal file
10
lenser/ansi/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""ANSI / CP437 "BBS art" output.
|
||||
|
||||
Not a real machine -- this renders the image as classic 16-colour ANSI text art
|
||||
suitable for display on a bulletin board system (or any ANSI/CP437 viewer). Each
|
||||
character cell is the CP437 upper-half-block (``0xDF``) with the foreground colour
|
||||
painting the top pixel and the background colour the bottom pixel, so one 80-column
|
||||
row of text is two rows of freely-coloured pixels. Sixteen EGA/VGA colours are
|
||||
available for both halves (bright backgrounds use "iCE colours"), so the picture is
|
||||
just a free 16-colour image at 80 x (2*rows).
|
||||
"""
|
||||
307
lenser/ansi/convert.py
Normal file
307
lenser/ansi/convert.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""Convert an image to 16-colour ANSI/CP437 BBS art.
|
||||
|
||||
Two encoders share this module:
|
||||
|
||||
* **half-block** (fast, the default when ``intensive`` is off) -- every cell is the
|
||||
CP437 upper-half-block ``0xDF`` with fg = top pixel, bg = bottom pixel, so the
|
||||
picture is a free 16-colour dither on an 80 x (2*rows) grid (no cell clash).
|
||||
|
||||
* **full glyph** (``intensive`` on) -- every 8x16 cell is matched to the best of the
|
||||
whole CP437 repertoire (letters, punctuation, line- and block-drawing) together
|
||||
with an optimal foreground/background colour pair, minimising perceptual (CIELAB)
|
||||
reproduction error. Using the actual glyph shapes -- not just blocks -- reproduces
|
||||
edges, texture and gradients far better, at the cost of a per-cell search.
|
||||
|
||||
Both choose their two colours per cell from the 16 EGA/VGA colours (bright
|
||||
backgrounds via iCE colours); "mono" restricts them to a grey/tinted ramp.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .. import dither, imageprep, palette as pal
|
||||
from ..convert import base
|
||||
|
||||
# Standard 16-colour EGA/VGA text palette (indices 0..15 = the ANSI colour order:
|
||||
# black, blue, green, cyan, red, magenta, brown, light-grey, then their bright
|
||||
# variants). Foreground index -> SGR 30..37 (+bold for 8..15); background index ->
|
||||
# SGR 40..47 (+"iCE" blink for 8..15).
|
||||
VGA = np.array([
|
||||
(0x00, 0x00, 0x00), (0x00, 0x00, 0xAA), (0x00, 0xAA, 0x00), (0x00, 0xAA, 0xAA),
|
||||
(0xAA, 0x00, 0x00), (0xAA, 0x00, 0xAA), (0xAA, 0x55, 0x00), (0xAA, 0xAA, 0xAA),
|
||||
(0x55, 0x55, 0x55), (0x55, 0x55, 0xFF), (0x55, 0xFF, 0x55), (0x55, 0xFF, 0xFF),
|
||||
(0xFF, 0x55, 0x55), (0xFF, 0x55, 0xFF), (0xFF, 0xFF, 0x55), (0xFF, 0xFF, 0xFF),
|
||||
], dtype=np.uint8)
|
||||
|
||||
# Canvas sizes, keyed "COLSxROWS" (character cells). 80x25 is the classic one-screen
|
||||
# BBS canvas, 80x50 is a taller (scrolling) canvas with twice the vertical detail.
|
||||
# "mono" is a greyscale (or hue-tinted) 80x25 canvas -- the monochrome mode every
|
||||
# platform provides.
|
||||
_DIMS = {"80x25": (80, 25), "80x50": (80, 50), "mono": (80, 25)}
|
||||
MODES = list(_DIMS.keys())
|
||||
|
||||
# The four VGA greys, darkest-to-lightest, used as the monochrome ramp.
|
||||
_GREY_RAMP = [0, 8, 7, 15] # black, dark grey, light grey, white
|
||||
|
||||
UPPER_HALF_BLOCK = 0xDF # CP437 "▀": top half = fg colour, bottom half = bg
|
||||
PREVIEW_ZOOM = 4 # widen the 80-wide half-block preview for the GUI
|
||||
|
||||
GLYPH_W, GLYPH_H = 8, 16 # CP437 text cell (pixels)
|
||||
_FONT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cp437_8x16.bin")
|
||||
|
||||
|
||||
def _sgr(fg: int, bg: int) -> bytes:
|
||||
"""SGR escape selecting foreground ``fg`` and background ``bg`` (0..15 each).
|
||||
|
||||
Bright foregrounds (8..15) use bold (``1``); bright backgrounds use the blink
|
||||
bit (``5``) interpreted as high-intensity ("iCE colours"), which every modern
|
||||
ANSI viewer and most BBS terminals honour.
|
||||
"""
|
||||
parts = ["0"]
|
||||
if fg >= 8:
|
||||
parts.append("1")
|
||||
if bg >= 8:
|
||||
parts.append("5")
|
||||
parts.append(str(30 + (fg & 7)))
|
||||
parts.append(str(40 + (bg & 7)))
|
||||
return b"\x1b[" + ";".join(parts).encode("ascii") + b"m"
|
||||
|
||||
|
||||
def _mono_ramp(base_color) -> list[int]:
|
||||
"""Luminance-sorted VGA indices for the mono ramp. With no base colour this is
|
||||
the four greys; with one it is black + the nearest VGA hue (+ white), so the
|
||||
picture becomes that colour's shades -- the app's tinted-mono behaviour."""
|
||||
plab = pal.srgb_to_lab(VGA)
|
||||
if base_color is None:
|
||||
ramp = set(_GREY_RAMP)
|
||||
else:
|
||||
# base_color is a colodore palette index; tint toward its nearest VGA hue.
|
||||
rgb = pal.get_palette("colodore")[int(base_color)].astype(np.int64)
|
||||
hue = int(np.argmin(((VGA.astype(np.int64) - rgb) ** 2).sum(axis=1)))
|
||||
ramp = {0, hue, 15}
|
||||
return sorted(ramp, key=lambda i: plab[i, 0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# half-block encoder (fast path)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def encode_ansi(index_image: np.ndarray, cols: int, rows: int) -> bytes:
|
||||
"""Encode a (2*rows, cols) index image as a CP437 half-block ANSI byte stream.
|
||||
|
||||
Each output row pairs two pixel rows into one text row of ``0xDF`` cells,
|
||||
emitting a colour escape only when the (fg, bg) pair changes, and resetting at
|
||||
each line end so a coloured background never bleeds past column 80. Lines end
|
||||
in CRLF -- on the deferred-wrap ("magic margin") terminals BBSes use, an exact
|
||||
80-column line plus CRLF advances one row with no blank line.
|
||||
"""
|
||||
out = bytearray(b"\x1b[0m")
|
||||
for r in range(rows):
|
||||
top = index_image[2 * r]
|
||||
bot = index_image[2 * r + 1]
|
||||
last = None
|
||||
for c in range(cols):
|
||||
pair = (int(top[c]), int(bot[c]))
|
||||
if pair != last:
|
||||
out += _sgr(*pair)
|
||||
last = pair
|
||||
out.append(UPPER_HALF_BLOCK)
|
||||
out += b"\x1b[0m\r\n"
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp):
|
||||
W, H = cols, rows * 2
|
||||
img_lab = pal.srgb_to_lab(rgb)
|
||||
plab = pal.srgb_to_lab(VGA)
|
||||
allowed = np.tile(np.asarray(ramp, np.int64), (H, W, 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
preview = np.repeat(np.repeat(VGA[idx], PREVIEW_ZOOM, 0), PREVIEW_ZOOM, 1)
|
||||
return base.Conversion(
|
||||
mode=mode, width=W, height=H, pixel_aspect=1.0, index_image=idx,
|
||||
data=encode_ansi(idx, cols, rows), data_addr=0, preview_rgb=preview,
|
||||
viewer="", error=base.mean_error(idx, img_lab, plab),
|
||||
meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows,
|
||||
"encoding": "halfblock"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# full-glyph encoder (intensive path)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_glyph_cache = None
|
||||
|
||||
|
||||
def _glyphs():
|
||||
"""Load the bundled CP437 8x16 font once and return (masks, codes).
|
||||
|
||||
``masks`` is (Ng, 128) float32 -- one row per usable, de-duplicated glyph, a
|
||||
pixel being 1.0 where the glyph is foreground (pixel order y*8+x, x MSB-first,
|
||||
matching how cells are flattened). ``codes`` is the CP437 byte to emit for each.
|
||||
Control bytes (0x00-0x1F, 0x7F) are excluded so the stream is always safe to
|
||||
send to a terminal, and glyphs with identical bitmaps collapse to one entry.
|
||||
"""
|
||||
global _glyph_cache
|
||||
if _glyph_cache is not None:
|
||||
return _glyph_cache
|
||||
font = np.frombuffer(open(_FONT_PATH, "rb").read(), np.uint8).reshape(256, GLYPH_H)
|
||||
bits = np.unpackbits(font, axis=1).astype(np.float32) # (256, 128)
|
||||
masks, codes, seen = [], [], set()
|
||||
for code in range(0x20, 0x100):
|
||||
if code == 0x7F:
|
||||
continue
|
||||
key = bits[code].tobytes()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
masks.append(bits[code])
|
||||
codes.append(code)
|
||||
_glyph_cache = (np.stack(masks), np.array(codes, np.uint8))
|
||||
return _glyph_cache
|
||||
|
||||
|
||||
def _match_cells(cells_lab, csub_lab, csub_idx):
|
||||
"""Best (glyph, fg, bg) per cell by minimum summed CIELAB error.
|
||||
|
||||
``cells_lab`` is (n, 128, 3); ``csub_lab``/``csub_idx`` are the allowed colours
|
||||
in CIELAB and as VGA indices. For a glyph's fg (bg) pixel set the optimal
|
||||
palette colour is the one nearest that set's mean, whose summed squared error is
|
||||
``k*|c|^2 - 2 c.sum + sum|p|^2`` -- computed here for every glyph and colour at
|
||||
once, then reduced. Returns (glyph_row, fg_idx, bg_idx, err) arrays, length n.
|
||||
"""
|
||||
G, _ = _glyphs()
|
||||
Ng = G.shape[0]
|
||||
k1 = G.sum(1) # (Ng,) fg pixel counts
|
||||
k0 = float(G.shape[1]) - k1
|
||||
Csq = (csub_lab ** 2).sum(1) # (m,)
|
||||
|
||||
grow = np.empty(len(cells_lab), np.int64)
|
||||
fgi = np.empty(len(cells_lab), np.int64)
|
||||
bgi = np.empty(len(cells_lab), np.int64)
|
||||
err = np.empty(len(cells_lab), np.float64)
|
||||
|
||||
# Batch cells so the (Ng, batch, m) error tensors stay modest in memory.
|
||||
step = max(1, 2_000_000 // (Ng * max(1, len(csub_idx))))
|
||||
for s in range(0, len(cells_lab), step):
|
||||
P = cells_lab[s:s + step] # (nb, 128, 3)
|
||||
nb = P.shape[0]
|
||||
sq = (P ** 2).sum(2) # (nb, 128)
|
||||
tot = P.sum(1) # (nb, 3)
|
||||
totsq = sq.sum(1) # (nb,)
|
||||
sum_fg = np.einsum("gp,npc->gnc", G, P) # (Ng, nb, 3)
|
||||
msq_fg = G @ sq.T # (Ng, nb)
|
||||
# error of assigning each glyph's fg pixels to each candidate colour
|
||||
efg = (k1[:, None, None] * Csq[None, None, :]
|
||||
- 2 * np.einsum("gnc,mc->gnm", sum_fg, csub_lab)
|
||||
+ msq_fg[:, :, None]) # (Ng, nb, m)
|
||||
best_fg = efg.min(2)
|
||||
sel_fg = efg.argmin(2)
|
||||
ebg = (k0[:, None, None] * Csq[None, None, :]
|
||||
- 2 * np.einsum("gnc,mc->gnm", tot[None] - sum_fg, csub_lab)
|
||||
+ (totsq[None, :] - msq_fg)[:, :, None])
|
||||
best_bg = ebg.min(2)
|
||||
sel_bg = ebg.argmin(2)
|
||||
total = best_fg + best_bg # (Ng, nb)
|
||||
g = total.argmin(0) # (nb,)
|
||||
r = np.arange(nb)
|
||||
grow[s:s + nb] = g
|
||||
fgi[s:s + nb] = csub_idx[sel_fg[g, r]]
|
||||
bgi[s:s + nb] = csub_idx[sel_bg[g, r]]
|
||||
err[s:s + nb] = total[g, r]
|
||||
return grow, fgi, bgi, err
|
||||
|
||||
|
||||
def encode_ansi_glyph(codes, fg, bg, cols, rows):
|
||||
"""Encode per-cell CP437 ``codes`` with ``fg``/``bg`` (all (rows, cols)) as ANSI.
|
||||
|
||||
Same colour-run and line-reset discipline as the half-block encoder, but each
|
||||
cell emits its matched glyph byte instead of a fixed half-block.
|
||||
"""
|
||||
out = bytearray(b"\x1b[0m")
|
||||
for r in range(rows):
|
||||
last = None
|
||||
for c in range(cols):
|
||||
pair = (int(fg[r, c]), int(bg[r, c]))
|
||||
if pair != last:
|
||||
out += _sgr(*pair)
|
||||
last = pair
|
||||
out.append(int(codes[r, c]))
|
||||
out += b"\x1b[0m\r\n"
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode):
|
||||
G, gcodes = _glyphs()
|
||||
img_lab = pal.srgb_to_lab(rgb) # (rows*16, cols*8, 3)
|
||||
plab = pal.srgb_to_lab(VGA)
|
||||
csub_idx = np.asarray(ramp, np.int64)
|
||||
csub_lab = plab[csub_idx]
|
||||
|
||||
# Match against a pre-dithered copy so smooth gradients become shade characters
|
||||
# (two blended colours) instead of banding into flat cells. A FAST vectorised
|
||||
# ordered dither is used -- error-diffusion dithers are far too slow at
|
||||
# 8x16-per-cell resolution and the glyph matcher re-approximates the local mix
|
||||
# anyway. "none" matches the continuous image (crispest flats, but visible
|
||||
# gradient bands); every diffusion choice maps to blue-noise (the best-looking).
|
||||
pd = {"none": None, "bayer": "bayer", "bluenoise": "bluenoise"}.get(
|
||||
dither_mode, "bluenoise")
|
||||
if pd is not None:
|
||||
allowed = np.tile(csub_idx, (img_lab.shape[0], img_lab.shape[1], 1))
|
||||
target = plab[dither.quantize(img_lab, allowed, plab, pd)]
|
||||
else:
|
||||
target = img_lab
|
||||
cells = (target.reshape(rows, GLYPH_H, cols, GLYPH_W, 3)
|
||||
.transpose(0, 2, 1, 3, 4).reshape(rows * cols, GLYPH_H * GLYPH_W, 3))
|
||||
grow, fg, bg, _ = _match_cells(cells, csub_lab, csub_idx)
|
||||
|
||||
# render the matched glyphs to an RGB preview at full 8x16 cell resolution
|
||||
masks = G[grow] # (n, 128)
|
||||
fg_rgb = VGA[fg].astype(np.float32)
|
||||
bg_rgb = VGA[bg].astype(np.float32)
|
||||
cellpix = masks[:, :, None] * fg_rgb[:, None, :] + (1 - masks[:, :, None]) * bg_rgb[:, None, :]
|
||||
preview = (cellpix.reshape(rows, cols, GLYPH_H, GLYPH_W, 3)
|
||||
.transpose(0, 2, 1, 3, 4).reshape(rows * GLYPH_H, cols * GLYPH_W, 3)
|
||||
.astype(np.uint8))
|
||||
|
||||
codes = gcodes[grow].reshape(rows, cols)
|
||||
fg2d, bg2d = fg.reshape(rows, cols), bg.reshape(rows, cols)
|
||||
# perceptual error of the rendered result against the ORIGINAL (undithered) image
|
||||
rms = float(np.sqrt(((pal.srgb_to_lab(preview) - img_lab) ** 2).sum(-1)).mean())
|
||||
return base.Conversion(
|
||||
mode=mode, width=cols * GLYPH_W, height=rows * GLYPH_H, pixel_aspect=1.0,
|
||||
index_image=None, data=encode_ansi_glyph(codes, fg2d, bg2d, cols, rows),
|
||||
data_addr=0, preview_rgb=preview, viewer="", error=rms,
|
||||
meta={"palette": "vga", "dither": dither_mode, "cols": cols, "rows": rows,
|
||||
"encoding": "glyph"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def convert_image(path_or_img, mode="80x25", palette_name="vga",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
"""Convert an image to ANSI BBS art.
|
||||
|
||||
``mode`` picks the canvas ("80x25" / "80x50", full 16-colour) or "mono"
|
||||
(greyscale, or a hue tint via ``base_color``). With ``intensive`` set, every
|
||||
8x16 cell is matched to the best CP437 glyph + colour pair (highest quality);
|
||||
otherwise the fast half-block encoder runs and ``dither_mode`` chooses its
|
||||
dither. The returned Conversion's ``data`` is the ready-to-write ``.ANS`` byte
|
||||
stream and ``preview_rgb`` the rendered picture for the GUI.
|
||||
"""
|
||||
if prep_opt is None:
|
||||
prep_opt = imageprep.PrepOptions()
|
||||
cols, rows = _DIMS.get(mode, _DIMS["80x25"])
|
||||
ramp = _mono_ramp(base_color) if mode == "mono" else list(range(16))
|
||||
|
||||
if intensive:
|
||||
rgb = imageprep.prepare(path_or_img, cols * GLYPH_W, rows * GLYPH_H, 1.0,
|
||||
prep_opt, border_rgb=(0, 0, 0))
|
||||
return _convert_glyph(rgb, mode, cols, rows, ramp, dither_mode)
|
||||
|
||||
rgb = imageprep.prepare(path_or_img, cols, rows * 2, 1.0, prep_opt,
|
||||
border_rgb=(0, 0, 0))
|
||||
return _convert_halfblock(rgb, mode, cols, rows, dither_mode, ramp)
|
||||
BIN
lenser/ansi/cp437_8x16.bin
Normal file
BIN
lenser/ansi/cp437_8x16.bin
Normal file
Binary file not shown.
19
lenser/ansi/exporter.py
Normal file
19
lenser/ansi/exporter.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Write an ANSI/CP437 conversion to a ``.ANS`` file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def export_ans(conv, path, source_path=None, display="key", seconds=0,
|
||||
video="pal", layout="unified"):
|
||||
"""Write the conversion's ANSI byte stream to ``path`` (forcing a .ans suffix).
|
||||
|
||||
The extra keyword arguments (display / seconds / video / layout) exist only so
|
||||
ANSI shares the platform export interface; a static text file ignores them.
|
||||
"""
|
||||
if not str(path).lower().endswith(".ans"):
|
||||
path = os.path.splitext(path)[0] + ".ans"
|
||||
with open(path, "wb") as f:
|
||||
f.write(conv.data)
|
||||
return path
|
||||
1
lenser/apple/__init__.py
Normal file
1
lenser/apple/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Apple II (Apple II+/IIe) image conversion and bootable disk export."""
|
||||
20
lenser/apple/convert/__init__.py
Normal file
20
lenser/apple/convert/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""Apple II conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
from ... import imageprep
|
||||
from . import hgr_mono
|
||||
|
||||
_MODULES = {"hgr_mono": hgr_mono}
|
||||
for _name in ("dhgr", "hgr_color", "mono"):
|
||||
try:
|
||||
_MODULES[_name] = __import__(f"lenser.apple.convert.{_name}", fromlist=[_name])
|
||||
except Exception:
|
||||
pass
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
def convert_image(path_or_img, mode="hgr_mono", palette_name="mono",
|
||||
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)
|
||||
60
lenser/apple/convert/dhgr.py
Normal file
60
lenser/apple/convert/dhgr.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Apple //e Double Hi-Res: 140x192, 16 colours (no per-cell limit).
|
||||
|
||||
Each line is 560 bits = 140 four-bit colour groups, stored 7 bits per byte with
|
||||
the bytes interleaved between auxiliary and main memory (aux holds the even display
|
||||
bytes, main the odd). Needs a //e (auxiliary memory).
|
||||
"""
|
||||
|
||||
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 apal
|
||||
|
||||
WIDTH, HEIGHT = 140, 192
|
||||
PIXEL_ASPECT = 2.0
|
||||
DATA_ADDR = 0x2000
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="dhgr", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = apal.dhgr_lab()
|
||||
prgb = apal.DHGR16.astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
|
||||
|
||||
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
|
||||
aux = bytearray(0x2000)
|
||||
main = bytearray(0x2000)
|
||||
for y in range(HEIGHT):
|
||||
stream = np.zeros(560, dtype=np.uint8)
|
||||
row = idx[y]
|
||||
for n in range(WIDTH):
|
||||
c = int(row[n])
|
||||
stream[4 * n + 0] = c & 1
|
||||
stream[4 * n + 1] = (c >> 1) & 1
|
||||
stream[4 * n + 2] = (c >> 2) & 1
|
||||
stream[4 * n + 3] = (c >> 3) & 1
|
||||
base = apal.hgr_row_addr(y)
|
||||
for col in range(40):
|
||||
ab = 0
|
||||
mb = 0
|
||||
for i in range(7):
|
||||
ab |= int(stream[7 * (2 * col) + i]) << i
|
||||
mb |= int(stream[7 * (2 * col + 1) + i]) << i
|
||||
aux[base + col] = ab
|
||||
main[base + col] = mb
|
||||
|
||||
data = bytes(main) + bytes(aux) # main half at $2000, aux at $4000
|
||||
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
|
||||
|
||||
return Conversion(
|
||||
mode="dhgr", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
|
||||
viewer="dhgr", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "dhgr", "dither": dither_mode},
|
||||
)
|
||||
72
lenser/apple/convert/hgr_color.py
Normal file
72
lenser/apple/convert/hgr_color.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Apple II HGR artifact colour: 140x192 colour pixels (280x192 mono), ~6 colours.
|
||||
|
||||
Each 2-mono-pixel "colour pixel" can be black, white, or one of two chroma colours
|
||||
set by its byte's palette bit (violet/green for palette 0, blue/orange for palette 1).
|
||||
We pick the palette bit per byte, then dither each colour pixel to its 4 reachable
|
||||
colours. Works on the II+ and //e, and reuses the HGR boot loader.
|
||||
"""
|
||||
|
||||
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 apal
|
||||
|
||||
WIDTH, HEIGHT = 140, 192 # colour-pixel resolution
|
||||
PIXEL_ASPECT = 2.0
|
||||
DATA_ADDR = 0x2000
|
||||
N_BYTES = 40
|
||||
|
||||
# index sets reachable in a byte for each palette bit: {black, even-chroma, odd-chroma, white}
|
||||
_SET = {
|
||||
0: [apal.HGR_BLACK, apal.HGR_VIOLET, apal.HGR_GREEN, apal.HGR_WHITE],
|
||||
1: [apal.HGR_BLACK, apal.HGR_BLUE, apal.HGR_ORANGE, apal.HGR_WHITE],
|
||||
}
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="hgr", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
plab = apal.hgr6_lab()
|
||||
prgb = apal.HGR6.astype(np.uint8)
|
||||
img_lab = c64pal.srgb_to_lab(img_rgb) # (192,140,3)
|
||||
|
||||
# choose a palette bit per byte (per line), by nearest-colour error.
|
||||
pal_byte = np.zeros((HEIGHT, N_BYTES), dtype=np.uint8)
|
||||
allowed = np.zeros((HEIGHT, WIDTH, 4), dtype=np.int64)
|
||||
for y in range(HEIGHT):
|
||||
for b in range(N_BYTES):
|
||||
ks = [k for k in range(WIDTH) if (2 * k) // 7 == b]
|
||||
if not ks:
|
||||
continue
|
||||
best_p, best_e = 0, None
|
||||
for p in (0, 1):
|
||||
cols = np.array(_SET[p])
|
||||
d = np.sum((img_lab[y, ks][:, None, :] - plab[cols][None, :, :]) ** 2, axis=-1)
|
||||
e = d.min(axis=1).sum()
|
||||
if best_e is None or e < best_e:
|
||||
best_e, best_p = e, p
|
||||
pal_byte[y, b] = best_p
|
||||
for k in ks:
|
||||
allowed[y, k] = _SET[best_p]
|
||||
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.int64)
|
||||
|
||||
# colour-pixel index -> two mono bits at (2k, 2k+1)
|
||||
bits = np.zeros((HEIGHT, WIDTH * 2), dtype=np.uint8)
|
||||
even_on = np.isin(idx, [apal.HGR_VIOLET, apal.HGR_BLUE, apal.HGR_WHITE])
|
||||
odd_on = np.isin(idx, [apal.HGR_GREEN, apal.HGR_ORANGE, apal.HGR_WHITE])
|
||||
bits[:, 0::2] = even_on
|
||||
bits[:, 1::2] = odd_on
|
||||
|
||||
data = apal.pack_hgr_color(bits, pal_byte)
|
||||
preview = np.repeat(prgb[idx], int(PIXEL_ASPECT), axis=1)
|
||||
|
||||
return Conversion(
|
||||
mode="hgr_color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
|
||||
viewer="hgr", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_lab, plab),
|
||||
meta={"palette": "hgr", "dither": dither_mode},
|
||||
)
|
||||
41
lenser/apple/convert/hgr_mono.py
Normal file
41
lenser/apple/convert/hgr_mono.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Apple II HGR monochrome: 280x192, 1 bit/pixel, black & white.
|
||||
|
||||
Universal across the Apple II+ and //e. Tone is carried entirely by dithering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither
|
||||
from ...convert.base import Conversion, perceptual_error
|
||||
from .. import palette as apal
|
||||
|
||||
WIDTH, HEIGHT = 280, 192
|
||||
PIXEL_ASPECT = 1.0
|
||||
DATA_ADDR = 0x2000 # HGR page 1
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
from ...palette import srgb_to_lab
|
||||
plab = apal.mono_lab() # 2 entries: black, white
|
||||
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 = apal.pack_hgr_mono(idx) # 8192-byte HGR buffer
|
||||
preview = (apal.MONO.astype(np.uint8))[idx]
|
||||
|
||||
return Conversion(
|
||||
mode="hgr_mono", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=DATA_ADDR,
|
||||
viewer="hgr", preview_rgb=preview,
|
||||
error=perceptual_error(idx, img_mono, plab_mono),
|
||||
meta={"palette": "mono", "dither": dither_mode},
|
||||
)
|
||||
15
lenser/apple/convert/mono.py
Normal file
15
lenser/apple/convert/mono.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Apple monochrome -- HGR's 280x192 black & white, exposed as the standard
|
||||
``mono`` mode for cross-platform parity (tone carried by dithering)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import hgr_mono
|
||||
|
||||
WIDTH, HEIGHT, PIXEL_ASPECT = hgr_mono.WIDTH, hgr_mono.HEIGHT, hgr_mono.PIXEL_ASPECT
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="mono", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
conv = hgr_mono.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color)
|
||||
conv.mode = "mono"
|
||||
return conv
|
||||
53
lenser/apple/dsk.py
Normal file
53
lenser/apple/dsk.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Write a bootable Apple II .dsk (DOS 3.3 sector order, 143360 bytes).
|
||||
|
||||
The .dsk stores sectors in DOS *logical* order, but the Disk II boot ROM reads
|
||||
*physical* sectors, so we place each chunk of the bitmap at the logical slot that
|
||||
maps to the physical sector our loader will read -- making the loader's
|
||||
physical-sequential reads come out contiguous in memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# DOS 3.3 physical-sector -> logical-sector (the .dsk read interleave).
|
||||
PHYS2LOG = [0, 7, 14, 6, 13, 5, 12, 4, 11, 3, 10, 2, 9, 1, 8, 15]
|
||||
|
||||
SECTOR = 256
|
||||
SPT = 16
|
||||
TRACKS = 35
|
||||
DISK_SIZE = TRACKS * SPT * SECTOR # 143360
|
||||
|
||||
|
||||
class DskError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _offset(track: int, phys_sector: int) -> int:
|
||||
return (track * SPT + PHYS2LOG[phys_sector]) * SECTOR
|
||||
|
||||
|
||||
def build_boot_dsk(boot: bytes, payload: bytes) -> bytes:
|
||||
"""boot = assembled boot0 (origin $0800); payload = data pages the loader reads
|
||||
in order: track 0 sectors 1-15, then whole tracks 1, 2, ... (physical order)."""
|
||||
if len(payload) % SECTOR:
|
||||
raise DskError("payload must be a whole number of 256-byte sectors")
|
||||
npages = len(payload) // SECTOR
|
||||
disk = bytearray(DISK_SIZE)
|
||||
disk[0:SECTOR] = (bytes(boot) + bytes(SECTOR))[:SECTOR] # boot0 at T0 phys 0
|
||||
|
||||
assigns = [(0, p) for p in range(1, 16)] # track 0 (skip boot)
|
||||
track = 1
|
||||
while len(assigns) < npages:
|
||||
assigns += [(track, p) for p in range(16)]
|
||||
track += 1
|
||||
if track > TRACKS:
|
||||
raise DskError("payload exceeds disk capacity")
|
||||
for page, (trk, phys) in enumerate(assigns[:npages]):
|
||||
off = _offset(trk, phys)
|
||||
disk[off:off + SECTOR] = payload[page * SECTOR:(page + 1) * SECTOR]
|
||||
return bytes(disk)
|
||||
|
||||
|
||||
def write_dsk(path: str, data: bytes) -> str:
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
10
lenser/apple/exporter.py
Normal file
10
lenser/apple/exporter.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Build a bootable Apple II .dsk from a conversion."""
|
||||
from __future__ import annotations
|
||||
from . import dsk
|
||||
from .viewer.assemble import assemble_stub
|
||||
|
||||
def export_dsk(conv, output_path, source_path=None, display="forever", seconds=0):
|
||||
if not output_path.lower().endswith((".dsk", ".do")):
|
||||
output_path += ".dsk"
|
||||
boot = assemble_stub(conv.viewer, display=display, seconds=seconds)
|
||||
return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, conv.data))
|
||||
107
lenser/apple/palette.py
Normal file
107
lenser/apple/palette.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Apple II colour palettes and the HGR memory-layout helper.
|
||||
|
||||
- HGR mono: black/white (the 280x192 1-bit bitmap displayed as monochrome).
|
||||
- HGR colour: 6 NTSC "artifact" colours (added later).
|
||||
- DHGR: 16 colours (added later).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
# Monochrome (white phosphor) pair.
|
||||
MONO = np.array([(0, 0, 0), (255, 255, 255)], dtype=np.float64)
|
||||
|
||||
# Double-Hi-Res 16-colour palette, indexed by the 4-bit value -- measured from
|
||||
# MAME's apple2ee DHGR output so the encoder's nibble values map to exactly what
|
||||
# the //e displays.
|
||||
DHGR16 = np.array([
|
||||
(0x00, 0x00, 0x00), # 0 black
|
||||
(0x40, 0x1c, 0xf7), # 1 blue
|
||||
(0x00, 0x74, 0x40), # 2 dark green
|
||||
(0x19, 0x90, 0xff), # 3 medium blue
|
||||
(0x40, 0x63, 0x00), # 4 olive / dark green
|
||||
(0x80, 0x80, 0x80), # 5 grey
|
||||
(0x19, 0xd7, 0x00), # 6 green
|
||||
(0x58, 0xf4, 0xbf), # 7 aqua
|
||||
(0xa7, 0x0b, 0x40), # 8 dark red / magenta
|
||||
(0xe6, 0x28, 0xff), # 9 magenta / violet
|
||||
(0x80, 0x80, 0x80), # 10 grey
|
||||
(0xbf, 0x9c, 0xff), # 11 lavender
|
||||
(0xe6, 0x6f, 0x00), # 12 orange
|
||||
(0xff, 0x8b, 0xbf), # 13 pink
|
||||
(0xbf, 0xe3, 0x08), # 14 yellow-green
|
||||
(0xff, 0xff, 0xff), # 15 white
|
||||
], dtype=np.float64)
|
||||
|
||||
|
||||
# HGR NTSC "artifact" colours. Per 7-pixel byte a palette bit selects one of two
|
||||
# colour pairs; the displayed colour of an "on" pixel also depends on its column
|
||||
# parity (and two adjacent on-pixels read as white).
|
||||
# palette 0: even column -> violet, odd column -> green
|
||||
# palette 1: even column -> blue, odd column -> orange
|
||||
HGR_BLACK, HGR_VIOLET, HGR_GREEN, HGR_WHITE, HGR_BLUE, HGR_ORANGE = 0, 1, 2, 3, 4, 5
|
||||
HGR6 = np.array([
|
||||
(0x00, 0x00, 0x00), # black
|
||||
(0xd0, 0x3a, 0xff), # violet
|
||||
(0x20, 0xc8, 0x00), # green
|
||||
(0xff, 0xff, 0xff), # white
|
||||
(0x20, 0x9a, 0xff), # blue
|
||||
(0xff, 0x6a, 0x20), # orange
|
||||
], dtype=np.float64)
|
||||
|
||||
|
||||
def mono_lab() -> np.ndarray:
|
||||
return srgb_to_lab(MONO)
|
||||
|
||||
|
||||
def hgr6_lab() -> np.ndarray:
|
||||
return srgb_to_lab(HGR6)
|
||||
|
||||
|
||||
def pack_hgr_color(bits280: np.ndarray, pal_byte: np.ndarray) -> bytes:
|
||||
"""280x192 mono bits + (192x40) per-byte palette bit -> 8192 HGR buffer."""
|
||||
buf = bytearray(0x2000)
|
||||
H = bits280.shape[0]
|
||||
for y in range(H):
|
||||
base = hgr_row_addr(y)
|
||||
row = bits280[y]
|
||||
for bx in range(40):
|
||||
b = 0
|
||||
for i in range(7):
|
||||
if row[bx * 7 + i]:
|
||||
b |= (1 << i)
|
||||
if pal_byte[y, bx]:
|
||||
b |= 0x80
|
||||
buf[base + bx] = b
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def dhgr_lab() -> np.ndarray:
|
||||
return srgb_to_lab(DHGR16)
|
||||
|
||||
|
||||
def hgr_row_addr(y: int) -> int:
|
||||
"""Offset (from $2000) of HGR row ``y`` (0..191) in the interleaved layout."""
|
||||
return (y & 7) * 0x400 + ((y >> 3) & 7) * 0x80 + (y >> 6) * 0x28
|
||||
|
||||
|
||||
def pack_hgr_mono(val_image: np.ndarray) -> bytes:
|
||||
"""280x192 1-bit image -> 8192-byte HGR page 1 buffer.
|
||||
|
||||
7 pixels per byte, bit 0 = leftmost, bit 7 (palette bit) = 0 for mono.
|
||||
"""
|
||||
buf = bytearray(0x2000)
|
||||
H, W = val_image.shape
|
||||
for y in range(H):
|
||||
base = hgr_row_addr(y)
|
||||
row = val_image[y]
|
||||
for bx in range(40):
|
||||
b = 0
|
||||
for i in range(7):
|
||||
if row[bx * 7 + i]:
|
||||
b |= (1 << i)
|
||||
buf[base + bx] = b
|
||||
return bytes(buf)
|
||||
1
lenser/apple/viewer/__init__.py
Normal file
1
lenser/apple/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401
|
||||
97
lenser/apple/viewer/assemble.py
Normal file
97
lenser/apple/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Assemble the Apple II boot/viewer with `xa` (origin $0800, raw bytes)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
SOURCES = {"hgr": "hgr.s", "dhgr": "dhgr.s"}
|
||||
_cache: dict[tuple, bytes] = {}
|
||||
|
||||
# How long the viewer holds the picture (see apple/viewer/awyt.i).
|
||||
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
|
||||
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
|
||||
|
||||
|
||||
def build_slideshow_stub(n_images: int, advance: str = "both", seconds: int = 10,
|
||||
loop: bool = True) -> bytes:
|
||||
"""Assemble the Apple HGR slideshow boot loader (one 256-byte boot sector).
|
||||
|
||||
Reads NIMAGES * 32 sectors into the $4000 buffer and cycles them; must fit a
|
||||
single boot sector since the Disk II ROM only loads sector 0.
|
||||
"""
|
||||
import shutil as _sh
|
||||
if not _sh.which("xa"):
|
||||
raise AssemblerError("The 'xa' assembler was not found on PATH.")
|
||||
end_page = 0x40 + n_images * 0x20
|
||||
wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
|
||||
f"#define WAITSECS {max(0, min(255, int(seconds)))}\n"
|
||||
f"#define NIMAGES {n_images}\n"
|
||||
f"#define LOOPFLAG {1 if loop else 0}\n"
|
||||
f"#define ENDPAGE ${end_page:02X}\n"
|
||||
'#include "slideshow.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:
|
||||
raw = f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
if len(raw) > 256:
|
||||
raise AssemblerError(
|
||||
f"Apple slideshow boot loader is {len(raw)} bytes, over the 256-byte "
|
||||
"boot sector")
|
||||
return raw
|
||||
|
||||
|
||||
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) -> bytes:
|
||||
waitmode = WAIT_MODES.get(display, 0)
|
||||
secs = max(0, min(255, int(seconds))) # 8-bit delay counter
|
||||
key = (viewer_key, waitmode, secs)
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' assembler was not found on PATH.")
|
||||
if not os.path.exists(os.path.join(VIEWER_DIR, SOURCES[viewer_key])):
|
||||
raise AssemblerError(f"viewer source missing: {SOURCES[viewer_key]}")
|
||||
|
||||
# Wrapper sets options then includes the real source; run 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 {secs}\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
|
||||
44
lenser/apple/viewer/awyt.i
Normal file
44
lenser/apple/viewer/awyt.i
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
; Shared display-duration epilogue for the Apple II viewers (HGR already on).
|
||||
; WAITMODE 0 forever, 1 until a key, 2 about WAITSECS seconds.
|
||||
; key and seconds exit to Applesoft BASIC ($E000 cold start), a real prompt with
|
||||
; no DOS needed. The II and II+ have no timer, so seconds is a calibrated delay
|
||||
; loop near 1 MHz. WAITSECS is clamped to 255 by the assembler.
|
||||
|
||||
#if WAITMODE == 0
|
||||
awhang:
|
||||
jmp awhang
|
||||
#endif
|
||||
|
||||
#if WAITMODE == 1
|
||||
awhang:
|
||||
lda $c000 ; keyboard; bit7 set = a key is down
|
||||
bpl awhang
|
||||
bit $c010 ; clear strobe
|
||||
lda $c051 ; switch to text so the BASIC prompt is visible
|
||||
lda $c054 ; page 1
|
||||
jmp $e000 ; Applesoft cold start
|
||||
#endif
|
||||
|
||||
#if WAITMODE == 2
|
||||
lda #WAITSECS
|
||||
sta $fd ; seconds remaining
|
||||
aw_o:
|
||||
lda #$03 ; ~1 second = 3 * (256*256 inner) at ~1 MHz
|
||||
sta $fc
|
||||
aw_m:
|
||||
ldx #$00
|
||||
aw_x:
|
||||
ldy #$00
|
||||
aw_y:
|
||||
dey
|
||||
bne aw_y
|
||||
dex
|
||||
bne aw_x
|
||||
dec $fc
|
||||
bne aw_m
|
||||
dec $fd
|
||||
bne aw_o
|
||||
lda $c051 ; switch to text so the BASIC prompt is visible
|
||||
lda $c054 ; page 1
|
||||
jmp $e000 ; Applesoft cold start
|
||||
#endif
|
||||
128
lenser/apple/viewer/dhgr.s
Normal file
128
lenser/apple/viewer/dhgr.s
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
; lenser -- Apple //e Double Hi-Res boot loader + viewer (self-contained)
|
||||
;
|
||||
; Loads 16K to main $2000-$5FFF with ordinary reads (main half at $2000, aux half
|
||||
; at $4000), then block-copies $4000-$5FFF into auxiliary $2000-$3FFF (RAMWRT on
|
||||
; only for that clean copy, never during the boot-ROM reads), then turns on DHGR.
|
||||
; ROM read at $C65C reads sector $3D into page $27 and re-enters $0801.
|
||||
;
|
||||
; assembled by apple/viewer/assemble.py via xa
|
||||
|
||||
* = $0800
|
||||
.byte $01
|
||||
entry: ; $0801, re-entered after each ROM read
|
||||
lda dpage
|
||||
cmp #$60 ; loaded $2000-$5FFF (64 pages)?
|
||||
bcs done
|
||||
lda psec
|
||||
cmp #$10
|
||||
bcc readit
|
||||
jsr seeknext
|
||||
lda #$00
|
||||
sta psec
|
||||
readit:
|
||||
lda psec
|
||||
sta $3d ; desired sector
|
||||
lda curtrk
|
||||
sta $41 ; desired track (the ROM read verifies BOTH)
|
||||
lda #$00
|
||||
sta $26
|
||||
lda dpage
|
||||
sta $27
|
||||
inc psec
|
||||
inc dpage
|
||||
ldx $2b
|
||||
jmp $c65c
|
||||
|
||||
done:
|
||||
; copy main $4000-$5FFF -> aux $2000-$3FFF
|
||||
lda #$00
|
||||
sta $06
|
||||
lda #$40
|
||||
sta $07 ; src = $4000
|
||||
lda #$00
|
||||
sta $08
|
||||
lda #$20
|
||||
sta $09 ; dst = $2000
|
||||
sta $c005 ; RAMWRT on (writes go to aux)
|
||||
ldx #$20 ; 32 pages
|
||||
cpl:
|
||||
ldy #$00
|
||||
cp1:
|
||||
lda ($06),y
|
||||
sta ($08),y
|
||||
iny
|
||||
bne cp1
|
||||
inc $07
|
||||
inc $09
|
||||
dex
|
||||
bne cpl
|
||||
sta $c004 ; RAMWRT off
|
||||
; turn on Double Hi-Res
|
||||
lda $c050 ; graphics
|
||||
lda $c052 ; full screen
|
||||
lda $c054 ; page 1
|
||||
lda $c057 ; hi-res
|
||||
sta $c00d ; SET80VID (write-triggered switch -- must STA)
|
||||
sta $c05e ; SETDHIRES (write-triggered)
|
||||
#include "awyt.i"
|
||||
|
||||
; advance the head one track (two half-steps) with the standard phase-overlap
|
||||
; seek. energize the NEXT phase while the current one is still on (this pulls
|
||||
; the head smoothly), then release the old phase, using on/off settle delays
|
||||
; from an acceleration table indexed by step number ($0a). the final phase is
|
||||
; released before reading.
|
||||
seeknext:
|
||||
inc curtrk ; now on the next track
|
||||
lda #$00
|
||||
sta $0a ; step index for the timing table
|
||||
jsr onestep
|
||||
jsr onestep
|
||||
lda halftrk ; release final phase before the read
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
rts
|
||||
onestep:
|
||||
lda halftrk ; energize NEXT phase (halftrk+1), old still on
|
||||
clc
|
||||
adc #$01
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c081,x
|
||||
ldx $0a
|
||||
lda ontable,x
|
||||
jsr wait
|
||||
lda halftrk ; release OLD phase (halftrk)
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
ldx $0a
|
||||
lda offtable,x
|
||||
jsr wait
|
||||
inc halftrk
|
||||
inc $0a
|
||||
rts
|
||||
; Apple seek timing tables (head accelerates over a move, slow first step).
|
||||
ontable: .byte $13,$0a,$08,$06,$05,$04,$04,$03
|
||||
offtable: .byte $46,$1a,$10,$0c,$0a,$09,$08,$08
|
||||
wait: ; delay loop ~ proportional to A
|
||||
tay
|
||||
w1:
|
||||
ldx #$00
|
||||
w2:
|
||||
dex
|
||||
bne w2
|
||||
dey
|
||||
bne w1
|
||||
rts
|
||||
|
||||
psec: .byte $01
|
||||
dpage: .byte $20
|
||||
halftrk: .byte $00
|
||||
curtrk: .byte $00
|
||||
102
lenser/apple/viewer/hgr.s
Normal file
102
lenser/apple/viewer/hgr.s
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
; lenser -- Apple II HGR boot loader + viewer (self-contained, no DOS)
|
||||
;
|
||||
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
|
||||
; re-entrant loader reads the 32 sectors of the 8K HGR bitmap into $2000-$3FFF
|
||||
; (track 0 sectors 1-15, then tracks 1 and 2), seeking the head between tracks
|
||||
; with the standard phase-overlap step, then switches on HGR and loops. The ROM
|
||||
; read at $Cn5C verifies the address field against BOTH the sector ($3D) and the
|
||||
; track ($41), then reads into page $27 and JMPs back to $0801.
|
||||
;
|
||||
; assembled by apple/viewer/assemble.py via xa
|
||||
|
||||
* = $0800
|
||||
.byte $01 ; ROM scratch (boot sector byte 0)
|
||||
entry: ; $0801, (re)entered after every ROM read
|
||||
lda dpage
|
||||
cmp #$40
|
||||
bcs done ; loaded $2000..$3FFF -> show it
|
||||
lda psec
|
||||
cmp #$10
|
||||
bcc readit ; still sectors left on this track
|
||||
jsr seeknext ; finished a track -> step to the next
|
||||
lda #$00
|
||||
sta psec
|
||||
readit:
|
||||
lda psec
|
||||
sta $3d ; desired sector
|
||||
lda curtrk
|
||||
sta $41 ; desired track (ROM read verifies both)
|
||||
lda #$00
|
||||
sta $26 ; buffer lo
|
||||
lda dpage
|
||||
sta $27 ; buffer hi
|
||||
inc psec
|
||||
inc dpage
|
||||
ldx $2b ; slot*16 (set by boot ROM)
|
||||
jmp $c65c ; slot 6 ROM read; reads sector then JMP $0801
|
||||
|
||||
done:
|
||||
lda $c050 ; graphics
|
||||
lda $c052 ; full screen (not mixed)
|
||||
lda $c054 ; page 1
|
||||
lda $c057 ; hi-res
|
||||
#include "awyt.i"
|
||||
|
||||
; advance the head one track (two half-steps) with the standard phase-overlap
|
||||
; step. energize the next phase while the current one is still on, then release
|
||||
; the old phase, using on/off settle delays from an acceleration table.
|
||||
seeknext:
|
||||
inc curtrk
|
||||
lda #$00
|
||||
sta $0a
|
||||
jsr onestep
|
||||
jsr onestep
|
||||
lda halftrk ; release final phase before reading
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
rts
|
||||
onestep:
|
||||
lda halftrk ; energize NEXT phase (old still on -> overlap)
|
||||
clc
|
||||
adc #$01
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c081,x
|
||||
ldx $0a
|
||||
lda ontable,x
|
||||
jsr wait
|
||||
lda halftrk ; release OLD phase
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
ldx $0a
|
||||
lda offtable,x
|
||||
jsr wait
|
||||
inc halftrk
|
||||
inc $0a
|
||||
rts
|
||||
ontable: .byte $13,$0a,$08,$06
|
||||
offtable: .byte $46,$1a,$10,$0c
|
||||
wait:
|
||||
tay
|
||||
w1:
|
||||
ldx #$00
|
||||
w2:
|
||||
dex
|
||||
bne w2
|
||||
dey
|
||||
bne w1
|
||||
rts
|
||||
|
||||
; ---- loader state (initial values; updated in place during the load) ----
|
||||
psec: .byte $01 ; next physical sector (track 0 starts at 1)
|
||||
dpage: .byte $20 ; next destination page ($2000)
|
||||
halftrk: .byte $00 ; current half-track
|
||||
curtrk: .byte $00 ; current track
|
||||
204
lenser/apple/viewer/slideshow.s
Normal file
204
lenser/apple/viewer/slideshow.s
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
; lenser -- Apple II HGR slideshow loader + viewer (self-contained, no DOS).
|
||||
;
|
||||
; The Disk II boot ROM loads track 0 sector 0 to $0800 and JMPs $0801. This
|
||||
; loader reads ALL the slideshow's HGR images (NIMAGES * 32 sectors) contiguously
|
||||
; into a RAM buffer from $4000 up (image i at $4000 + i*$2000), then cycles --
|
||||
; copying each image into HGR page 1 ($2000), switching on graphics, and waiting
|
||||
; (key / seconds / both) before the next. Looping is RAM-only (no re-seek).
|
||||
;
|
||||
; #defines from the wrapper --
|
||||
; WAITMODE 1 key / 2 seconds / 3 both WAITSECS seconds (~) NIMAGES count
|
||||
; LOOPFLAG 1 wrap / 0 stop ENDPAGE one past the last buffer page ($40+N*$20)
|
||||
;
|
||||
; assembled by apple/viewer/assemble.py via xa
|
||||
|
||||
src = $06 ; zero-page copy pointers
|
||||
dst = $08
|
||||
ssidx = $19
|
||||
|
||||
* = $0800
|
||||
.byte $01 ; ROM scratch (boot sector byte 0)
|
||||
entry: ; $0801, (re)entered after every ROM read
|
||||
lda dpage
|
||||
cmp #ENDPAGE
|
||||
bcs loaded ; whole buffer read -> start the show
|
||||
lda psec
|
||||
cmp #$10
|
||||
bcc readit
|
||||
jsr seeknext
|
||||
lda #$00
|
||||
sta psec
|
||||
readit:
|
||||
lda psec
|
||||
sta $3d
|
||||
lda curtrk
|
||||
sta $41
|
||||
lda #$00
|
||||
sta $26
|
||||
lda dpage
|
||||
sta $27
|
||||
inc psec
|
||||
inc dpage
|
||||
ldx $2b
|
||||
jmp $c65c ; slot-6 ROM read; reads a sector then JMP $0801
|
||||
|
||||
loaded:
|
||||
lda #$00
|
||||
sta ssidx
|
||||
cyc:
|
||||
; ---- copy image ssidx ($4000 + ssidx*$2000) -> HGR page 1 ($2000) ----
|
||||
lda ssidx
|
||||
asl
|
||||
asl
|
||||
asl
|
||||
asl
|
||||
asl ; ssidx * $20 pages (carry clear, <=4 images)
|
||||
adc #$40
|
||||
sta src+1 ; source hi = $40 + ssidx*$20
|
||||
lda #$00
|
||||
sta src
|
||||
sta dst
|
||||
lda #$20
|
||||
sta dst+1 ; dest = $2000
|
||||
ldx #$20 ; 32 pages
|
||||
ldy #$00
|
||||
cpyl:
|
||||
lda (src),y
|
||||
sta (dst),y
|
||||
iny
|
||||
bne cpyl
|
||||
inc src+1
|
||||
inc dst+1
|
||||
dex
|
||||
bne cpyl
|
||||
|
||||
lda $c050 ; graphics
|
||||
lda $c054 ; page 1
|
||||
lda $c057 ; hi-res
|
||||
|
||||
jsr sswait
|
||||
|
||||
inc ssidx
|
||||
lda ssidx
|
||||
cmp #NIMAGES
|
||||
bcc cyc
|
||||
#if LOOPFLAG == 1
|
||||
jmp loaded ; wrap (re-uses the ssidx=0 init at loaded)
|
||||
#else
|
||||
lda $c051 ; text
|
||||
lda $c054
|
||||
jmp $e000 ; Applesoft cold start
|
||||
#endif
|
||||
|
||||
; ---- wait (returns); clears the key strobe first ----
|
||||
sswait:
|
||||
bit $c010
|
||||
#if WAITMODE == 1
|
||||
swk:
|
||||
lda $c000
|
||||
bpl swk
|
||||
bit $c010
|
||||
rts
|
||||
#endif
|
||||
#if WAITMODE == 2
|
||||
lda #WAITSECS
|
||||
sta $fd
|
||||
so:
|
||||
lda #$03
|
||||
sta $fc
|
||||
sm:
|
||||
ldx #$00
|
||||
sx:
|
||||
ldy #$00
|
||||
sy:
|
||||
dey
|
||||
bne sy
|
||||
dex
|
||||
bne sx
|
||||
dec $fc
|
||||
bne sm
|
||||
dec $fd
|
||||
bne so
|
||||
rts
|
||||
#endif
|
||||
#if WAITMODE == 3
|
||||
lda #WAITSECS
|
||||
sta $fd
|
||||
bo:
|
||||
lda #$03
|
||||
sta $fc
|
||||
bm:
|
||||
ldx #$00
|
||||
bx:
|
||||
lda $c000
|
||||
bmi bdone ; a key ends the slide
|
||||
ldy #$00
|
||||
by:
|
||||
dey
|
||||
bne by
|
||||
dex
|
||||
bne bx
|
||||
dec $fc
|
||||
bne bm
|
||||
dec $fd
|
||||
bne bo
|
||||
bdone:
|
||||
bit $c010 ; (also harmless on timeout)
|
||||
rts
|
||||
#endif
|
||||
|
||||
; advance the head one track (two half-steps), phase-overlap step (from hgr.s)
|
||||
seeknext:
|
||||
inc curtrk
|
||||
lda #$00
|
||||
sta $0a
|
||||
jsr onestep
|
||||
jsr onestep
|
||||
lda halftrk
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
rts
|
||||
onestep:
|
||||
lda halftrk
|
||||
clc
|
||||
adc #$01
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c081,x
|
||||
ldx $0a
|
||||
lda ontable,x
|
||||
jsr wait
|
||||
lda halftrk
|
||||
and #$03
|
||||
asl
|
||||
ora $2b
|
||||
tax
|
||||
lda $c080,x
|
||||
ldx $0a
|
||||
lda offtable,x
|
||||
jsr wait
|
||||
inc halftrk
|
||||
inc $0a
|
||||
rts
|
||||
ontable: .byte $13,$0a,$08,$06
|
||||
offtable: .byte $46,$1a,$10,$0c
|
||||
wait:
|
||||
tay
|
||||
w1:
|
||||
ldx #$00
|
||||
w2:
|
||||
dex
|
||||
bne w2
|
||||
dey
|
||||
bne w1
|
||||
rts
|
||||
|
||||
psec: .byte $01 ; next physical sector (track 0 starts at 1)
|
||||
dpage: .byte $40 ; next destination page ($4000)
|
||||
halftrk: .byte $00
|
||||
curtrk: .byte $00
|
||||
1
lenser/atari/__init__.py
Normal file
1
lenser/atari/__init__.py
Normal 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
101
lenser/atari/atr.py
Normal 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
20
lenser/atari/car.py
Normal 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
|
||||
31
lenser/atari/convert/__init__.py
Normal file
31
lenser/atari/convert/__init__.py
Normal 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)
|
||||
176
lenser/atari/convert/_common.py
Normal file
176
lenser/atari/convert/_common.py
Normal 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
|
||||
51
lenser/atari/convert/gr15.py
Normal file
51
lenser/atari/convert/gr15.py
Normal 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},
|
||||
)
|
||||
84
lenser/atari/convert/gr15dli.py
Normal file
84
lenser/atari/convert/gr15dli.py
Normal 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},
|
||||
)
|
||||
40
lenser/atari/convert/gr8.py
Normal file
40
lenser/atari/convert/gr8.py
Normal 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},
|
||||
)
|
||||
48
lenser/atari/convert/gr9.py
Normal file
48
lenser/atari/convert/gr9.py
Normal 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},
|
||||
)
|
||||
16
lenser/atari/convert/mono.py
Normal file
16
lenser/atari/convert/mono.py
Normal 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
32
lenser/atari/exporter.py
Normal 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
96
lenser/atari/palette.py
Normal 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
|
||||
1
lenser/atari/viewer/__init__.py
Normal file
1
lenser/atari/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .assemble import AssemblerError, SOURCES, assemble_stub, have_xa # noqa: F401
|
||||
156
lenser/atari/viewer/assemble.py
Normal file
156
lenser/atari/viewer/assemble.py
Normal 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)
|
||||
48
lenser/atari/viewer/awyt.i
Normal file
48
lenser/atari/viewer/awyt.i
Normal 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
|
||||
67
lenser/atari/viewer/cart.s
Normal file
67
lenser/atari/viewer/cart.s
Normal 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
|
||||
50
lenser/atari/viewer/gr15.s
Normal file
50
lenser/atari/viewer/gr15.s
Normal 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
|
||||
100
lenser/atari/viewer/gr15dli.s
Normal file
100
lenser/atari/viewer/gr15dli.s
Normal 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
42
lenser/atari/viewer/gr8.s
Normal 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
39
lenser/atari/viewer/gr9.s
Normal 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
|
||||
248
lenser/atari/viewer/slideshow_dli.s
Normal file
248
lenser/atari/viewer/slideshow_dli.s
Normal 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
|
||||
272
lenser/atari/viewer/slideshow_static.s
Normal file
272
lenser/atari/viewer/slideshow_static.s
Normal 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
|
||||
120
lenser/basicgen.py
Normal file
120
lenser/basicgen.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Generate a small, colourful Commodore 64 BASIC program (tokenised .PRG) that
|
||||
prints image metadata. We emit the tokenised bytes directly -- token bytes for
|
||||
the few keywords used (PRINT, POKE) and PETSCII for everything else -- so there
|
||||
is no dependency on an external BASIC tokeniser.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# BASIC V2 keyword tokens.
|
||||
_PRINT = 0x99
|
||||
_POKE = 0x97
|
||||
_CHR = 0xC7 # CHR$( function token
|
||||
_QUOTE = 0x22
|
||||
|
||||
# PETSCII control codes.
|
||||
CLR = 0x93
|
||||
RVON = 0x12
|
||||
RVOFF = 0x92
|
||||
# colours
|
||||
WHITE, RED, GREEN, BLUE = 0x05, 0x1c, 0x1e, 0x1f
|
||||
BLACK, ORANGE, BROWN = 0x90, 0x81, 0x95
|
||||
LT_RED, DK_GREY, GREY, LT_GREEN = 0x96, 0x97, 0x98, 0x99
|
||||
LT_BLUE, LT_GREY, PURPLE, YELLOW, CYAN = 0x9a, 0x9b, 0x9c, 0x9e, 0x9f
|
||||
|
||||
# label colours cycled down the screen (none black, all readable on black).
|
||||
_LABEL_COLOURS = [CYAN, YELLOW, LT_GREEN, LT_RED, LT_BLUE, PURPLE, ORANGE, GREEN]
|
||||
# per-character rainbow for the title (bright, distinct, no black).
|
||||
_RAINBOW = [RED, ORANGE, YELLOW, LT_GREEN, GREEN, CYAN, LT_BLUE, BLUE, PURPLE,
|
||||
LT_RED, WHITE]
|
||||
|
||||
_SCREEN_W = 40
|
||||
_VALUE_COL = 11 # column where a value starts (= label field width)
|
||||
_LINE_MAX = _SCREEN_W - 1 # keep lines < 40 so the screen never auto-wraps
|
||||
_VALUE_W = _LINE_MAX - _VALUE_COL # printable value chars per line
|
||||
|
||||
|
||||
def _petscii(text: str) -> bytes:
|
||||
"""Map an ASCII string to printable PETSCII (upper-case glyph range)."""
|
||||
out = bytearray()
|
||||
for ch in str(text).upper():
|
||||
b = ord(ch)
|
||||
if b == _QUOTE:
|
||||
b = 0x27 # avoid closing the BASIC string
|
||||
if 0x20 <= b <= 0x5F:
|
||||
out.append(b)
|
||||
else:
|
||||
out.append(0x2E) # '.'
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _print_str(inner: bytes) -> bytes:
|
||||
return bytes([_PRINT, _QUOTE]) + inner + bytes([_QUOTE])
|
||||
|
||||
|
||||
def _assemble(lines: list[tuple[int, bytes]]) -> bytes:
|
||||
"""Link tokenised lines into a PRG (load address $0801)."""
|
||||
cur = 0x0801
|
||||
pieces = []
|
||||
for num, toks in lines:
|
||||
body = bytes([num & 0xFF, (num >> 8) & 0xFF]) + toks + b"\x00"
|
||||
nxt = cur + 2 + len(body)
|
||||
pieces.append(bytes([nxt & 0xFF, (nxt >> 8) & 0xFF]) + body)
|
||||
cur = nxt
|
||||
return bytes([0x01, 0x08]) + b"".join(pieces) + b"\x00\x00"
|
||||
|
||||
|
||||
def _rainbow_title(text: str) -> bytes:
|
||||
"""Centred, per-character rainbow title (control codes don't take columns)."""
|
||||
pad = max(0, (_SCREEN_W - len(text)) // 2)
|
||||
out = bytes([CLR]) + _petscii(" " * pad)
|
||||
for k, ch in enumerate(text):
|
||||
out += bytes([_RAINBOW[k % len(_RAINBOW)]]) + _petscii(ch)
|
||||
return bytes([_PRINT, _QUOTE]) + out + bytes([_QUOTE])
|
||||
|
||||
|
||||
def _field_lines(label: str, value: str, colour: int) -> list[bytes]:
|
||||
"""Word-/width-wrapped PRINT lines for one field; continuations are indented
|
||||
to the value's start column so a long value lines up under itself."""
|
||||
label_p = _petscii((label + ":").ljust(_VALUE_COL))
|
||||
value = str(value)[:_VALUE_W * 4] # cap at four screen lines
|
||||
chunks = [value[i:i + _VALUE_W] for i in range(0, len(value), _VALUE_W)] or [""]
|
||||
|
||||
out = [_print_str(bytes([colour]) + label_p + bytes([WHITE]) + _petscii(chunks[0]))]
|
||||
indent = _petscii(" " * _VALUE_COL)
|
||||
for chunk in chunks[1:]:
|
||||
out.append(_print_str(bytes([WHITE]) + indent + _petscii(chunk)))
|
||||
return out
|
||||
|
||||
|
||||
def build_info_prg(fields: list[tuple[str, str]]) -> bytes:
|
||||
"""Return a tokenised BASIC PRG that prints ``fields`` (label, value)."""
|
||||
lines: list[tuple[int, bytes]] = []
|
||||
num = 0
|
||||
|
||||
def add(toks: bytes):
|
||||
nonlocal num
|
||||
num += 10
|
||||
lines.append((num, toks))
|
||||
|
||||
# border and background both black
|
||||
add(bytes([_POKE]) + b"53280,0:" + bytes([_POKE]) + b"53281,0")
|
||||
add(_rainbow_title("8 Bit Lenser picture info"))
|
||||
add(bytes([_PRINT])) # blank line
|
||||
|
||||
for i, (label, value) in enumerate(fields):
|
||||
col = _LABEL_COLOURS[i % len(_LABEL_COLOURS)]
|
||||
for line in _field_lines(label, value, col):
|
||||
add(line)
|
||||
|
||||
add(bytes([_PRINT]))
|
||||
# PRINT " load "CHR$(34)"*"CHR$(34)",8,1 to view picture" -- CHR$(34) is the
|
||||
# double-quote, which can't appear literally inside a BASIC string.
|
||||
q = bytes([_CHR]) + _petscii("(34)")
|
||||
add(bytes([_PRINT])
|
||||
+ bytes([_QUOTE]) + bytes([GREY]) + _petscii(" load ") + bytes([_QUOTE])
|
||||
+ q
|
||||
+ bytes([_QUOTE]) + _petscii("*") + bytes([_QUOTE])
|
||||
+ q
|
||||
+ bytes([_QUOTE]) + _petscii(",8,1 to view picture") + bytes([_QUOTE]))
|
||||
return _assemble(lines)
|
||||
1
lenser/bbc/__init__.py
Normal file
1
lenser/bbc/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""BBC Micro (Model B) image conversion and DFS disk / sideways-ROM export."""
|
||||
17
lenser/bbc/convert/__init__.py
Normal file
17
lenser/bbc/convert/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""BBC Micro conversion dispatch."""
|
||||
from __future__ import annotations
|
||||
from ... import imageprep
|
||||
from . import mode0, mode1, mode2, mode5, mono
|
||||
|
||||
_MODULES = {"mode0": mode0, "mode1": mode1, "mode2": mode2, "mode5": mode5,
|
||||
"mono": mono}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="mode2", palette_name="bbc",
|
||||
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)
|
||||
72
lenser/bbc/convert/_common.py
Normal file
72
lenser/bbc/convert/_common.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Shared helpers for the BBC Micro converters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ... import dither
|
||||
from ...convert.base import Conversion, perceptual_error, _box_blur
|
||||
from ...palette import srgb_to_lab
|
||||
from .. import palette as bpal
|
||||
|
||||
|
||||
def _choose_physicals(img_lab, n, dither_mode):
|
||||
"""Pick the n physical colours (of 8) that best reproduce the image, then
|
||||
dither once with that palette. Candidates are ranked by a fast vectorised
|
||||
proxy -- the perceptual error of the nearest-colour (un-dithered)
|
||||
reconstruction -- so we do NOT run the slow Floyd dither for all C(8,n)
|
||||
combinations (doing so made the 4-colour modes hang for a minute+); Floyd runs
|
||||
only on the winning palette. Returns (physical_indices, logical_idx, error)."""
|
||||
plab_all = bpal.phys_lab()
|
||||
H, W, _ = img_lab.shape
|
||||
target_blur = _box_blur(img_lab)
|
||||
best_combo, best_score = None, np.inf
|
||||
for combo in itertools.combinations(range(8), n):
|
||||
sub = plab_all[list(combo)]
|
||||
nidx = ((img_lab[:, :, None, :] - sub[None, None]) ** 2).sum(-1).argmin(-1)
|
||||
diff = _box_blur(sub[nidx]) - target_blur
|
||||
score = float(np.sqrt((diff ** 2).sum(-1)).mean())
|
||||
if score < best_score:
|
||||
best_score, best_combo = score, list(combo)
|
||||
sub = plab_all[best_combo]
|
||||
allowed = np.tile(np.arange(n), (H, W, 1))
|
||||
idx = dither.quantize(img_lab, allowed, sub, dither_mode).astype(np.uint8)
|
||||
return best_combo, idx, perceptual_error(idx, img_lab, sub)
|
||||
|
||||
|
||||
def build(img_rgb, *, mode, bbc_mode, ncol, bpp, width, height, base,
|
||||
dither_mode, mono=False):
|
||||
if mono:
|
||||
L = srgb_to_lab(img_rgb)[..., 0]
|
||||
img_lab = np.zeros((height, width, 3))
|
||||
img_lab[..., 0] = L
|
||||
plab = np.zeros((2, 3)); plab[:, 0] = bpal.mono_lab()[:, 0]
|
||||
allowed = np.tile(np.array([0, 1]), (height, width, 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
physicals = [0, 7] # black, white
|
||||
err = perceptual_error(idx, img_lab, plab)
|
||||
prgb = bpal.PHYS[[0, 7]].astype(np.uint8)
|
||||
else:
|
||||
img_lab = srgb_to_lab(img_rgb)
|
||||
if ncol >= 8:
|
||||
physicals = list(range(8))
|
||||
plab = bpal.phys_lab()
|
||||
allowed = np.tile(np.arange(8), (height, width, 1))
|
||||
idx = dither.quantize(img_lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
err = perceptual_error(idx, img_lab, plab)
|
||||
else:
|
||||
physicals, idx, err = _choose_physicals(img_lab, ncol, dither_mode)
|
||||
prgb = bpal.PHYS[physicals].astype(np.uint8)
|
||||
|
||||
data = bpal.pack(idx, width, bpp)
|
||||
preview = prgb[idx]
|
||||
return Conversion(
|
||||
mode=mode, width=width, height=height,
|
||||
pixel_aspect=(4 / 3) / (width / height),
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=base,
|
||||
viewer="bbc", preview_rgb=preview, error=err,
|
||||
meta={"palette": "bbc", "dither": dither_mode, "bbc_mode": bbc_mode,
|
||||
"ncol": ncol, "physicals": physicals, "base": base},
|
||||
)
|
||||
9
lenser/bbc/convert/mode0.py
Normal file
9
lenser/bbc/convert/mode0.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""BBC MODE 0: 640x256, 2-colour (black & white). 20K screen at &3000."""
|
||||
from __future__ import annotations
|
||||
from . import _common
|
||||
WIDTH, HEIGHT = 640, 256
|
||||
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
|
||||
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
|
||||
return _common.build(img_rgb, mode="mode0", bbc_mode=0, ncol=2, bpp=1,
|
||||
width=WIDTH, height=HEIGHT, base=0x3000,
|
||||
dither_mode=dither_mode, mono=True)
|
||||
8
lenser/bbc/convert/mode1.py
Normal file
8
lenser/bbc/convert/mode1.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""BBC MODE 1: 320x256, 4-colour (best 4 of 8). 20K screen at &3000."""
|
||||
from __future__ import annotations
|
||||
from . import _common
|
||||
WIDTH, HEIGHT = 320, 256
|
||||
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
|
||||
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
|
||||
return _common.build(img_rgb, mode="mode1", bbc_mode=1, ncol=4, bpp=2,
|
||||
width=WIDTH, height=HEIGHT, base=0x3000, dither_mode=dither_mode)
|
||||
8
lenser/bbc/convert/mode2.py
Normal file
8
lenser/bbc/convert/mode2.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""BBC MODE 2: 160x256, 8-colour. 20K screen at &3000."""
|
||||
from __future__ import annotations
|
||||
from . import _common
|
||||
WIDTH, HEIGHT = 160, 256
|
||||
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
|
||||
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
|
||||
return _common.build(img_rgb, mode="mode2", bbc_mode=2, ncol=8, bpp=4,
|
||||
width=WIDTH, height=HEIGHT, base=0x3000, dither_mode=dither_mode)
|
||||
8
lenser/bbc/convert/mode5.py
Normal file
8
lenser/bbc/convert/mode5.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""BBC MODE 5: 160x256, 4-colour (best 4 of 8). 10K screen at &5800 (fits a ROM)."""
|
||||
from __future__ import annotations
|
||||
from . import _common
|
||||
WIDTH, HEIGHT = 160, 256
|
||||
PIXEL_ASPECT = (4 / 3) / (WIDTH / HEIGHT)
|
||||
def convert(img_rgb, palette_name="bbc", dither_mode="floyd", intensive=False, base_color=None):
|
||||
return _common.build(img_rgb, mode="mode5", bbc_mode=5, ncol=4, bpp=2,
|
||||
width=WIDTH, height=HEIGHT, base=0x5800, dither_mode=dither_mode)
|
||||
15
lenser/bbc/convert/mono.py
Normal file
15
lenser/bbc/convert/mono.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""BBC monochrome -- MODE 0's 640x256 black & white, exposed as the standard
|
||||
``mono`` mode for cross-platform parity (tone carried by dithering)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import mode0
|
||||
|
||||
WIDTH, HEIGHT, PIXEL_ASPECT = mode0.WIDTH, mode0.HEIGHT, mode0.PIXEL_ASPECT
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="bbc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
conv = mode0.convert(img_rgb, palette_name, dither_mode, intensive,
|
||||
base_color=base_color)
|
||||
conv.mode = "mono"
|
||||
return conv
|
||||
24
lenser/bbc/exporter.py
Normal file
24
lenser/bbc/exporter.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Build a BBC Micro DFS disk (.ssd) from a conversion."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import ssd
|
||||
from .viewer.assemble import LOAD_ADDR, build_viewer
|
||||
|
||||
|
||||
def export_ssd(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="pal") -> str:
|
||||
"""Two DFS files: the !BOOT loader (sets mode + palette, *LOADs IMG) and the
|
||||
IMG screen data. Boot option 2 (*RUN !BOOT) so SHIFT+BREAK autostarts it."""
|
||||
if not output_path.lower().endswith(".ssd"):
|
||||
output_path += ".ssd"
|
||||
m = conv.meta
|
||||
viewer = build_viewer(m["bbc_mode"], m["ncol"], m["physicals"], m["base"],
|
||||
display=display, seconds=seconds, video=video)
|
||||
# !BOOT autostarts on SHIFT+BREAK (real hardware); PIC is the same loader
|
||||
# under a name with no '!' so it can be *RUN from a command line / emulator.
|
||||
files = [
|
||||
("!BOOT", LOAD_ADDR, LOAD_ADDR, viewer),
|
||||
("PIC", LOAD_ADDR, LOAD_ADDR, viewer),
|
||||
("IMG", m["base"], m["base"], conv.data),
|
||||
]
|
||||
return ssd.write_ssd(output_path, files, title="8BITLENSER", boot_option=2)
|
||||
70
lenser/bbc/palette.py
Normal file
70
lenser/bbc/palette.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""BBC Micro (Video ULA + 6845) palette and screen packing.
|
||||
|
||||
8 physical colours -- pure digital RGB. Modes map 1/2/4 bits-per-pixel of
|
||||
*logical* colour through a programmable palette (VDU 19) to these physicals.
|
||||
|
||||
Screen memory is character-cell interleaved: 8x8 cells ordered left-to-right then
|
||||
top-to-bottom; within a cell the bytes go by scanline, and each scanline spans
|
||||
1/2/4 bytes (2/4/8-colour). Pixel bits within a byte are interleaved, leftmost
|
||||
pixel in the high bits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
# Physical colours 0..7.
|
||||
PHYS = np.array([
|
||||
(0, 0, 0), # 0 black
|
||||
(255, 0, 0), # 1 red
|
||||
(0, 255, 0), # 2 green
|
||||
(255, 255, 0), # 3 yellow
|
||||
(0, 0, 255), # 4 blue
|
||||
(255, 0, 255), # 5 magenta
|
||||
(0, 255, 255), # 6 cyan
|
||||
(255, 255, 255), # 7 white
|
||||
], dtype=np.float64)
|
||||
|
||||
|
||||
def phys_lab() -> np.ndarray:
|
||||
return srgb_to_lab(PHYS)
|
||||
|
||||
|
||||
def mono_lab() -> np.ndarray:
|
||||
return srgb_to_lab(PHYS[[0, 7]]) # black + white
|
||||
|
||||
|
||||
def _byte_for_pixels(vals, bits_per_pixel):
|
||||
"""Encode the pixels covering one byte into the BBC interleaved layout.
|
||||
Leftmost pixel uses the highest bit of each bit-plane group."""
|
||||
n = len(vals) # pixels per byte (8/4/2)
|
||||
b = 0
|
||||
for bit in range(bits_per_pixel - 1, -1, -1): # high plane first
|
||||
for i, v in enumerate(vals): # left pixel first
|
||||
b = (b << 1) | ((v >> bit) & 1)
|
||||
return b
|
||||
|
||||
|
||||
def pack(idx: np.ndarray, width: int, bits_per_pixel: int) -> bytes:
|
||||
"""Pack a (height, width) logical-colour array into BBC screen bytes.
|
||||
|
||||
The BBC layout is universal: one byte holds ``ppb`` horizontally-adjacent
|
||||
pixels, and 8 consecutive bytes step down the 8 raster lines of that
|
||||
byte-column before moving one byte-column to the right; whole character rows
|
||||
(8 raster lines) then follow top-to-bottom. So
|
||||
addr = char_row*(num_byte_cols*8) + byte_col*8 + raster
|
||||
"""
|
||||
h, w = idx.shape
|
||||
ppb = 8 // bits_per_pixel # pixels per byte
|
||||
num_byte_cols = w // ppb
|
||||
row_stride = num_byte_cols * 8 # bytes per character row
|
||||
out = bytearray((w * h) // ppb)
|
||||
for y in range(h):
|
||||
base = (y // 8) * row_stride + (y % 8)
|
||||
for bc in range(num_byte_cols):
|
||||
x0 = bc * ppb
|
||||
vals = [int(idx[y, x0 + p]) for p in range(ppb)]
|
||||
out[base + bc * 8] = _byte_for_pixels(vals, bits_per_pixel)
|
||||
return bytes(out)
|
||||
72
lenser/bbc/ssd.py
Normal file
72
lenser/bbc/ssd.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Write an Acorn DFS single-sided disk image (.ssd) natively.
|
||||
|
||||
DFS layout: sectors 0-1 are the catalogue; files follow from sector 2, each
|
||||
starting on a 256-byte sector boundary. Addresses are 18-bit (load/exec/length
|
||||
high bits packed into the per-file flag byte). Boot option (*OPT 4,n) lives in
|
||||
sector 1 byte 6 bits 4-5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SECTOR = 256
|
||||
TRACKS = 80
|
||||
SECTORS = TRACKS * 10 # 800 sectors = 200K, single sided
|
||||
|
||||
|
||||
class DfsError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _name7(name: str) -> bytes:
|
||||
n = "".join(c for c in name.upper() if 32 <= ord(c) < 127)[:7]
|
||||
return n.ljust(7).encode("ascii")
|
||||
|
||||
|
||||
def build_ssd(files, title="8BITLENSER", boot_option=0) -> bytes:
|
||||
"""files: list of (name, load_addr, exec_addr, data). boot_option 0-3
|
||||
(*OPT 4,n): 0 none, 1 *LOAD, 2 *RUN, 3 *EXEC of the first matching $.!BOOT."""
|
||||
if len(files) > 31:
|
||||
raise DfsError("DFS holds at most 31 files")
|
||||
|
||||
cat0 = bytearray(SECTOR)
|
||||
cat1 = bytearray(SECTOR)
|
||||
t = title.ljust(12)[:12].encode("ascii", "replace")
|
||||
cat0[0:8] = t[0:8]
|
||||
cat1[0:4] = t[8:12]
|
||||
cat1[4] = 0 # cycle number
|
||||
cat1[5] = len(files) * 8 # (#files) * 8
|
||||
cat1[6] = ((SECTORS >> 8) & 0x03) | ((boot_option & 0x03) << 4)
|
||||
cat1[7] = SECTORS & 0xFF
|
||||
|
||||
body = bytearray()
|
||||
start_sector = 2
|
||||
for i, (name, load, exec_, data) in enumerate(files):
|
||||
e = 8 + i * 8
|
||||
cat0[e:e + 7] = _name7(name)
|
||||
cat0[e + 7] = ord("$") # directory '$' (unlocked)
|
||||
length = len(data)
|
||||
cat1[e + 0] = load & 0xFF
|
||||
cat1[e + 1] = (load >> 8) & 0xFF
|
||||
cat1[e + 2] = exec_ & 0xFF
|
||||
cat1[e + 3] = (exec_ >> 8) & 0xFF
|
||||
cat1[e + 4] = length & 0xFF
|
||||
cat1[e + 5] = (length >> 8) & 0xFF
|
||||
cat1[e + 6] = (((exec_ >> 16) & 0x03) << 6 |
|
||||
((length >> 16) & 0x03) << 4 |
|
||||
((load >> 16) & 0x03) << 2 |
|
||||
((start_sector >> 8) & 0x03))
|
||||
cat1[e + 7] = start_sector & 0xFF
|
||||
nsec = (length + SECTOR - 1) // SECTOR
|
||||
body += bytes(data) + bytes((-length) % SECTOR)
|
||||
start_sector += nsec
|
||||
|
||||
img = bytes(cat0) + bytes(cat1) + bytes(body)
|
||||
if len(img) > SECTORS * SECTOR:
|
||||
raise DfsError("files exceed disk capacity")
|
||||
return img + bytes(SECTORS * SECTOR - len(img))
|
||||
|
||||
|
||||
def write_ssd(path: str, files, title="8BITLENSER", boot_option=0) -> str:
|
||||
with open(path, "wb") as f:
|
||||
f.write(build_ssd(files, title, boot_option))
|
||||
return path
|
||||
0
lenser/bbc/viewer/__init__.py
Normal file
0
lenser/bbc/viewer/__init__.py
Normal file
102
lenser/bbc/viewer/assemble.py
Normal file
102
lenser/bbc/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Assemble the BBC 6502 viewer with `xa` and patch in per-image parameters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
LOAD_ADDR = 0x1900 # DFS PAGE
|
||||
WAIT_MODES = {"forever": 0, "key": 1, "seconds": 2}
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def _xa(wrapper: str) -> bytes:
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
|
||||
"Install it with: sudo apt install xa65")
|
||||
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:
|
||||
return f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
|
||||
|
||||
SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
|
||||
|
||||
|
||||
def build_slideshow_viewer(bbc_mode: int, ncol: int, base: int, palettes,
|
||||
advance: str = "both", seconds: int = 10,
|
||||
loop: bool = True, video: str = "pal") -> bytes:
|
||||
"""Return the multi-image BBC loader (origin $1900).
|
||||
|
||||
``palettes`` is one physical-colour list per image (each truncated/padded to
|
||||
ncol) -- emitted as the ss_pal table the loader indexes by slide; the screen
|
||||
base hex is patched into the OSCLI *LOAD string.
|
||||
"""
|
||||
if not palettes:
|
||||
raise AssemblerError("a slideshow needs at least one image")
|
||||
rate = 60 if video == "ntsc" else 50
|
||||
flat = []
|
||||
for p in palettes:
|
||||
row = list(p[:ncol])
|
||||
flat += row + [0] * (ncol - len(row))
|
||||
table = ",".join(str(b & 0xFF) for b in flat)
|
||||
wrapper = (f"#define WAITMODE {SS_WAITMODE[advance]}\n"
|
||||
f"#define WAITSECS {max(0, int(seconds))}\n"
|
||||
f"#define RATE {rate}\n"
|
||||
f"#define NIMAGES {len(palettes)}\n"
|
||||
f"#define LOOPFLAG {1 if loop else 0}\n"
|
||||
f"#define MODE {bbc_mode}\n"
|
||||
f"#define NCOL {ncol}\n"
|
||||
'#include "slideshow.s"\n'
|
||||
"ss_pal:\n"
|
||||
f" .byte {table}\n")
|
||||
raw = bytearray(_xa(wrapper))
|
||||
off = raw.find(b"LOAD 00 ")
|
||||
if off < 0:
|
||||
raise AssemblerError("could not locate the OSCLI string to patch")
|
||||
raw[off + 8:off + 12] = f"{base:04X}".encode("ascii") # screen-base hex
|
||||
return bytes(raw)
|
||||
|
||||
|
||||
def build_viewer(bbc_mode: int, ncol: int, physicals, base: int,
|
||||
display: str = "forever", seconds: int = 0,
|
||||
video: str = "pal") -> bytes:
|
||||
"""Return the assembled loader (origin $1900) with the mode/palette/screen-base
|
||||
parameters patched in."""
|
||||
waitmode = WAIT_MODES.get(display, 0)
|
||||
rate = 60 if video == "ntsc" else 50 # bbcb is PAL (50 Hz)
|
||||
wrapper = (f"#define WAITMODE {waitmode}\n"
|
||||
f"#define WAITSECS {max(0, int(seconds))}\n"
|
||||
f"#define RATE {rate}\n"
|
||||
'#include "bbc.s"\n')
|
||||
raw = bytearray(_xa(wrapper))
|
||||
|
||||
off = raw.find(b"LOAD IMG ")
|
||||
if off < 0:
|
||||
raise AssemblerError("could not locate the OSCLI string to patch")
|
||||
raw[off + 9:off + 13] = f"{base:04X}".encode("ascii") # screen-base hex
|
||||
raw[off + 14] = bbc_mode & 0xFF # p_mode
|
||||
raw[off + 15] = ncol & 0xFF # p_ncol
|
||||
for i, p in enumerate(physicals[:8]): # p_pal
|
||||
raw[off + 16 + i] = p & 0xFF
|
||||
return bytes(raw)
|
||||
97
lenser/bbc/viewer/bbc.s
Normal file
97
lenser/bbc/viewer/bbc.s
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
; lenser -- BBC Micro viewer (6502), loaded at PAGE (&1900) and *RUN.
|
||||
;
|
||||
; Sets the screen MODE, programmes the logical->physical palette, then *LOADs the
|
||||
; image file "IMG" straight into screen memory, and holds it per the display
|
||||
; option. Parameters come from a fixed block the packager fills in (so the code
|
||||
; is constant); see build_viewer() in assemble.py.
|
||||
;
|
||||
; #defines from the wrapper -- WAITMODE (0 forever, 1 key, 2 seconds), WAITSECS, RATE.
|
||||
;
|
||||
; assembled by bbc/viewer/assemble.py via xa
|
||||
|
||||
OSWRCH = $FFEE
|
||||
OSRDCH = $FFE0
|
||||
OSBYTE = $FFF4
|
||||
OSCLI = $FFF7
|
||||
|
||||
* = $1900
|
||||
start:
|
||||
; ---- VDU 22, mode ----
|
||||
lda #22
|
||||
jsr OSWRCH
|
||||
lda p_mode
|
||||
jsr OSWRCH
|
||||
|
||||
; ---- palette via VDU 19, logical, physical, 0,0,0 for each logical ----
|
||||
ldx #0
|
||||
palloop:
|
||||
cpx p_ncol
|
||||
beq paldone
|
||||
lda #19
|
||||
jsr OSWRCH
|
||||
txa
|
||||
jsr OSWRCH ; logical colour = X
|
||||
lda p_pal,x
|
||||
jsr OSWRCH ; physical
|
||||
lda #0
|
||||
jsr OSWRCH
|
||||
jsr OSWRCH
|
||||
jsr OSWRCH
|
||||
inx
|
||||
bne palloop
|
||||
paldone:
|
||||
|
||||
; ---- hide the text cursor (VDU 23,1,0;0;0;0;) ----
|
||||
lda #23
|
||||
jsr OSWRCH
|
||||
lda #1
|
||||
jsr OSWRCH
|
||||
ldx #8
|
||||
curz:
|
||||
lda #0
|
||||
jsr OSWRCH
|
||||
dex
|
||||
bne curz
|
||||
|
||||
; ---- *LOAD the image straight into screen memory ----
|
||||
ldx #<cmd
|
||||
ldy #>cmd
|
||||
jsr OSCLI
|
||||
|
||||
; ---- hold the picture ----
|
||||
#if WAITMODE == 0
|
||||
hang:
|
||||
jmp hang
|
||||
#endif
|
||||
#if WAITMODE == 1
|
||||
jsr OSRDCH ; block until a key
|
||||
rts ; back to BASIC
|
||||
#endif
|
||||
#if WAITMODE == 2
|
||||
lda #<(WAITSECS*RATE)
|
||||
sta cnt
|
||||
lda #>(WAITSECS*RATE)
|
||||
sta cnt+1
|
||||
swait:
|
||||
lda #19
|
||||
jsr OSBYTE ; OSBYTE 19 = wait for vertical sync
|
||||
lda cnt
|
||||
bne sdec
|
||||
dec cnt+1
|
||||
sdec:
|
||||
dec cnt
|
||||
lda cnt
|
||||
ora cnt+1
|
||||
bne swait
|
||||
rts ; back to BASIC
|
||||
#endif
|
||||
|
||||
cnt: .byte 0,0
|
||||
|
||||
; OSCLI string -- the packager patches in the right screen-base hex
|
||||
cmd: .byte "LOAD IMG ", "0000", 13
|
||||
|
||||
; parameter block -- the packager fills these in
|
||||
p_mode: .byte 0
|
||||
p_ncol: .byte 0
|
||||
p_pal: .byte 0,0,0,0,0,0,0,0
|
||||
174
lenser/bbc/viewer/slideshow.s
Normal file
174
lenser/bbc/viewer/slideshow.s
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
; lenser -- BBC Micro slideshow loader (6502), loaded at PAGE (&1900) and *RUN.
|
||||
;
|
||||
; Sets the screen MODE once, then steps through NIMAGES pictures stored as DFS
|
||||
; files "00".."NN". For each slide it programmes that image's logical->physical
|
||||
; palette (from the ss_pal table), *LOADs the file straight into screen memory,
|
||||
; and waits (key / seconds / both) before advancing.
|
||||
;
|
||||
; #defines from the build wrapper --
|
||||
; WAITMODE 1 key / 2 seconds / 3 both WAITSECS timeout RATE 50/60 fps
|
||||
; NIMAGES image count LOOPFLAG 1 wrap / 0 stop MODE screen mode NCOL colours
|
||||
; The packager patches the screen-base hex into the OSCLI string and appends the
|
||||
; ss_pal table (NCOL physical-colour bytes per image).
|
||||
|
||||
OSWRCH = $FFEE
|
||||
OSRDCH = $FFE0
|
||||
OSBYTE = $FFF4
|
||||
OSCLI = $FFF7
|
||||
|
||||
palptr = $70
|
||||
cnt = $72
|
||||
|
||||
* = $1900
|
||||
start:
|
||||
; ---- VDU 22, mode (once) ----
|
||||
lda #22
|
||||
jsr OSWRCH
|
||||
lda #MODE
|
||||
jsr OSWRCH
|
||||
|
||||
; ---- hide the text cursor (VDU 23,1,0;0;0;0;) ----
|
||||
lda #23
|
||||
jsr OSWRCH
|
||||
lda #1
|
||||
jsr OSWRCH
|
||||
ldx #8
|
||||
curz:
|
||||
lda #0
|
||||
jsr OSWRCH
|
||||
dex
|
||||
bne curz
|
||||
|
||||
lda #0
|
||||
sta ssidx
|
||||
|
||||
ssmain:
|
||||
; ---- palptr = ss_pal + ssidx*NCOL ----
|
||||
lda #<ss_pal
|
||||
sta palptr
|
||||
lda #>ss_pal
|
||||
sta palptr+1
|
||||
ldx ssidx
|
||||
beq setpal
|
||||
pmul:
|
||||
clc
|
||||
lda palptr
|
||||
adc #NCOL
|
||||
sta palptr
|
||||
bcc pm1
|
||||
inc palptr+1
|
||||
pm1:
|
||||
dex
|
||||
bne pmul
|
||||
setpal:
|
||||
; ---- VDU 19, logical, physical, 0,0,0 for each logical colour ----
|
||||
ldx #0
|
||||
pl:
|
||||
cpx #NCOL
|
||||
beq pldone
|
||||
lda #19
|
||||
jsr OSWRCH
|
||||
txa
|
||||
jsr OSWRCH ; logical colour = X
|
||||
txa
|
||||
tay
|
||||
lda (palptr),y
|
||||
jsr OSWRCH ; physical colour
|
||||
lda #0
|
||||
jsr OSWRCH
|
||||
jsr OSWRCH
|
||||
jsr OSWRCH
|
||||
inx
|
||||
bne pl
|
||||
pldone:
|
||||
|
||||
; ---- build the filename digits "NN" into the OSCLI string ----
|
||||
lda ssidx
|
||||
ldx #$2f
|
||||
sec
|
||||
nten:
|
||||
inx
|
||||
sbc #10
|
||||
bcs nten
|
||||
adc #10
|
||||
ora #$30
|
||||
sta cmd+6 ; ones digit
|
||||
txa
|
||||
sta cmd+5 ; tens digit
|
||||
|
||||
; ---- *LOAD NN <base> straight into screen memory ----
|
||||
ldx #<cmd
|
||||
ldy #>cmd
|
||||
jsr OSCLI
|
||||
|
||||
jsr sswait
|
||||
|
||||
inc ssidx
|
||||
lda ssidx
|
||||
cmp #NIMAGES
|
||||
bcc ssmain
|
||||
#if LOOPFLAG == 1
|
||||
lda #0
|
||||
sta ssidx
|
||||
jmp ssmain
|
||||
#else
|
||||
rts ; back to BASIC
|
||||
#endif
|
||||
|
||||
; ---- wait (returns), flushing the keyboard buffer first ----
|
||||
sswait:
|
||||
lda #15
|
||||
ldx #1
|
||||
jsr OSBYTE ; flush input buffers
|
||||
#if WAITMODE == 1
|
||||
jsr OSRDCH ; block until a key
|
||||
rts
|
||||
#endif
|
||||
#if WAITMODE == 2
|
||||
lda #<(WAITSECS*RATE)
|
||||
sta cnt
|
||||
lda #>(WAITSECS*RATE)
|
||||
sta cnt+1
|
||||
sw2:
|
||||
lda #19
|
||||
jsr OSBYTE ; wait for vertical sync (1 frame)
|
||||
lda cnt
|
||||
bne sd2
|
||||
dec cnt+1
|
||||
sd2:
|
||||
dec cnt
|
||||
lda cnt
|
||||
ora cnt+1
|
||||
bne sw2
|
||||
rts
|
||||
#endif
|
||||
#if WAITMODE == 3
|
||||
lda #<(WAITSECS*RATE)
|
||||
sta cnt
|
||||
lda #>(WAITSECS*RATE)
|
||||
sta cnt+1
|
||||
sw3:
|
||||
lda #129
|
||||
ldx #0
|
||||
ldy #0
|
||||
jsr OSBYTE ; INKEY(0) -- poll keyboard, no wait
|
||||
cpy #$ff
|
||||
bne sw3d ; Y != $FF -> a key was pressed
|
||||
lda #19
|
||||
jsr OSBYTE ; wait one frame
|
||||
lda cnt
|
||||
bne sd3
|
||||
dec cnt+1
|
||||
sd3:
|
||||
dec cnt
|
||||
lda cnt
|
||||
ora cnt+1
|
||||
bne sw3
|
||||
sw3d:
|
||||
rts
|
||||
#endif
|
||||
|
||||
ssidx: .byte 0
|
||||
; OSCLI string -- packager patches the 4-hex screen base; loader patches "NN"
|
||||
cmd: .byte "LOAD 00 0000", 13
|
||||
; ss_pal table (NCOL bytes per image) appended by the build wrapper
|
||||
1
lenser/c128/__init__.py
Normal file
1
lenser/c128/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Commodore 128 target for lenser (VDC 8563 80-column display)."""
|
||||
21
lenser/c128/convert/__init__.py
Normal file
21
lenser/c128/convert/__init__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Commodore 128 conversion dispatch (VDC 80-column)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import imageprep
|
||||
from . import mono
|
||||
from . import color
|
||||
from . import hicolor
|
||||
|
||||
_MODULES = {"mono": mono, "color": color, "hicolor": hicolor}
|
||||
MODES = list(_MODULES.keys())
|
||||
|
||||
|
||||
def convert_image(path_or_img, mode="mono", palette_name="vdc",
|
||||
dither_mode="floyd", intensive=False, prep_opt=None,
|
||||
base_color=None):
|
||||
prep_opt = prep_opt or imageprep.PrepOptions()
|
||||
module = _MODULES.get(mode, mono)
|
||||
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)
|
||||
44
lenser/c128/convert/color.py
Normal file
44
lenser/c128/convert/color.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""C128 VDC chunky 80x100 16-colour mode.
|
||||
|
||||
MAME's 8563 draws the "bitmap" through the character/font path, so true
|
||||
per-pixel bitmap colour isn't possible -- but each 8x2 cell can carry its own
|
||||
colour via the attribute byte (fg = high nibble). Filling the character matrix
|
||||
with a solid glyph turns that into a per-cell SOLID colour image: a chunky
|
||||
80x100 picture using all 16 VDC colours. Lower resolution than `mono`'s
|
||||
640x200, but full colour (or smooth multi-level greyscale on the four greys).
|
||||
"""
|
||||
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 vdcpal
|
||||
|
||||
# 8x2 device-pixel cells over the 640x200 display = 80x100 colour cells. Each
|
||||
# cell is ~8*0.42 wide x 2 tall, so the 80x100 grid is ~4:3 -> aspect ~1.68.
|
||||
WIDTH, HEIGHT = 80, 100
|
||||
PIXEL_ASPECT = 1.68
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
prgb = vdcpal.get_palette().astype(np.uint8)
|
||||
plab = vdcpal.palette_lab()
|
||||
|
||||
lab = c64pal.srgb_to_lab(img_rgb)
|
||||
allowed = np.tile(np.arange(16), (HEIGHT, WIDTH, 1))
|
||||
idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
|
||||
# one attribute byte per cell; colour in the HIGH nibble (VDC fg = attr>>4),
|
||||
# low nibble (bg) left 0 -- the solid glyph means only fg shows.
|
||||
attr = ((idx.reshape(-1).astype(np.uint8) & 0x0F) << 4)
|
||||
data = bytes(attr.tolist()) # 80*100 = 8000
|
||||
|
||||
return Conversion(
|
||||
mode="color", width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=idx.astype(np.uint16), data=data, data_addr=0, viewer="c128",
|
||||
preview_rgb=prgb[idx], error=perceptual_error(idx, lab, plab),
|
||||
meta={"palette": "vdc", "dither": dither_mode, "fgbg": 0x0F,
|
||||
"vdc_mode": "color"},
|
||||
)
|
||||
142
lenser/c128/convert/hicolor.py
Normal file
142
lenser/c128/convert/hicolor.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"""C128 VDC high-resolution colour mode (640x200 via a custom character set).
|
||||
|
||||
MAME's 8563 (and real hardware) renders 80-column *character* mode with a custom
|
||||
font + per-cell attributes. That gives genuine per-pixel detail (8x8 glyph per
|
||||
cell) at 640x200, with one freely chosen INK colour per cell (attribute low
|
||||
nibble) over a single GLOBAL background (VDC register 26) -- a ZX-Spectrum-like
|
||||
colour model, but at double the Spectrum's horizontal resolution.
|
||||
|
||||
The picture is built by, for each 8x8 cell, choosing the ink colour that best
|
||||
represents it against the global background, dithering the cell to ink/background
|
||||
(a 1bpp glyph), then vector-quantising the ~2000 cell glyphs down to a 512-entry
|
||||
character set. The VDC addresses 256 glyphs per bank; a second bank is selected
|
||||
per cell by the attribute's ALTERNATE_CHARSET bit (bit 7), giving 512 glyphs.
|
||||
"""
|
||||
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 vdcpal
|
||||
|
||||
WIDTH, HEIGHT = 640, 200
|
||||
PIXEL_ASPECT = 0.42 # same fine pixels as the mono bitmap
|
||||
CW, CH = 8, 8 # 8x8 character cells
|
||||
COLS, ROWS = WIDTH // CW, HEIGHT // CH # 80 x 25 = 2000 cells
|
||||
BANK = 256 # glyphs per VDC charset bank
|
||||
NGLYPH = 512 # two banks (selected per cell by attr bit 7)
|
||||
|
||||
# VDC RAM layout (matches the C128 default so registers stay default).
|
||||
CODES_ADDR = 0x0000
|
||||
ATTR_ADDR = 0x0800
|
||||
CHAR_ADDR = 0x2000 # bank 0 at $2000, bank 1 at $3000 (+$1000)
|
||||
VDC_LEN = 0x4000 # full 16K, copied to VDC RAM by the viewer
|
||||
|
||||
|
||||
def _cell_view(a):
|
||||
"""(ROWS*CH, COLS*CW, ...) -> (ROWS, COLS, CH, CW, ...) cell blocks."""
|
||||
r = a.reshape(ROWS, CH, COLS, CW, *a.shape[2:])
|
||||
return r.transpose(0, 2, 1, 3, *range(4, a.ndim + 2))
|
||||
|
||||
|
||||
def _kmeans_binary(patterns, k, iters=10, seed=0):
|
||||
"""Cluster 0/1 patterns (N, D) into k binary centroids (Hamming / majority)."""
|
||||
n = len(patterns)
|
||||
rng = np.random.default_rng(seed)
|
||||
uniq = np.unique(patterns, axis=0)
|
||||
if len(uniq) <= k:
|
||||
cb = np.zeros((k, patterns.shape[1]), np.uint8)
|
||||
cb[:len(uniq)] = uniq
|
||||
d = (patterns[:, None, :] != cb[None, :, :]).sum(2)
|
||||
return cb, d.argmin(1)
|
||||
cb = uniq[rng.choice(len(uniq), k, replace=False)].astype(np.uint8)
|
||||
assign = np.zeros(n, np.int32)
|
||||
for _ in range(iters):
|
||||
# nearest centroid by Hamming distance, in chunks to bound memory
|
||||
for s in range(0, n, 4096):
|
||||
blk = patterns[s:s + 4096]
|
||||
d = (blk[:, None, :] != cb[None, :, :]).sum(2)
|
||||
assign[s:s + 4096] = d.argmin(1)
|
||||
for j in range(k):
|
||||
members = patterns[assign == j]
|
||||
if len(members):
|
||||
cb[j] = (members.mean(0) >= 0.5).astype(np.uint8)
|
||||
return cb, assign
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
"""Full 16-colour high-resolution mode."""
|
||||
return build(img_rgb, dither_mode, inks=list(range(16)),
|
||||
bg_list=list(range(16)), mode_name="hicolor")
|
||||
|
||||
|
||||
def build(img_rgb, dither_mode, inks, bg_list, mode_name):
|
||||
"""Shared 640x200 custom-charset encoder.
|
||||
|
||||
inks palette indices a cell's foreground may use (per-cell ink choice)
|
||||
bg_list palette indices to try as the single global background
|
||||
mode_name value for Conversion.mode (the VDC viewer is the same either way)
|
||||
"""
|
||||
prgb = vdcpal.get_palette().astype(np.uint8)
|
||||
plab = vdcpal.palette_lab()
|
||||
lab = c64pal.srgb_to_lab(img_rgb) # (H, W, 3)
|
||||
inks = np.asarray(inks)
|
||||
|
||||
# distance from every pixel to every palette colour
|
||||
dist = np.linalg.norm(lab[:, :, None, :] - plab[None, None, :, :], axis=3)
|
||||
dist_cells = _cell_view(dist) # (R,C,CH,CW,16)
|
||||
dist_inks = dist_cells[..., inks] # (R,C,CH,CW,len)
|
||||
|
||||
# choose the global background: the colour that, as the shared second tone,
|
||||
# lets each cell's best ink reproduce the image with least total error.
|
||||
best_bg, best_err, best_ink = bg_list[0], None, None
|
||||
for bg in bg_list:
|
||||
d_bg = dist_cells[..., bg:bg + 1] # (R,C,CH,CW,1)
|
||||
per_ink = np.minimum(d_bg, dist_inks).sum((2, 3)) # (R,C,len)
|
||||
sel = per_ink.argmin(2) # (R,C) index into inks
|
||||
err = np.take_along_axis(per_ink, sel[..., None], 2).sum()
|
||||
if best_err is None or err < best_err:
|
||||
best_bg, best_err, best_ink = bg, err, inks[sel]
|
||||
bg, ink_cell = best_bg, best_ink # ink_cell (R,C) palette
|
||||
|
||||
# dither each pixel between the global bg and its cell's ink
|
||||
ink_px = np.repeat(np.repeat(ink_cell, CH, 0), CW, 1) # (H,W)
|
||||
allowed = np.stack([np.full((HEIGHT, WIDTH), bg), ink_px], axis=2)
|
||||
idx = dither.quantize(lab, allowed, plab, dither_mode).astype(np.uint8)
|
||||
|
||||
# 1bpp glyph per cell (1 = ink), then vector-quantise to a 256-glyph charset
|
||||
bits = (idx == ink_px).astype(np.uint8) # (H,W)
|
||||
glyph_cells = _cell_view(bits).reshape(ROWS * COLS, CH * CW)
|
||||
codebook, assign = _kmeans_binary(glyph_cells, NGLYPH) # assign 0..NGLYPH-1
|
||||
code_map = assign.reshape(ROWS, COLS)
|
||||
|
||||
# rebuild the actually-displayed image from the quantised glyphs + colours
|
||||
glyphs_img = codebook[assign].reshape(ROWS, COLS, CH, CW)
|
||||
shown_ink = (glyphs_img == 1)
|
||||
final_idx = np.where(
|
||||
shown_ink, ink_cell[:, :, None, None], bg).astype(np.uint16)
|
||||
final_idx = final_idx.transpose(0, 2, 1, 3).reshape(HEIGHT, WIDTH)
|
||||
|
||||
# ---- assemble the VDC RAM image ----
|
||||
vdc = bytearray(VDC_LEN)
|
||||
glyph = code_map.reshape(-1)
|
||||
codes = (glyph & 0xFF).astype(np.uint8) # char code within bank
|
||||
# ink in the low nibble; bit 7 (ALTERNATE_CHARSET) selects bank 1
|
||||
attr = ((ink_cell.reshape(-1) & 0x0F) | ((glyph >> 8) << 7)).astype(np.uint8)
|
||||
vdc[CODES_ADDR:CODES_ADDR + codes.size] = codes.tobytes()
|
||||
vdc[ATTR_ADDR:ATTR_ADDR + attr.size] = attr.tobytes()
|
||||
for g in range(NGLYPH):
|
||||
rows = np.packbits(codebook[g].reshape(CH, CW), axis=1).reshape(-1)
|
||||
off = CHAR_ADDR + (g >> 8) * 0x1000 + ((g & 0xFF) << 4)
|
||||
vdc[off:off + CH] = rows.tobytes()
|
||||
|
||||
return Conversion(
|
||||
mode=mode_name, width=WIDTH, height=HEIGHT, pixel_aspect=PIXEL_ASPECT,
|
||||
index_image=final_idx, data=bytes(vdc), data_addr=0, viewer="c128",
|
||||
preview_rgb=prgb[final_idx],
|
||||
error=perceptual_error(final_idx, lab, plab),
|
||||
meta={"palette": "vdc", "dither": dither_mode, "fgbg": bg & 0x0F,
|
||||
"vdc_mode": "hicolor", "bg": bg},
|
||||
)
|
||||
29
lenser/c128/convert/mono.py
Normal file
29
lenser/c128/convert/mono.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""C128 VDC high-resolution greyscale / black-and-white (640x200).
|
||||
|
||||
MAME's 8563 has no true linear bitmap renderer (its R25-bit7 "bitmap" path only
|
||||
emits one bit per 8-pixel cell), so genuine 640x200 detail has to come through
|
||||
the character/font path -- exactly as the `hicolor` mode does. This mode reuses
|
||||
that machinery restricted to the VDC's four greys (black, dark grey, light grey,
|
||||
white): each 8x8 cell picks the grey that best matches it over a global grey
|
||||
background and dithers to a 1bpp glyph, giving smooth multi-level greyscale at
|
||||
full resolution. `--mono-base` swaps the grey ramp for a black->colour->white
|
||||
ramp to tint the result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import hicolor
|
||||
|
||||
WIDTH, HEIGHT = hicolor.WIDTH, hicolor.HEIGHT
|
||||
PIXEL_ASPECT = hicolor.PIXEL_ASPECT
|
||||
|
||||
GREYS = [0, 1, 14, 15] # VDC black, dark grey, light grey, white
|
||||
|
||||
|
||||
def convert(img_rgb, palette_name="vdc", dither_mode="floyd",
|
||||
intensive=False, base_color=None):
|
||||
if base_color in range(1, 16):
|
||||
inks = sorted({0, int(base_color), 15}) # black -> tint -> white
|
||||
else:
|
||||
inks = GREYS
|
||||
return hicolor.build(img_rgb, dither_mode, inks=inks, bg_list=inks,
|
||||
mode_name="mono")
|
||||
23
lenser/c128/exporter.py
Normal file
23
lenser/c128/exporter.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"""Build a C128 autobooting .d64 (the VDC viewer PRG, loaded with RUN"PIC")."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from .. import diskimage
|
||||
from .viewer import assemble
|
||||
|
||||
|
||||
def export_d64(conv, output_path, source_path=None, display="forever",
|
||||
seconds=0, video="ntsc"):
|
||||
if not output_path.lower().endswith(".d64"):
|
||||
output_path += ".d64"
|
||||
# "color" = 80x100 chunky solid cells; "hicolor" (also used by mono) =
|
||||
# 640x200 custom-charset font mode.
|
||||
if conv.meta.get("vdc_mode") == "color":
|
||||
prg = assemble.build_prg_color(bytes(conv.data), conv.meta.get("fgbg", 0x0F))
|
||||
else:
|
||||
prg = assemble.build_prg_hicolor(bytes(conv.data), conv.meta.get("fgbg", 0x00))
|
||||
name = os.path.splitext(os.path.basename(source_path or output_path))[0]
|
||||
diskimage.build_disk(output_path, "d64", name[:16] or "8bitlenser", "cv",
|
||||
[("pic", prg)])
|
||||
return output_path
|
||||
38
lenser/c128/palette.py
Normal file
38
lenser/c128/palette.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Commodore 128 VDC (8563) 16-colour RGBI palette.
|
||||
|
||||
The VDC outputs digital RGBI, giving a CGA-like 16-colour set (quite different
|
||||
from the VIC-II's colours). Index 0 = black, 15 = white -- the only two the mono
|
||||
mode needs; the rest are provided for tinted-mono and future colour modes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..palette import srgb_to_lab
|
||||
|
||||
VDC = np.array([
|
||||
(0x00, 0x00, 0x00), # 0 black
|
||||
(0x55, 0x55, 0x55), # 1 dark grey
|
||||
(0x00, 0x00, 0xAA), # 2 blue
|
||||
(0x55, 0x55, 0xFF), # 3 light blue
|
||||
(0x00, 0xAA, 0x00), # 4 green
|
||||
(0x55, 0xFF, 0x55), # 5 light green
|
||||
(0x00, 0xAA, 0xAA), # 6 cyan
|
||||
(0x55, 0xFF, 0xFF), # 7 light cyan
|
||||
(0xAA, 0x00, 0x00), # 8 red
|
||||
(0xFF, 0x55, 0x55), # 9 light red
|
||||
(0xAA, 0x00, 0xAA), # 10 purple
|
||||
(0xFF, 0x55, 0xFF), # 11 light purple
|
||||
(0xAA, 0x55, 0x00), # 12 brown
|
||||
(0xFF, 0xFF, 0x55), # 13 light yellow
|
||||
(0xAA, 0xAA, 0xAA), # 14 light grey
|
||||
(0xFF, 0xFF, 0xFF), # 15 white
|
||||
], dtype=np.float64)
|
||||
|
||||
|
||||
def get_palette() -> np.ndarray:
|
||||
return VDC
|
||||
|
||||
|
||||
def palette_lab() -> np.ndarray:
|
||||
return srgb_to_lab(VDC)
|
||||
1
lenser/c128/viewer/__init__.py
Normal file
1
lenser/c128/viewer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""C128 8502/VDC viewer (assembled by xa)."""
|
||||
122
lenser/c128/viewer/assemble.py
Normal file
122
lenser/c128/viewer/assemble.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Assemble the C128 VDC viewer with `xa` and build the loadable PRG.
|
||||
|
||||
The PRG loads at the C128 BASIC start ($1C01): a tiny BASIC stub (`10 SYS7200`)
|
||||
followed by the 8502 viewer (at $1C20) and, from $2000, the 640x200 bitmap.
|
||||
Running it (RUN"PIC") executes the stub, which SYSes the viewer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
VIEWER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
BASIC_START = 0x1C01
|
||||
ML_ORG = 0x1C20
|
||||
DATA_ORG = 0x2000 # bitmap goes here (viewer copies it to the VDC)
|
||||
|
||||
# BASIC: 10 SYS7200 ($1C20 = 7200) -- bytes as they sit from $1C01
|
||||
_STUB = bytes([0x0B, 0x1C, 0x0A, 0x00, 0x9E,
|
||||
0x37, 0x32, 0x30, 0x30, 0x00, 0x00, 0x00])
|
||||
|
||||
|
||||
class AssemblerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def have_xa() -> bool:
|
||||
return shutil.which("xa") is not None
|
||||
|
||||
|
||||
def _xa(wrapper: str) -> bytes:
|
||||
"""Assemble a generated wrapper (xa runs in VIEWER_DIR so #includes resolve)."""
|
||||
if not have_xa():
|
||||
raise AssemblerError("The 'xa' (xa65) assembler was not found on PATH.\n"
|
||||
"Install it with: sudo apt install xa65")
|
||||
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:
|
||||
return f.read()
|
||||
finally:
|
||||
os.unlink(wrap)
|
||||
|
||||
|
||||
def _assemble(fgbg: int, source: str) -> bytes:
|
||||
return _xa(f"#define SRC ${DATA_ORG:04X}\n"
|
||||
f"#define FGBG ${fgbg & 0xFF:02X}\n"
|
||||
f'#include "{source}"\n')
|
||||
|
||||
|
||||
def _wrap_prg(code: bytes, data: bytes) -> bytes:
|
||||
"""Lay out load-address prefix + BASIC stub + viewer code + data @ $2000."""
|
||||
mem = bytearray()
|
||||
mem += _STUB # $1C01..
|
||||
mem += b"\x00" * (ML_ORG - BASIC_START - len(_STUB))
|
||||
mem += code # $1C20..
|
||||
if len(mem) > DATA_ORG - BASIC_START:
|
||||
raise AssemblerError("viewer code overruns the $2000 data area")
|
||||
mem += b"\x00" * (DATA_ORG - BASIC_START - len(mem))
|
||||
mem += data # $2000..
|
||||
return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem)
|
||||
|
||||
|
||||
def build_prg_color(attributes: bytes, fgbg: int = 0x0F) -> bytes:
|
||||
"""Return the loadable PRG for the 80x100 chunky-colour viewer.
|
||||
|
||||
`attributes` is the 8000-byte per-cell colour map (colour in the high
|
||||
nibble); the viewer fills the character matrix with a solid glyph itself.
|
||||
"""
|
||||
return _wrap_prg(_assemble(fgbg, "color.s"), attributes)
|
||||
|
||||
|
||||
def build_prg_hicolor(vdc_image: bytes, fgbg: int = 0x00) -> bytes:
|
||||
"""Return the loadable PRG for the 640x200 custom-charset viewer (font mode).
|
||||
|
||||
Used by both the `hicolor` and `mono` modes. `vdc_image` is the full VDC RAM
|
||||
image (character codes, attributes and the custom character set already laid
|
||||
out); `fgbg` carries the global background in its low nibble. The viewer
|
||||
copies the image verbatim into VDC RAM.
|
||||
"""
|
||||
return _wrap_prg(_assemble(fgbg, "hicolor.s"), vdc_image)
|
||||
|
||||
|
||||
_SS_WAITMODE = {"key": 1, "seconds": 2, "both": 3}
|
||||
|
||||
|
||||
def build_slideshow_prg(fgbg_list, advance: str = "both", seconds: int = 10,
|
||||
loop: bool = True, video: str = "pal") -> bytes:
|
||||
"""Return the bootable C128 slideshow viewer PRG (RUN\"PIC\").
|
||||
|
||||
``fgbg_list`` is one VDC R26 background byte per image (conv.meta["fgbg"]);
|
||||
the per-image pictures are separate "00".."NN" files the viewer loads. The
|
||||
viewer is code-only (no appended image) -- just the BASIC stub + the 8502
|
||||
loop + the ss_fgbg table.
|
||||
"""
|
||||
if not fgbg_list:
|
||||
raise AssemblerError("a slideshow needs at least one image")
|
||||
jiffyps = 60 if video == "ntsc" else 50
|
||||
table = ",".join(str(int(b) & 0xFF) for b in fgbg_list)
|
||||
code = _xa(
|
||||
f"#define WAITMODE {_SS_WAITMODE[advance]}\n"
|
||||
f"#define WAITSECS {max(0, int(seconds))}\n"
|
||||
f"#define JIFFYPS {jiffyps}\n"
|
||||
f"#define NIMAGES {len(fgbg_list)}\n"
|
||||
f"#define LOOPFLAG {1 if loop else 0}\n"
|
||||
'#include "slideshow.s"\n'
|
||||
"ss_fgbg:\n"
|
||||
f" .byte {table}\n")
|
||||
mem = bytearray()
|
||||
mem += _STUB # $1C01 BASIC stub (10 SYS7200)
|
||||
mem += b"\x00" * (ML_ORG - BASIC_START - len(_STUB))
|
||||
mem += code # $1C20 viewer + ss_fgbg table
|
||||
return bytes([BASIC_START & 0xFF, BASIC_START >> 8]) + bytes(mem)
|
||||
173
lenser/c128/viewer/color.s
Normal file
173
lenser/c128/viewer/color.s
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
; Commodore 128 VDC (8563) 80x100 chunky-colour image viewer.
|
||||
;
|
||||
; MAME's 8563 renders the bitmap through the character/font path- each display
|
||||
; byte is a CHARACTER CODE whose glyph is drawn with fg = attribute high nibble,
|
||||
; bg = attribute low nibble. So a per-cell SOLID colour image is produced by
|
||||
; filling the whole character matrix with $FF (a solid glyph) and giving every
|
||||
; cell its colour in the high nibble of its attribute byte.
|
||||
;
|
||||
; R9=1 (2 scan lines per char row) makes 80x100 cells- 8000 char bytes + 8000
|
||||
; attribute bytes = 16000, which fits the stock 16K VDC RAM.
|
||||
;
|
||||
; #defines from viewer/assemble.py --
|
||||
; SRC main-RAM address of the 8000 attribute bytes ($2000)
|
||||
; FGBG VDC register 26 default value (unused cells / background)
|
||||
|
||||
* = $1C20
|
||||
|
||||
src = $fb
|
||||
adlo = $fd
|
||||
adhi = $fe
|
||||
cntl = $02
|
||||
cnth = $03
|
||||
fill = $04
|
||||
|
||||
start:
|
||||
sei
|
||||
lda #$0e
|
||||
sta $ff00 ; bank in RAM $4000-$BFFF, keep I/O for the VDC
|
||||
lda #<nmi
|
||||
sta $0318
|
||||
lda #>nmi
|
||||
sta $0319 ; neutralise NMI (not masked by SEI)
|
||||
|
||||
jsr setregs
|
||||
|
||||
; fill the character matrix VDC $0000 with $FF (solid glyph), 8000 bytes
|
||||
lda #$00
|
||||
sta adlo
|
||||
sta adhi
|
||||
lda #$ff
|
||||
sta fill
|
||||
lda #<8000
|
||||
sta cntl
|
||||
lda #>8000
|
||||
sta cnth
|
||||
jsr fillblk
|
||||
|
||||
; copy 8000 attribute bytes main SRC -> VDC $2000
|
||||
lda #<SRC
|
||||
sta src
|
||||
lda #>SRC
|
||||
sta src+1
|
||||
lda #$00
|
||||
sta adlo
|
||||
lda #$20
|
||||
sta adhi
|
||||
lda #<8000
|
||||
sta cntl
|
||||
lda #>8000
|
||||
sta cnth
|
||||
jsr copyblk
|
||||
|
||||
; enable bitmap + attributes (display the picture)
|
||||
ldx #25
|
||||
lda #$c0
|
||||
jsr vdcw
|
||||
|
||||
loop:
|
||||
jmp loop
|
||||
|
||||
nmi:
|
||||
rti
|
||||
|
||||
; program the VDC display geometry + attribute base (everything except R25)
|
||||
setregs:
|
||||
ldx #9
|
||||
lda #$01
|
||||
jsr vdcw ; R9 = 1 -> 2 scan lines per char row (80x100)
|
||||
ldx #4
|
||||
lda #131
|
||||
jsr vdcw ; R4 vertical total -> (131+1)*2 = 264 lines
|
||||
ldx #5
|
||||
lda #$00
|
||||
jsr vdcw ; R5 vertical total adjust
|
||||
ldx #6
|
||||
lda #100
|
||||
jsr vdcw ; R6 vertical displayed = 100 rows (*2 = 200)
|
||||
ldx #7
|
||||
lda #116
|
||||
jsr vdcw ; R7 vsync position
|
||||
ldx #20
|
||||
lda #$20
|
||||
jsr vdcw ; R20 attribute base high = $2000
|
||||
ldx #21
|
||||
lda #$00
|
||||
jsr vdcw ; R21 attribute base low
|
||||
ldx #26
|
||||
lda #FGBG
|
||||
jsr vdcw ; R26 default fg/bg
|
||||
ldx #10
|
||||
lda #$20
|
||||
jsr vdcw ; R10 cursor off (bit5) - hide the blinking cursor
|
||||
ldx #12
|
||||
lda #$00
|
||||
jsr vdcw ; R12 display start high
|
||||
ldx #13
|
||||
lda #$00
|
||||
jsr vdcw ; R13 display start low
|
||||
rts
|
||||
|
||||
; fill cnt bytes = (fill) into VDC RAM from adhi/adlo (explicit address per byte)
|
||||
fillblk:
|
||||
ldx #18
|
||||
lda adhi
|
||||
jsr vdcw
|
||||
ldx #19
|
||||
lda adlo
|
||||
jsr vdcw
|
||||
ldx #31
|
||||
lda fill
|
||||
jsr vdcw
|
||||
inc adlo
|
||||
bne fb1
|
||||
inc adhi
|
||||
fb1:
|
||||
lda cntl
|
||||
bne fb2
|
||||
dec cnth
|
||||
fb2:
|
||||
dec cntl
|
||||
lda cntl
|
||||
ora cnth
|
||||
bne fillblk
|
||||
rts
|
||||
|
||||
; copy cnt bytes from (src) into VDC RAM from adhi/adlo (explicit address per byte)
|
||||
copyblk:
|
||||
ldx #18
|
||||
lda adhi
|
||||
jsr vdcw
|
||||
ldx #19
|
||||
lda adlo
|
||||
jsr vdcw
|
||||
ldx #31
|
||||
ldy #$00
|
||||
lda (src),y
|
||||
jsr vdcw
|
||||
inc src
|
||||
bne cb1
|
||||
inc src+1
|
||||
cb1:
|
||||
inc adlo
|
||||
bne cb2
|
||||
inc adhi
|
||||
cb2:
|
||||
lda cntl
|
||||
bne cb3
|
||||
dec cnth
|
||||
cb3:
|
||||
dec cntl
|
||||
lda cntl
|
||||
ora cnth
|
||||
bne copyblk
|
||||
rts
|
||||
|
||||
; write A to VDC register X
|
||||
vdcw:
|
||||
stx $d600
|
||||
vw:
|
||||
bit $d600
|
||||
bpl vw
|
||||
sta $d601
|
||||
rts
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue