First public commit.
This commit is contained in:
parent
2a48f52979
commit
4bac9d83ed
288 changed files with 18417 additions and 1076 deletions
|
|
@ -14,9 +14,9 @@ import numpy as np
|
|||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from c64view import imageprep, palette as pal # noqa: E402
|
||||
from c64view.convert import fli, hires, ifli, multicolor # noqa: E402
|
||||
from c64view.viewer.assemble import SOURCES, build_viewer_prg, have_xa # noqa: E402
|
||||
from lenser import imageprep, palette as pal # noqa: E402
|
||||
from lenser.convert import fli, hires, ifli, multicolor # noqa: E402
|
||||
from lenser.viewer.assemble import SOURCES, build_viewer_prg, have_xa # noqa: E402
|
||||
|
||||
|
||||
def _gradient(w, h):
|
||||
|
|
@ -94,12 +94,449 @@ def test_interlace_blend_better():
|
|||
assert len(ifli.convert(img).data) == 25577
|
||||
|
||||
|
||||
def test_vic20_multicolor_roundtrip():
|
||||
from lenser.vic20.convert import multicolor as vmc
|
||||
img = imageprep.prepare(_imgobj(vmc.WIDTH, vmc.HEIGHT), vmc.WIDTH, vmc.HEIGHT,
|
||||
vmc.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
c = vmc.convert(img)
|
||||
d = c.data
|
||||
chardata, screen, color = d["chardata"], d["screen"], d["color"]
|
||||
bg, border, aux = d["bg"], d["border"], d["aux"]
|
||||
dec = np.zeros((vmc.HEIGHT, vmc.WIDTH), np.uint8)
|
||||
for cr in range(vmc.N_ROWS):
|
||||
for cc in range(vmc.N_COLS):
|
||||
ci = cr * vmc.N_COLS + cc
|
||||
f = color[ci] & 0x07
|
||||
lut = [bg, border, f, aux]
|
||||
ch = int(screen[ci])
|
||||
for r in range(8):
|
||||
byte = chardata[ch * 8 + r]
|
||||
for x in range(4):
|
||||
code = (byte >> (6 - 2 * x)) & 3
|
||||
dec[cr * 8 + r, cc * 4 + x] = lut[code]
|
||||
assert np.array_equal(dec, c.index_image)
|
||||
# colour RAM must flag multicolour (bit 3) and keep fg within 0-7
|
||||
assert np.all((color & 0x08) == 0x08)
|
||||
assert np.all((color & 0x07) <= 7)
|
||||
|
||||
|
||||
def test_vic20_hires_roundtrip():
|
||||
from lenser.vic20.convert import hires as vhi
|
||||
img = imageprep.prepare(_imgobj(vhi.WIDTH, vhi.HEIGHT), vhi.WIDTH, vhi.HEIGHT,
|
||||
vhi.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
c = vhi.convert(img)
|
||||
d = c.data
|
||||
chardata, screen, color = d["chardata"], d["screen"], d["color"]
|
||||
bg = d["bg"]
|
||||
dec = np.zeros((vhi.HEIGHT, vhi.WIDTH), np.uint8)
|
||||
for cr in range(vhi.N_ROWS):
|
||||
for cc in range(vhi.N_COLS):
|
||||
ci = cr * vhi.N_COLS + cc
|
||||
f = color[ci] & 0x07
|
||||
ch = int(screen[ci])
|
||||
for r in range(8):
|
||||
byte = chardata[ch * 8 + r]
|
||||
for x in range(8):
|
||||
dec[cr * 8 + r, cc * 8 + x] = f if (byte >> (7 - x)) & 1 else bg
|
||||
assert np.array_equal(dec, c.index_image)
|
||||
|
||||
|
||||
def test_spectrum_roundtrip():
|
||||
from lenser.spectrum.convert import hires as zh
|
||||
from lenser.spectrum import palette as zpal
|
||||
img = imageprep.prepare(_imgobj(zh.WIDTH, zh.HEIGHT), zh.WIDTH, zh.HEIGHT,
|
||||
zh.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
c = zh.convert(img)
|
||||
scr = c.data
|
||||
assert len(scr) == 6912
|
||||
dec = np.zeros((zh.HEIGHT, zh.WIDTH), np.uint8)
|
||||
for cy in range(zh.N_ROWS):
|
||||
for cx in range(zh.N_COLS):
|
||||
attr = scr[6144 + cy * 32 + cx]
|
||||
bright = (attr >> 6) & 1
|
||||
ink = (bright << 3) | (attr & 7)
|
||||
paper = (bright << 3) | ((attr >> 3) & 7)
|
||||
for r in range(8):
|
||||
y = cy * 8 + r
|
||||
byte = scr[zh._bitmap_offset(y, cx)]
|
||||
for px in range(8):
|
||||
bit = (byte >> (7 - px)) & 1
|
||||
dec[y, cx * 8 + px] = ink if bit else paper
|
||||
assert np.array_equal(dec, c.index_image)
|
||||
# every cell's two colours must share the BRIGHT bit (no normal/bright mix)
|
||||
for cy in range(zh.N_ROWS):
|
||||
for cx in range(zh.N_COLS):
|
||||
attr = scr[6144 + cy * 32 + cx]
|
||||
assert (attr & 0x80) == 0 # FLASH never set
|
||||
|
||||
|
||||
def test_mono_modes_available_and_convert():
|
||||
"""EVERY platform offers a monochrome mode that converts to a luminance-
|
||||
matched image (indices restricted to its grey ramp)."""
|
||||
from lenser import platforms
|
||||
for p in platforms.PLATFORMS:
|
||||
assert "mono" in platforms.modes(p), f"{p} missing mono mode"
|
||||
# the mono mode actually converts (greyscale) on every platform
|
||||
prep0 = imageprep.PrepOptions()
|
||||
src = _imgobj(320, 200)
|
||||
for p in platforms.PLATFORMS:
|
||||
c = platforms.convert(p, src, "mono", platforms.palettes(p)[0],
|
||||
"floyd", False, prep0, "grayscale")
|
||||
assert c.mode == "mono", f"{p} mono did not report mode 'mono'"
|
||||
prep = imageprep.PrepOptions()
|
||||
img = _imgobj(256, 192)
|
||||
# ti99 + spectrum mono decode exactly like their gm2 / hires colour formats
|
||||
from lenser.ti99.convert import convert_image as ti
|
||||
c = ti(img, mode="mono", dither_mode="atkinson")
|
||||
assert len(c.data) == 6912 and c.mode == "mono"
|
||||
from lenser.spectrum.convert import convert_image as zx
|
||||
c = zx(img, mode="mono", dither_mode="atkinson")
|
||||
assert len(c.data) == 6912
|
||||
# dictionary platforms: mono produces the same data dict their viewer expects
|
||||
from lenser.vic20.convert import convert_image as v
|
||||
d = v(img, mode="mono", dither_mode="floyd").data
|
||||
assert d["bg"] == 0 and len(d["chardata"]) == 2048 and d["color"].max() <= 7
|
||||
from lenser.intv.convert import convert_image as iv
|
||||
d = iv(img, mode="mono", dither_mode="none").data
|
||||
assert len(d["gram"]) == 64 * 8 and len(d["cards"]) == 240
|
||||
|
||||
|
||||
def test_a5200_cart():
|
||||
"""Atari 5200 reuses the Atari GTIA encoders and packs a 32K cartridge whose
|
||||
display list + bitmap ANTIC reads straight from ROM."""
|
||||
from lenser.a5200.convert import convert_image
|
||||
from lenser.a5200.viewer.assemble import build_cart, have_xa, BITMAP_ADDR
|
||||
img = _imgobj(160, 192)
|
||||
for mode in ("gr15", "gr8", "gr9"):
|
||||
c = convert_image(img, mode=mode, dither_mode="floyd")
|
||||
assert c.mode == mode
|
||||
if not have_xa():
|
||||
continue
|
||||
# all three display durations assemble + pack to 32K
|
||||
for disp, secs in (("forever", 0), ("key", 0), ("seconds", 10)):
|
||||
rom = build_cart(c.mode, bytes(c.data), display=disp, seconds=secs)
|
||||
assert len(rom) == 0x8000 # 32K
|
||||
# start vector at $BFFE-F points into the cart
|
||||
start = rom[0xBFFE - 0x4000] | (rom[0xBFFF - 0x4000] << 8)
|
||||
assert start == 0x4000
|
||||
# bitmap landed at $6000 (so the 4K split maps to $6000/$7000)
|
||||
assert rom[BITMAP_ADDR - 0x4000:BITMAP_ADDR - 0x4000 + 8] == c.data[:8]
|
||||
|
||||
|
||||
def test_a7800_cart():
|
||||
"""Atari 7800 (MARIA) packs a 48K .a78 with the bitmap + display lists + DLL
|
||||
that MARIA DMAs from ROM."""
|
||||
from lenser.a7800.convert import convert_image
|
||||
from lenser.a7800.viewer.assemble import (build_cart, have_xa, BITMAP_ADDR,
|
||||
DLL_ADDR, LINES, N_SEG)
|
||||
img = _imgobj(160, 192)
|
||||
for mode in ("c160", "mono"):
|
||||
c = convert_image(img, mode=mode, dither_mode="floyd")
|
||||
assert c.mode == mode
|
||||
# data = bitmap(7680) + seg_palettes(192*4) + colours(25)
|
||||
assert len(c.data) == 7680 + LINES * N_SEG + 25
|
||||
assert all(p < 8 for p in c.data[7680:7680 + LINES * N_SEG]) # palette 0-7
|
||||
if not have_xa():
|
||||
continue
|
||||
rom = build_cart(bytes(c.data), title="t")
|
||||
assert len(rom) == 128 + 0xC000 # header + 48K
|
||||
assert rom[1:1 + 9] == b"ATARI7800" # .a78 signature
|
||||
body = rom[128:]
|
||||
# reset vector points at the viewer entry ($4000)
|
||||
assert body[0xFFFC - 0x4000] | (body[0xFFFD - 0x4000] << 8) == 0x4000
|
||||
# bitmap landed at $8000
|
||||
assert body[BITMAP_ADDR - 0x4000:BITMAP_ADDR - 0x4000 + 8] == c.data[:8]
|
||||
|
||||
|
||||
def test_spectrum_sna():
|
||||
from lenser.spectrum.convert import hires as zh
|
||||
from lenser.spectrum import snapshot
|
||||
img = imageprep.prepare(_imgobj(zh.WIDTH, zh.HEIGHT), zh.WIDTH, zh.HEIGHT,
|
||||
zh.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
sna = snapshot.build_sna(zh.convert(img).data, border=0)
|
||||
assert len(sna) == 27 + 49152 # header + 48K RAM
|
||||
ram = sna[27:]
|
||||
assert ram[0x8000 - 0x4000:0x8000 - 0x4000 + 3] == bytes([0xF3, 0x18, 0xFE])
|
||||
# SP (header offset 0x17) points at the stub return address
|
||||
sp = sna[0x17] | (sna[0x18] << 8)
|
||||
lo = ram[sp - 0x4000]; hi = ram[sp - 0x4000 + 1]
|
||||
assert (lo | (hi << 8)) == 0x8000
|
||||
|
||||
|
||||
def test_vic20_cart_builds():
|
||||
from lenser.vic20.viewer.assemble import build_cart, have_xa
|
||||
if not have_xa():
|
||||
return # xa not installed; skip
|
||||
from lenser.vic20.convert import multicolor as vmc
|
||||
img = imageprep.prepare(_imgobj(vmc.WIDTH, vmc.HEIGHT), vmc.WIDTH, vmc.HEIGHT,
|
||||
vmc.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
rom = build_cart(vmc.convert(img).data)
|
||||
assert len(rom) == 0x2000 # full 8K cart
|
||||
assert rom[4:9] == bytes([0x41, 0x30, 0xC3, 0xC2, 0xCD]) # "A0CBM" signature
|
||||
|
||||
|
||||
def test_c128_mono_prg():
|
||||
# mono is high-res greyscale via the custom-charset (font) path, like hicolor
|
||||
from lenser.c128.convert import mono as c128mono, hicolor as hc
|
||||
from lenser.c128.viewer.assemble import (build_prg_hicolor, have_xa,
|
||||
BASIC_START, DATA_ORG, _STUB)
|
||||
img = imageprep.prepare(_imgobj(c128mono.WIDTH, c128mono.HEIGHT),
|
||||
c128mono.WIDTH, c128mono.HEIGHT,
|
||||
c128mono.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
conv = c128mono.convert(img)
|
||||
assert conv.mode == "mono"
|
||||
assert len(conv.data) == hc.VDC_LEN # full VDC RAM image
|
||||
assert conv.meta["vdc_mode"] == "hicolor" # uses the font-mode viewer
|
||||
# mono only uses the four greys, so every cell's ink is one of them
|
||||
greys = set(c128mono.GREYS)
|
||||
attrs = conv.data[hc.ATTR_ADDR:hc.ATTR_ADDR + hc.ROWS * hc.COLS]
|
||||
assert all((b & 0x0F) in greys for b in attrs)
|
||||
if not have_xa():
|
||||
return # xa not installed; skip the assembly half
|
||||
prg = build_prg_hicolor(bytes(conv.data), conv.meta["fgbg"])
|
||||
assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) # load $1C01
|
||||
assert prg[2:2 + len(_STUB)] == _STUB # BASIC 10 SYS7200 stub
|
||||
assert bytes(prg[-hc.VDC_LEN:]) == bytes(conv.data)
|
||||
assert len(prg) == 2 + (DATA_ORG - BASIC_START) + hc.VDC_LEN
|
||||
|
||||
|
||||
def test_c128_color_prg():
|
||||
from lenser.c128.convert import color as c128color
|
||||
from lenser.c128.viewer.assemble import (build_prg_color, have_xa,
|
||||
BASIC_START, DATA_ORG)
|
||||
img = imageprep.prepare(_imgobj(c128color.WIDTH, c128color.HEIGHT),
|
||||
c128color.WIDTH, c128color.HEIGHT,
|
||||
c128color.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
conv = c128color.convert(img)
|
||||
assert len(conv.data) == 8000 # 80x100 attribute bytes
|
||||
assert conv.meta["vdc_mode"] == "color"
|
||||
# colour lives in the high nibble; low nibble (bg) is 0
|
||||
assert all((b & 0x0F) == 0 for b in conv.data)
|
||||
if not have_xa():
|
||||
return
|
||||
prg = build_prg_color(bytes(conv.data), conv.meta["fgbg"])
|
||||
assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8])
|
||||
assert bytes(prg[-8000:]) == bytes(conv.data) # attributes land at $2000
|
||||
assert len(prg) == 2 + (DATA_ORG - BASIC_START) + 8000
|
||||
|
||||
|
||||
def test_c128_hicolor_prg():
|
||||
from lenser.c128.convert import hicolor as hc
|
||||
from lenser.c128.viewer.assemble import (build_prg_hicolor, have_xa,
|
||||
BASIC_START, DATA_ORG)
|
||||
img = imageprep.prepare(_imgobj(hc.WIDTH, hc.HEIGHT), hc.WIDTH, hc.HEIGHT,
|
||||
hc.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
conv = hc.convert(img)
|
||||
assert len(conv.data) == hc.VDC_LEN # full 16K VDC RAM image
|
||||
assert conv.meta["vdc_mode"] == "hicolor"
|
||||
# ink in the low nibble, bit 7 may select bank 1; blink/underline/reverse off
|
||||
attrs = conv.data[hc.ATTR_ADDR:hc.ATTR_ADDR + hc.ROWS * hc.COLS]
|
||||
assert all((b & 0x70) == 0 for b in attrs)
|
||||
if not have_xa():
|
||||
return
|
||||
prg = build_prg_hicolor(bytes(conv.data), conv.meta["fgbg"])
|
||||
assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8])
|
||||
assert bytes(prg[-hc.VDC_LEN:]) == bytes(conv.data)
|
||||
assert len(prg) == 2 + (DATA_ORG - BASIC_START) + hc.VDC_LEN
|
||||
|
||||
|
||||
def test_c16_hires_prg():
|
||||
from lenser.c16.convert import hires as c16h
|
||||
from lenser.c16.viewer.assemble import (build_prg, have_xa, BASIC_START,
|
||||
BITMAP_ORG, _STUB)
|
||||
img = imageprep.prepare(_imgobj(c16h.WIDTH, c16h.HEIGHT), c16h.WIDTH,
|
||||
c16h.HEIGHT, c16h.PIXEL_ASPECT, imageprep.PrepOptions())
|
||||
conv = c16h.convert(img)
|
||||
assert conv.mode == "hires"
|
||||
assert len(conv.data) == 10000 # bitmap 8000 + attr 1000 + ch 1000
|
||||
if not have_xa():
|
||||
return
|
||||
prg = build_prg(bytes(conv.data))
|
||||
assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) # load $1001
|
||||
assert prg[2:2 + len(_STUB)] == _STUB # 10 SYS4128
|
||||
# the 8000-byte bitmap lands at $2000
|
||||
off = 2 + (BITMAP_ORG - BASIC_START)
|
||||
assert bytes(prg[off:off + 8000]) == bytes(conv.data[:8000])
|
||||
|
||||
|
||||
def test_plus4_reuses_c16():
|
||||
# Plus/4 uses the same TED + BASIC 3.5 as the C16, so its encoder, modes and
|
||||
# .prg are identical -- the plus4 package re-exports the C16 implementation.
|
||||
from lenser.plus4.convert import MODES, convert_image
|
||||
from lenser.c16.convert import MODES as C16_MODES, convert_image as c16_convert
|
||||
from lenser.plus4.exporter import export_prg
|
||||
from lenser.c16.exporter import export_prg as c16_export_prg
|
||||
assert MODES == C16_MODES
|
||||
assert convert_image is c16_convert and export_prg is c16_export_prg
|
||||
img = _imgobj(320, 200)
|
||||
conv = convert_image(img, mode="hires", dither_mode="floyd")
|
||||
assert conv.mode == "hires" and len(conv.data) == 10000
|
||||
|
||||
|
||||
def test_cpc_sna():
|
||||
from lenser.cpc.convert import convert_image, MODES
|
||||
from lenser.cpc.exporter import export_sna
|
||||
from lenser.cpc import snapshot
|
||||
import tempfile, os
|
||||
img = _imgobj(320, 200)
|
||||
assert MODES == ["mode0", "mode1", "mono"]
|
||||
for m, ncol in (("mode0", 16), ("mode1", 4), ("mono", 2)):
|
||||
conv = convert_image(img, mode=m, dither_mode="floyd")
|
||||
assert len(conv.data) == 0x4000 # 16K screen at &C000
|
||||
assert conv.data_addr == 0xC000
|
||||
assert 1 <= len(conv.meta["inks"]) <= ncol
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = export_sna(conv, os.path.join(td, "x.sna"))
|
||||
sna = open(p, "rb").read()
|
||||
assert sna[:8] == b"MV - SNA" # CPC snapshot signature
|
||||
assert len(sna) == 0x100 + 0x10000 # 256 header + 64K RAM
|
||||
assert (sna[0x40] & 0x03) == conv.meta["cpc_mode"] # mode in RMR
|
||||
|
||||
|
||||
def test_coco3_cart():
|
||||
from lenser.coco3.convert import convert_image, MODES
|
||||
from lenser.coco3.exporter import export_ccc
|
||||
import tempfile, os
|
||||
img = _imgobj(320, 192)
|
||||
assert MODES == ["gr16", "gr4", "mono"]
|
||||
for m, ncol in (("gr16", 16), ("gr4", 4), ("mono", 2)):
|
||||
conv = convert_image(img, mode=m, dither_mode="floyd")
|
||||
assert len(conv.data) == 15360 # 80 bytes/row * 192, linear
|
||||
assert conv.data_addr == 0x4000
|
||||
assert 1 <= len(conv.meta["inks"]) <= ncol
|
||||
assert all(0 <= c < 64 for c in conv.meta["inks"]) # 6-bit GIME colours
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = export_ccc(conv, os.path.join(td, "x.ccc"))
|
||||
rom = open(p, "rb").read()
|
||||
assert len(rom) == 0x4000 # 16K cartridge
|
||||
assert rom[:2] == bytes([0x1A, 0x50]) # viewer starts: ORCC #$50
|
||||
|
||||
|
||||
def test_nes_cart():
|
||||
from lenser.nes.convert import convert_image, MODES
|
||||
from lenser.nes.exporter import export_nes
|
||||
from lenser.nes.cartridge import have_xa
|
||||
import tempfile, os
|
||||
img = _imgobj(256, 240)
|
||||
assert MODES == ["bg", "mono"]
|
||||
for m in MODES:
|
||||
conv = convert_image(img, mode=m, dither_mode="floyd")
|
||||
d = conv.data
|
||||
assert len(d["palette"]) == 32
|
||||
assert len(d["nametable"]) == 1024 # 960 names + 64 attribute
|
||||
assert len(d["chr"]) == 8192 # 8K CHR-ROM (256 bg tiles)
|
||||
assert all(0 <= b < 64 for b in d["palette"]) # 6-bit NES colours
|
||||
if not have_xa():
|
||||
continue
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = export_nes(conv, os.path.join(td, "x.nes"))
|
||||
rom = open(p, "rb").read()
|
||||
assert rom[:4] == b"NES\x1a" # iNES signature
|
||||
assert rom[4] == 1 and rom[5] == 1 # 16K PRG + 8K CHR (NROM)
|
||||
assert len(rom) == 16 + 0x4000 + 0x2000
|
||||
# reset vector ($FFFC) -> $C000 (viewer start)
|
||||
assert rom[16 + 0x3FFC] == 0x00 and rom[16 + 0x3FFD] == 0xC0
|
||||
|
||||
|
||||
def test_iigs_dsk():
|
||||
from lenser.iigs.convert import convert_image, MODES
|
||||
from lenser.iigs.viewer.assemble import assemble_boot, have_xa
|
||||
from lenser.iigs.exporter import export_dsk
|
||||
import tempfile, os
|
||||
img = _imgobj(320, 200)
|
||||
assert MODES == ["shr", "mono"]
|
||||
for m in MODES:
|
||||
conv = convert_image(img, mode=m, dither_mode="floyd")
|
||||
assert len(conv.data) == 0x8000 # 32K SHR block ($2000-$9FFF)
|
||||
assert conv.data_addr == 0x2000
|
||||
if not have_xa():
|
||||
return
|
||||
boot = assemble_boot()
|
||||
assert len(boot) <= 256 # fits one boot sector
|
||||
assert boot[1] == 0xAD # entry: LDA dpage (abs)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
conv = convert_image(img, mode="shr", dither_mode="floyd")
|
||||
p = export_dsk(conv, os.path.join(td, "x.dsk"))
|
||||
dsk = open(p, "rb").read()
|
||||
assert len(dsk) == 143360 # 140K 5.25" disk
|
||||
assert dsk[:len(boot)] == boot # boot sector at track 0 sector 0
|
||||
|
||||
|
||||
def test_pet_prg():
|
||||
from lenser.pet.convert import convert_image
|
||||
from lenser.pet import palette as petpal
|
||||
from lenser.pet.viewer.assemble import build_prg, have_xa, BASIC_START, _STUB
|
||||
# all 16 quadrant codes distinct -> a real one-to-one block mapping
|
||||
assert len(set(petpal.QUAD)) == 16
|
||||
for cols, scr in ((40, 1000), (80, 2000)):
|
||||
conv = convert_image(_imgobj(320, 200), cols=cols, dither_mode="floyd")
|
||||
assert conv.mode == "mono"
|
||||
assert len(conv.data) == scr # screen-RAM bytes
|
||||
assert conv.data_addr == 0x8000
|
||||
assert all(b in petpal.QUAD for b in conv.data) # only quadrant codes
|
||||
if not have_xa():
|
||||
continue
|
||||
prg = build_prg(bytes(conv.data))
|
||||
assert prg[:2] == bytes([BASIC_START & 0xFF, BASIC_START >> 8]) # $0401
|
||||
assert prg[2:2 + len(_STUB)] == _STUB # 10 SYS1056
|
||||
|
||||
|
||||
def test_sms_cart():
|
||||
from lenser.sms.convert import convert_image, MODES
|
||||
from lenser.sms.exporter import export_sms
|
||||
from lenser.sms import viewer as smsv
|
||||
import tempfile, os
|
||||
img = _imgobj(256, 192)
|
||||
assert MODES == ["bg", "mono"]
|
||||
for m in MODES:
|
||||
conv = convert_image(img, mode=m, dither_mode="floyd")
|
||||
d = conv.data
|
||||
assert len(d["patterns"]) == 448 * 32 # <=448 tiles, no name-table clash
|
||||
assert len(d["nametable"]) == 32 * 24 * 2
|
||||
assert len(d["palette"]) == 32 # 2 x 16 colours
|
||||
assert all(0 <= b < 64 for b in d["palette"])
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = export_sms(conv, os.path.join(td, "x.sms"))
|
||||
rom = open(p, "rb").read()
|
||||
assert len(rom) == 0x8000 # 32K cartridge
|
||||
assert rom[:1] == bytes([0xF3]) # Z80 viewer starts with DI
|
||||
assert rom[0x7FF0:0x7FF8] == b"TMR SEGA" # SMS header signature
|
||||
|
||||
|
||||
def test_amiga_adf():
|
||||
from lenser.amiga.convert import convert_image, MODES
|
||||
from lenser.amiga.exporter import export_adf
|
||||
import tempfile, os, struct
|
||||
img = _imgobj(320, 200)
|
||||
assert MODES == ["lowres", "mono"]
|
||||
for m, nplanes in (("lowres", 5), ("mono", 4)):
|
||||
conv = convert_image(img, mode=m, dither_mode="floyd")
|
||||
d = conv.data
|
||||
assert d["nplanes"] == nplanes and d["ham"] is False
|
||||
assert len(d["planes"]) == nplanes * 40 * 200 # contiguous bitplanes
|
||||
assert all(0 <= c < 4096 for c in d["colors"]) # 12-bit colours
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
p = export_adf(conv, os.path.join(td, "x.adf"))
|
||||
adf = open(p, "rb").read()
|
||||
assert len(adf) == 901120 # 880K floppy
|
||||
assert adf[0:4] == b"DOS\x00" # boot block id
|
||||
# boot block longword checksum must total $FFFFFFFF (else not bootable)
|
||||
s = 0
|
||||
for i in range(0, 1024, 4):
|
||||
s += struct.unpack(">I", adf[i:i + 4])[0]
|
||||
if s > 0xFFFFFFFF:
|
||||
s = (s + 1) & 0xFFFFFFFF
|
||||
assert s == 0xFFFFFFFF
|
||||
|
||||
|
||||
def test_viewers_assemble_and_fit():
|
||||
if not have_xa():
|
||||
return # xa not installed; skip
|
||||
sizes = {"hires": 9000, "multicolor": 10001, "fli": 17385,
|
||||
"fli_ntsc": 17385, "interlace": 25577}
|
||||
for key in SOURCES:
|
||||
if key not in sizes:
|
||||
continue # e.g. "slideshow" is code-only (own builder/test)
|
||||
prg = build_viewer_prg(key, bytes(sizes[key]),
|
||||
0x4000 if key.startswith("fli") else 0x2000)
|
||||
assert prg[:2] == bytes([0x01, 0x08]) # PRG load address $0801
|
||||
|
|
|
|||
381
tests/test_slideshow.py
Normal file
381
tests/test_slideshow.py
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
"""Slideshow tests: storage budget math, viewer assembly fit, and a full disk
|
||||
round-trip (viewer + N data files written and read back).
|
||||
|
||||
Run with `pytest` or directly: `python tests/test_slideshow.py`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from lenser import slideshow as ss # noqa: E402
|
||||
from lenser import imageprep # noqa: E402
|
||||
from lenser.diskimage import DiskError, have_c1541 # noqa: E402
|
||||
from lenser.viewer.assemble import (build_slideshow_prg, # noqa: E402
|
||||
have_xa)
|
||||
|
||||
|
||||
def _gradient_png(path, w=160, h=200):
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
rgb = np.stack([(xx * 255 // w), (yy * 255 // h),
|
||||
((xx + yy) * 255 // (w + h))], axis=-1).astype(np.uint8)
|
||||
Image.fromarray(rgb, "RGB").save(path)
|
||||
|
||||
|
||||
def test_slideshow_budget():
|
||||
# CBM block rounding: a PRG of L bytes -> ceil((L+2)/254) blocks.
|
||||
assert ss.item_blocks("c64", "d64", 10001) == 40 # multicolor
|
||||
assert ss.item_blocks("c64", "d64", 9000) == 36 # hires/mono
|
||||
assert ss.item_blocks("c64", "d64", 1) == 1
|
||||
|
||||
b = ss.budget("c64", "d64", [10001, 9000, 9000], viewer_len=283)
|
||||
assert b.fits and b.files == 4
|
||||
assert b.used_blocks == ss._cbm_blocks(283) + 40 + 36 + 36
|
||||
|
||||
# too many multicolor images for a d64, but fine on a d81
|
||||
many = [10001] * 20
|
||||
assert not ss.budget("c64", "d64", many, 283).fits
|
||||
assert ss.budget("c64", "d81", many, 283).fits
|
||||
|
||||
# directory-entry cap (d81 = 296) is enforced even when blocks would fit:
|
||||
# 300 one-block files use ~301 blocks (well under 3160) but 301 dir entries.
|
||||
tiny = [10] * 300
|
||||
over = ss.budget("c64", "d81", tiny, 283)
|
||||
assert over.used_blocks < over.total_blocks # blocks would fit
|
||||
assert not over.fits and "directory" in over.reason
|
||||
|
||||
|
||||
def test_slideshow_stub_assembles_and_fits():
|
||||
if not have_xa():
|
||||
return
|
||||
for advance in ("key", "seconds", "both"):
|
||||
for loop in (True, False):
|
||||
prg = build_slideshow_prg([0, 1, 0], advance=advance, seconds=5,
|
||||
loop=loop)
|
||||
assert prg[:2] == bytes([0x01, 0x08]) # load address $0801
|
||||
end = 0x0801 + (len(prg) - 2)
|
||||
assert end < 0x2000 # code clears picture RAM
|
||||
|
||||
|
||||
def test_slideshow_disk_roundtrip():
|
||||
if not (have_xa() and have_c1541()):
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src)
|
||||
items = [
|
||||
ss.SlideItem(src, mode="multicolor"),
|
||||
ss.SlideItem(src, mode="hires",
|
||||
prep=imageprep.PrepOptions(brightness=1.2)),
|
||||
ss.SlideItem(src, mode="mono", mono_base="green"),
|
||||
]
|
||||
show = ss.Slideshow(platform="c64", disk_format="d64", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
out = os.path.join(td, "show.d64")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
|
||||
# directory lists the viewer first, then 00, 01, 02 in order
|
||||
dirout = subprocess.run(["c1541", "-attach", out, "-dir"],
|
||||
capture_output=True, text=True).stdout
|
||||
assert '"show"' in dirout
|
||||
for i in range(3):
|
||||
assert f'"{i:02d}"' in dirout
|
||||
|
||||
# each data file reads back byte-identical to the conversion payload
|
||||
for i, c in enumerate(convs):
|
||||
host = os.path.join(td, f"r{i}.prg")
|
||||
subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host],
|
||||
capture_output=True)
|
||||
raw = open(host, "rb").read()
|
||||
assert raw[0] | (raw[1] << 8) == c.data_addr
|
||||
assert raw[2:] == c.data
|
||||
|
||||
|
||||
def test_slideshow_flavor_detection():
|
||||
class C:
|
||||
def __init__(self, v):
|
||||
self.viewer = v
|
||||
assert ss.slideshow_flavor([C("hires"), C("multicolor"), C("hires")]) == "simple"
|
||||
assert ss.slideshow_flavor([C("fli"), C("fli")]) == "fli"
|
||||
assert ss.slideshow_flavor([C("interlace")]) == "interlace"
|
||||
for bad in ([C("hires"), C("fli")], [C("fli"), C("interlace")]):
|
||||
try:
|
||||
ss.slideshow_flavor(bad)
|
||||
assert False, "mixed flavors should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_fli_interlace_stubs_fit():
|
||||
if not have_xa():
|
||||
return
|
||||
# fli code must clear $4000, interlace must clear $2000 (their data regions)
|
||||
for flavor, limit in (("fli", 0x4000), ("interlace", 0x2000)):
|
||||
for video in ("pal", "ntsc"):
|
||||
prg = build_slideshow_prg([0, 0, 0], advance="both", seconds=5,
|
||||
loop=True, video=video, flavor=flavor)
|
||||
assert prg[:2] == bytes([0x01, 0x08])
|
||||
assert 0x0801 + (len(prg) - 2) < limit
|
||||
|
||||
|
||||
def test_fli_interlace_disk_roundtrip():
|
||||
if not (have_xa() and have_c1541()):
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src)
|
||||
for flavor, mode in (("fli", "fli"), ("interlace", "interlace")):
|
||||
items = [ss.SlideItem(src, mode=mode), ss.SlideItem(src, mode=mode)]
|
||||
show = ss.Slideshow(platform="c64", disk_format="d64", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
assert ss.slideshow_flavor(convs) == flavor
|
||||
out = os.path.join(td, f"{flavor}.d64")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
for i, c in enumerate(convs):
|
||||
host = os.path.join(td, f"{flavor}{i}.prg")
|
||||
subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host],
|
||||
capture_output=True)
|
||||
raw = open(host, "rb").read()
|
||||
assert raw[0] | (raw[1] << 8) == c.data_addr # $4000 fli / $2000 ifli
|
||||
assert raw[2:] == c.data
|
||||
|
||||
|
||||
def test_c128_slideshow():
|
||||
assert ss.supports_slideshow("c128")
|
||||
# CBM-DOS budget shared with the C64
|
||||
assert ss.item_blocks("c128", "d64", 16384) == ss.item_blocks("c64", "d64", 16384)
|
||||
if not (have_xa() and have_c1541()):
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src)
|
||||
items = [ss.SlideItem(src, mode="hicolor"), ss.SlideItem(src, mode="mono")]
|
||||
show = ss.Slideshow(platform="c128", disk_format="d64", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
out = os.path.join(td, "c128.d64")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
dirout = subprocess.run(["c1541", "-attach", out, "-dir"],
|
||||
capture_output=True, text=True).stdout
|
||||
assert '"pic"' in dirout # boots via RUN"PIC"
|
||||
for i in range(2):
|
||||
assert f'"{i:02d}"' in dirout
|
||||
host = os.path.join(td, f"r{i}.prg")
|
||||
subprocess.run(["c1541", "-attach", out, "-read", f"{i:02d}", host],
|
||||
capture_output=True)
|
||||
raw = open(host, "rb").read()
|
||||
assert raw[0] | (raw[1] << 8) == 0x4000 # VDC images load to $4000
|
||||
assert raw[2:] == convs[i].data
|
||||
# the 80x100 'color' VDC mode is not yet supported in slideshows
|
||||
cshow = ss.Slideshow(platform="c128", items=[ss.SlideItem(src, mode="color")])
|
||||
try:
|
||||
ss.check_modes("c128", [ss.convert_item(cshow, cshow.items[0])])
|
||||
assert False, "color mode should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_atari_slideshow():
|
||||
assert ss.supports_slideshow("atari")
|
||||
assert ss.disk_formats("atari") == ["atr"]
|
||||
# ATR sectors are 128 bytes, no 2-byte load-address overhead
|
||||
assert ss.item_blocks("atari", "atr", 8196) == 65
|
||||
assert ss.item_blocks("atari", "atr", 256) == 2
|
||||
if not have_xa():
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src, 160, 192)
|
||||
items = [ss.SlideItem(src, mode="gr15") for _ in range(3)]
|
||||
show = ss.Slideshow(platform="atari", disk_format="atr", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
out = os.path.join(td, "show.atr")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
raw = open(out, "rb").read()
|
||||
assert raw[:2] == bytes([0x96, 0x02]) # ATR magic
|
||||
assert raw[16 + 1] == 8 # boot loads 8 sectors
|
||||
spi = ss.item_blocks("atari", "atr", len(convs[0].data))
|
||||
for i, c in enumerate(convs):
|
||||
sector = 9 + i * spi # boot_sectors(8)+1 + i*spi
|
||||
off = 16 + (sector - 1) * 128
|
||||
assert raw[off:off + len(c.data)] == c.data
|
||||
# gr15 / gr9 / gr8 / gr15dli all build a disk
|
||||
for mode in ("gr9", "gr8", "gr15dli"):
|
||||
sh = ss.Slideshow(platform="atari", disk_format="atr",
|
||||
items=[ss.SlideItem(src, mode=mode)] * 2)
|
||||
ss.build_disk(sh, os.path.join(td, f"{mode}.atr"))
|
||||
# but modes may not be MIXED in one slideshow
|
||||
mix = [ss.convert_item(ss.Slideshow(platform="atari"),
|
||||
ss.SlideItem(src, mode=m)) for m in ("gr15", "gr9")]
|
||||
try:
|
||||
ss.check_modes("atari", mix)
|
||||
assert False, "mixed Atari modes should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_bbc_slideshow():
|
||||
assert ss.supports_slideshow("bbc")
|
||||
assert ss.disk_formats("bbc") == ["ssd"]
|
||||
assert ss.item_blocks("bbc", "ssd", 20480) == 80 # 256-byte DFS sectors
|
||||
if not have_xa():
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src, 320, 256)
|
||||
items = [ss.SlideItem(src, mode="mode1") for _ in range(3)]
|
||||
show = ss.Slideshow(platform="bbc", disk_format="ssd", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
out = os.path.join(td, "show.ssd")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
d = open(out, "rb").read()
|
||||
nfiles = d[0x105] // 8
|
||||
names = [d[8 + i * 8:8 + i * 8 + 7].decode("ascii").rstrip()
|
||||
for i in range(nfiles)]
|
||||
assert "!BOOT" in names and "PIC" in names
|
||||
for i in range(3):
|
||||
assert f"{i:02d}" in names
|
||||
j = names.index(f"{i:02d}")
|
||||
e = 0x100 + 8 + j * 8
|
||||
start = ((d[e + 6] & 3) << 8) | d[e + 7]
|
||||
length = (((d[e + 6] >> 4) & 3) << 16) | (d[e + 5] << 8) | d[e + 4]
|
||||
off = start * 256
|
||||
assert d[off:off + length] == convs[i].data
|
||||
# a single BBC screen mode per slideshow
|
||||
m2 = ss.convert_item(ss.Slideshow(platform="bbc"),
|
||||
ss.SlideItem(src, mode="mode2"))
|
||||
try:
|
||||
ss.check_modes("bbc", [convs[0], m2])
|
||||
assert False, "mixed BBC modes should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_apple_slideshow():
|
||||
assert ss.supports_slideshow("apple")
|
||||
assert ss.disk_formats("apple") == ["dsk"]
|
||||
assert ss.item_blocks("apple", "dsk", 8192) == 32
|
||||
assert ss.budget("apple", "dsk", [8192] * 4, 0).fits # RAM holds 4 HGR
|
||||
assert not ss.budget("apple", "dsk", [8192] * 5, 0).fits
|
||||
if not have_xa():
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src, 280, 192)
|
||||
items = [ss.SlideItem(src, mode="hgr_color") for _ in range(3)]
|
||||
show = ss.Slideshow(platform="apple", disk_format="dsk", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
out = os.path.join(td, "show.dsk")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
d = open(out, "rb").read()
|
||||
assert len(d) == 143360 # DOS 3.3 .dsk
|
||||
assert d[0] == 0x01 # boot sector byte 0
|
||||
# too many images for RAM
|
||||
try:
|
||||
ss.check_modes("apple", convs * 2) # 6 > 4
|
||||
assert False, "over-RAM Apple slideshow should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
# DHGR is not supported for slideshows yet
|
||||
dh = ss.convert_item(ss.Slideshow(platform="apple"),
|
||||
ss.SlideItem(src, mode="dhgr"))
|
||||
try:
|
||||
ss.check_modes("apple", [dh])
|
||||
assert False, "DHGR should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_iigs_slideshow():
|
||||
assert ss.supports_slideshow("iigs")
|
||||
assert ss.disk_formats("iigs") == ["dsk"]
|
||||
assert ss.item_blocks("iigs", "dsk", 32768) == 128
|
||||
assert ss.budget("iigs", "dsk", [32768] * 4, 0).fits
|
||||
assert not ss.budget("iigs", "dsk", [32768] * 5, 0).fits
|
||||
if not have_xa():
|
||||
return
|
||||
# the two-stage loader: boot must fit one 256-byte sector
|
||||
from lenser.iigs.viewer.assemble import build_slideshow
|
||||
boot, stage2, pages = build_slideshow(3, advance="both", seconds=5, loop=True)
|
||||
assert len(boot) <= 256 and len(stage2) > 0 and pages >= 1
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src, 320, 200)
|
||||
items = [ss.SlideItem(src, mode="shr") for _ in range(3)]
|
||||
show = ss.Slideshow(platform="iigs", disk_format="dsk", advance="both",
|
||||
seconds=5, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
out = os.path.join(td, "show.dsk")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
d = open(out, "rb").read()
|
||||
assert len(d) == 143360 and d[0] == 0x01 # bootable .dsk
|
||||
try:
|
||||
ss.check_modes("iigs", convs * 2) # 6 > 4
|
||||
assert False, "over-capacity IIgs slideshow should be rejected"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_amiga_slideshow():
|
||||
assert ss.supports_slideshow("amiga")
|
||||
assert ss.disk_formats("amiga") == ["adf"]
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src, 320, 256)
|
||||
items = [ss.SlideItem(src, mode="lowres") for _ in range(3)]
|
||||
show = ss.Slideshow(platform="amiga", disk_format="adf", advance="both",
|
||||
seconds=4, loop=True, items=items)
|
||||
convs = [ss.convert_item(show, it) for it in items]
|
||||
# Amiga conv.data is a dict; its on-disk size comes from the blob
|
||||
nb = ss.image_nbytes(convs[0])
|
||||
assert nb > 8000 and ss.item_blocks("amiga", "adf", nb) == -(-nb // 512)
|
||||
out = os.path.join(td, "show.adf")
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
d = open(out, "rb").read()
|
||||
assert len(d) == 901120 and d[:4] == b"DOS\x00" # bootable 880K .adf
|
||||
# chip-RAM bound rejects an oversized show
|
||||
assert not ss.budget("amiga", "adf", [40000] * 20, 0).fits
|
||||
|
||||
|
||||
def test_slideshow_overfill_raises():
|
||||
if not have_xa():
|
||||
return
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
src = os.path.join(td, "g.png")
|
||||
_gradient_png(src)
|
||||
# 25 multicolor images (~40 blocks each) cannot fit a 664-block d64
|
||||
items = [ss.SlideItem(src, mode="multicolor") for _ in range(25)]
|
||||
show = ss.Slideshow(disk_format="d64", items=items)
|
||||
# reuse one conversion for all to keep the test fast
|
||||
c0 = ss.convert_item(show, items[0])
|
||||
convs = [c0] * 25
|
||||
out = os.path.join(td, "show.d64")
|
||||
try:
|
||||
ss.build_disk(show, out, convs=convs)
|
||||
assert False, "expected DiskError for an over-budget slideshow"
|
||||
except DiskError as e:
|
||||
assert "does not fit" in str(e)
|
||||
|
||||
|
||||
def _imgobj(w, h):
|
||||
arr = np.zeros((h, w, 3), np.uint8)
|
||||
return Image.fromarray(arr, "RGB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||||
for fn in fns:
|
||||
fn()
|
||||
print(f"PASS {fn.__name__}")
|
||||
print(f"\nAll {len(fns)} slideshow tests passed.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue