First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
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)}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue