8bitlenser/lenser/platforms.py
2026-07-03 19:35:35 -07:00

563 lines
27 KiB
Python

"""Platform abstraction so the GUI (and anything else) can treat C64 and Atari
uniformly: which modes/palettes/formats exist, and how to convert/export/run.
"""
from __future__ import annotations
import os
import tempfile
from . import crt, imageprep, mame
from .apple.convert import MODES as APPLE_MODES, convert_image as _apple_convert
from .apple.exporter import export_dsk
from .atari.convert import MODES as ATARI_MODES, convert_image as _atari_convert
from .atari.exporter import export_atr, export_car
from .atari.palette import nearest_hue
from .bbc.convert import MODES as BBC_MODES, convert_image as _bbc_convert
from .bbc.exporter import export_ssd
from .coco.convert import MODES as COCO_MODES, convert_image as _coco_convert
from .coco.exporter import export_ccc
from .coleco.convert import MODES as COLECO_MODES, convert_image as _coleco_convert
from .coleco.exporter import export_col
from .a2600.convert import MODES as A2600_MODES, convert_image as _a2600_convert
from .a2600.exporter import export_a26
from .convert import MODES as C64_MODES, convert_image as _c64_convert
from .diskimage import FORMATS
from .exporter import export_cart, export_disk
from .palette import COLOR_NAMES, get_palette
from .ti99.convert import MODES as TI99_MODES, convert_image as _ti99_convert
from .ti99.exporter import export_rpk
from .intv.convert import MODES as INTV_MODES, convert_image as _intv_convert
from .intv.exporter import export_int
from .vic20.convert import MODES as VIC20_MODES, convert_image as _vic20_convert
from .vic20.exporter import export_a0
from .spectrum.convert import MODES as SPECTRUM_MODES, convert_image as _spectrum_convert
from .spectrum.exporter import export_sna
from .a5200.convert import MODES as A5200_MODES, convert_image as _a5200_convert
from .a5200.exporter import export_a52
from .a7800.convert import MODES as A7800_MODES, convert_image as _a7800_convert
from .a7800.exporter import export_a78
from .c128.convert import MODES as C128_MODES, convert_image as _c128_convert
from .c128.exporter import export_d64 as _c128_export_d64
from .c16.convert import MODES as C16_MODES, convert_image as _c16_convert
from .c16.exporter import export_prg as _c16_export_prg
from .plus4.convert import MODES as PLUS4_MODES, convert_image as _plus4_convert
from .plus4.exporter import export_prg as _plus4_export_prg
from .cpc.convert import MODES as CPC_MODES, convert_image as _cpc_convert
from .cpc.exporter import export_sna as _cpc_export_sna
from .coco3.convert import MODES as COCO3_MODES, convert_image as _coco3_convert
from .coco3.exporter import export_ccc as _coco3_export_ccc
from .nes.convert import MODES as NES_MODES, convert_image as _nes_convert
from .nes.exporter import export_nes as _nes_export_nes
from .iigs.convert import MODES as IIGS_MODES, convert_image as _iigs_convert
from .iigs.exporter import export_dsk as _iigs_export_dsk
from .pet.convert import convert_image as _pet_convert
from .pet.exporter import export_prg as _pet_export_prg
from .sms.convert import MODES as SMS_MODES, convert_image as _sms_convert
from .sms.exporter import export_sms as _sms_export_sms
from .amiga.convert import MODES as AMIGA_MODES, convert_image as _amiga_convert
from .amiga.exporter import export_adf as _amiga_export_adf
from .ansi.convert import MODES as ANSI_MODES, convert_image as _ansi_convert
from .ansi.exporter import export_ans as _ansi_export_ans
# PET/CBM families -> (MAME machine, screen columns). 40-col models share the
# 80x50 quadrant-block encoder; 80-col models share 160x50.
PET_CFG = {"pet2001": ("pet2001n", 40), "pet4032": ("pet4032", 40),
"pet8032": ("pet8032", 80), "superpet": ("cbm8032", 80)}
# (MAME's `superpet` driver is incomplete; the SuperPET's display is identical to
# the CBM 8032 it is built on, so the working cbm8032 machine is used.)
PLATFORMS = ["c64", "atari", "apple", "ti99", "coco", "bbc", "coleco", "a2600",
"intv", "vic20", "spectrum", "a5200", "a7800", "c128", "c16",
"plus4", "cpc", "coco3", "nes", "iigs",
"pet2001", "pet4032", "pet8032", "superpet", "sms", "amiga", "ansi"]
# Apple modes needing a //e (auxiliary memory) rather than a II+.
_APPLE_IIE_MODES = {"dhgr"}
def modes(platform: str) -> list[str]:
if platform == "c64":
return ["auto", *C64_MODES]
if platform == "atari":
return list(ATARI_MODES)
if platform == "ti99":
return list(TI99_MODES)
if platform == "coco":
return list(COCO_MODES)
if platform == "bbc":
return list(BBC_MODES)
if platform == "coleco":
return list(COLECO_MODES)
if platform == "a2600":
return list(A2600_MODES)
if platform == "intv":
return list(INTV_MODES)
if platform == "vic20":
return list(VIC20_MODES)
if platform == "spectrum":
return list(SPECTRUM_MODES)
if platform == "a5200":
return list(A5200_MODES)
if platform == "a7800":
return list(A7800_MODES)
if platform == "c128":
return list(C128_MODES)
if platform == "c16":
return list(C16_MODES)
if platform == "plus4":
return list(PLUS4_MODES)
if platform == "cpc":
return list(CPC_MODES)
if platform == "coco3":
return list(COCO3_MODES)
if platform == "nes":
return list(NES_MODES)
if platform == "iigs":
return list(IIGS_MODES)
if platform in PET_CFG:
return ["mono"]
if platform == "sms":
return list(SMS_MODES)
if platform == "amiga":
return list(AMIGA_MODES)
if platform == "ansi":
return list(ANSI_MODES)
return list(APPLE_MODES)
def palettes(platform: str) -> list[str]:
if platform == "c64":
return ["colodore", "pepto"]
if platform == "atari":
return ["ntsc"]
if platform == "ti99":
return ["tms9918"]
if platform == "coco":
return ["mc6847"]
if platform == "bbc":
return ["bbc"]
if platform == "coleco":
return ["tms9918"]
if platform == "a2600":
return ["tia"]
if platform == "intv":
return ["stic"]
if platform == "vic20":
return ["vic"]
if platform == "spectrum":
return ["spectrum"]
if platform == "a5200":
return ["ntsc"]
if platform == "a7800":
return ["ntsc"]
if platform == "c128":
return ["vdc"]
if platform in ("c16", "plus4"):
return ["ted"]
if platform == "cpc":
return ["cpc"]
if platform == "coco3":
return ["gime"]
if platform == "nes":
return ["nes"]
if platform == "iigs":
return ["iigs"]
if platform in PET_CFG:
return ["pet"]
if platform == "sms":
return ["sms"]
if platform == "amiga":
return ["amiga"]
if platform == "ansi":
return ["vga"]
return ["mono"]
def disk_formats(platform: str) -> list[str]:
if platform == "c64":
return [*FORMATS.keys(), "crt"] # disks + 16K cartridge
if platform == "atari":
return ["atr", "car"] # disk + 16K cartridge
if platform == "ti99":
return ["rpk"]
if platform == "coco":
return ["ccc"] # Program Pak cartridge
if platform == "bbc":
return ["ssd"] # DFS disk
if platform == "coleco":
return ["col"] # ColecoVision cartridge (runs on Adam too)
if platform == "a2600":
return ["a26"] # Atari 2600 cartridge
if platform == "intv":
return ["int"] # Intellivision cartridge (.int/.bin/.rom)
if platform == "vic20":
return ["a0"] # VIC-20 autostart cartridge ($A000)
if platform == "spectrum":
return ["sna"] # 48K snapshot (also drops a .scr)
if platform == "a5200":
return ["a52"] # Atari 5200 cartridge
if platform == "a7800":
return ["a78"] # Atari 7800 cartridge
if platform == "c128":
return ["d64"] # C128 autobooting disk (VDC 80-col)
if platform in ("c16", "plus4"):
return ["prg"] # TED program (quickload / LOAD+RUN)
if platform == "cpc":
return ["sna"] # Amstrad CPC snapshot
if platform == "coco3":
return ["ccc"] # CoCo 3 cartridge
if platform == "nes":
return ["nes"] # iNES cartridge
if platform == "iigs":
return ["dsk"] # bootable 5.25" SHR disk
if platform in PET_CFG:
return ["prg"] # PET program (quickload / LOAD+RUN)
if platform == "sms":
return ["sms"] # Master System cartridge
if platform == "amiga":
return ["adf"] # Amiga bootable floppy
if platform == "ansi":
return ["ans"] # ANSI/CP437 BBS art (plain text file)
return ["dsk"]
def _base_color(platform: str, base_name: str):
if base_name == "grayscale":
return None
idx = COLOR_NAMES.index(base_name)
if platform == "atari":
return nearest_hue(get_palette("colodore")[idx])
return idx
def convert(platform, path, mode, palette, dither, intensive, prep_opt, base_name):
base = _base_color(platform, base_name)
if platform == "ti99":
if mode not in TI99_MODES:
mode = "gm2"
return _ti99_convert(path, mode=mode, palette_name="tms9918",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "apple":
if mode not in APPLE_MODES:
mode = "hgr_mono"
return _apple_convert(path, mode=mode, dither_mode=dither,
intensive=intensive, prep_opt=prep_opt, base_color=base)
if platform == "atari":
if mode not in ATARI_MODES:
mode = "gr15"
return _atari_convert(path, mode=mode, palette_name="ntsc",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "coco":
if mode not in COCO_MODES:
mode = "pmode4"
return _coco_convert(path, mode=mode, palette_name="mc6847",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "bbc":
if mode not in BBC_MODES:
mode = "mode2"
return _bbc_convert(path, mode=mode, palette_name="bbc",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "coleco":
if mode not in COLECO_MODES:
mode = "gm2"
return _coleco_convert(path, mode=mode, palette_name="tms9918",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "a2600":
if mode not in A2600_MODES:
mode = "pf"
return _a2600_convert(path, mode=mode, palette_name="tia",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "intv":
if mode not in INTV_MODES:
mode = "stic"
return _intv_convert(path, mode=mode, palette_name="stic",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "vic20":
if mode not in VIC20_MODES:
mode = "multicolor"
return _vic20_convert(path, mode=mode, palette_name="vic",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "spectrum":
if mode not in SPECTRUM_MODES:
mode = "hires"
return _spectrum_convert(path, mode=mode, palette_name="spectrum",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "a5200":
if mode not in A5200_MODES:
mode = "gr15"
return _a5200_convert(path, mode=mode, palette_name="ntsc",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "a7800":
if mode not in A7800_MODES:
mode = "c160"
return _a7800_convert(path, mode=mode, palette_name="ntsc",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "c128":
c128_mode = mode if mode in C128_MODES else "mono"
return _c128_convert(path, mode=c128_mode, palette_name="vdc",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "c16":
c16_mode = mode if mode in C16_MODES else "hires"
return _c16_convert(path, mode=c16_mode, palette_name="ted",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "plus4":
p4_mode = mode if mode in PLUS4_MODES else "hires"
return _plus4_convert(path, mode=p4_mode, palette_name="ted",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "cpc":
cpc_mode = mode if mode in CPC_MODES else "mode0"
return _cpc_convert(path, mode=cpc_mode, palette_name="cpc",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "coco3":
c3_mode = mode if mode in COCO3_MODES else "gr16"
return _coco3_convert(path, mode=c3_mode, palette_name="gime",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "nes":
nes_mode = mode if mode in NES_MODES else "bg"
return _nes_convert(path, mode=nes_mode, palette_name="nes",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "iigs":
gs_mode = mode if mode in IIGS_MODES else "shr"
return _iigs_convert(path, mode=gs_mode, palette_name="iigs",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform in PET_CFG:
return _pet_convert(path, cols=PET_CFG[platform][1], dither_mode=dither,
intensive=intensive, prep_opt=prep_opt, base_color=base)
if platform == "sms":
sms_mode = mode if mode in SMS_MODES else "bg"
return _sms_convert(path, mode=sms_mode, palette_name="sms",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "amiga":
am_mode = mode if mode in AMIGA_MODES else "lowres"
return _amiga_convert(path, mode=am_mode, palette_name="amiga",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
if platform == "ansi":
ansi_mode = mode if mode in ANSI_MODES else "80x25"
return _ansi_convert(path, mode=ansi_mode, palette_name="vga",
dither_mode=dither, intensive=intensive,
prep_opt=prep_opt, base_color=base)
return _c64_convert(path, mode=mode, palette_name=palette, dither_mode=dither,
intensive=intensive, prep_opt=prep_opt, base_color=base)
def export(platform, conv, path, fmt, source_path, video,
display="key", seconds=0, layout="unified"):
if platform == "ti99":
return export_rpk(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "coco":
return export_ccc(conv, path, source_path=source_path,
display=display, seconds=seconds)
if platform == "bbc":
return export_ssd(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "coleco":
return export_col(conv, path, source_path=source_path,
display=display, seconds=seconds)
if platform == "a2600":
return export_a26(conv, path, source_path=source_path,
display=display, seconds=seconds)
if platform == "intv":
return export_int(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "vic20":
return export_a0(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "spectrum":
return export_sna(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "a5200":
return export_a52(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "a7800":
return export_a78(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "c128":
return _c128_export_d64(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "c16":
return _c16_export_prg(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "plus4":
return _plus4_export_prg(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "cpc":
return _cpc_export_sna(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "coco3":
return _coco3_export_ccc(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "nes":
return _nes_export_nes(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "iigs":
return _iigs_export_dsk(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform in PET_CFG:
return _pet_export_prg(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "sms":
return _sms_export_sms(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "amiga":
return _amiga_export_adf(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "ansi":
return _ansi_export_ans(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if platform == "apple":
return export_dsk(conv, path, source_path=source_path,
display=display, seconds=seconds)
if platform == "atari":
if fmt == "car" or str(path).lower().endswith(".car"):
return export_car(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
return export_atr(conv, path, source_path=source_path,
display=display, seconds=seconds, video=video)
if fmt == "crt" or str(path).lower().endswith(".crt"):
return export_cart(conv, path, source_path=source_path, video=video,
display=display, seconds=seconds)
return export_disk(conv, path, disk_format=fmt, source_path=source_path,
video=video, display=display, seconds=seconds, layout=layout)
def has_emulator(platform: str) -> bool:
if platform == "ansi":
return False # a text file has nothing to "run"
return mame.have_mame()
def run_emulator(platform, path, mode, standard):
"""Launch the generated image under MAME (every platform uses MAME)."""
pal = standard != "ntsc"
if platform == "ti99":
# cartridge auto-listed on the TI menu as item 2.
return mame.launch("ti99_4a", [("cart", path)], autoboot="2",
autoboot_delay=5)
if platform == "apple":
machine = "apple2ee" if mode in _APPLE_IIE_MODES else "apple2p"
return mame.launch(machine, [("flop1", path)], throttle=False)
if platform == "coco":
# Color Computer 2; the Program Pak autostarts at $C000.
return mame.launch("coco2b", [("cart", path)])
if platform == "bbc":
# BBC Model B; a Lua script types *RUN PIC once DFS has booted.
return mame.launch("bbcb", [("flop1", path)],
autoboot_script_text=mame.bbc_autorun_lua())
if platform == "coleco":
# ColecoVision; the cartridge autostarts (the same .col runs on the Adam).
return mame.launch("coleco", [("cart", path)])
if platform == "a2600":
# interlace flickers two banks at 60Hz -> needs real-time, not warp.
return mame.launch("a2600", [("cart", path)], throttle=(mode == "pf_il"))
if platform == "intv":
return mame.launch("intv", [("cart", path)])
if platform == "vic20":
# The autostart cartridge programs the VIC itself. MAME raises an
# "imperfect emulation" warning the user dismisses with a key.
return mame.launch("vic20p" if pal else "vic20", [("cart", path)])
if platform == "spectrum":
# The .sna snapshot bakes the picture into screen RAM + an idle loop, so
# it appears instantly; the ULA holds it with no further CPU work.
return mame.launch("spectrum", [("snapshot", path)])
if platform == "a5200":
# The cartridge programs ANTIC/GTIA directly; the BIOS jumps straight in.
return mame.launch("a5200", [("cart", path)])
if platform == "a7800":
# The cartridge programs MARIA directly; the BIOS validates + jumps in.
return mame.launch("a7800", [("cart", path)])
if platform == "c128":
# Disk holds a viewer that drives the 80-column VDC; RUN"PIC" loads + runs
# it. The image appears on the VDC (80-col) screen. -nothrottle for the load.
return mame.launch("c128", [("flop", path)], autoboot='run"pic"\\n',
autoboot_delay=5, throttle=False)
if platform == "c16":
# quickload loads the PRG into RAM; a Lua script SYSes the viewer (MAME's
# c16 quickload doesn't set BASIC pointers, so RUN won't, but SYS does).
return mame.launch("c16", [("quik", path)],
autoboot_script_text=mame.c16_autorun_lua(),
autoboot_delay=4, throttle=False)
if platform == "plus4":
# Same TED + BASIC 3.5 as the C16, so the identical .prg + SYS autorun runs
# on the Plus/4 (just a different MAME machine, with 64K RAM).
return mame.launch("plus4", [("quik", path)],
autoboot_script_text=mame.c16_autorun_lua(),
autoboot_delay=4, throttle=False)
if platform == "cpc":
# The .sna snapshot bakes the screen, palette, mode and an idle CPU, so
# the picture appears instantly with no loader (like the ZX Spectrum).
return mame.launch("cpc6128", [("snapshot", path)])
if platform == "coco3":
# The cartridge autostarts a 6809 viewer that programs the GIME; a Lua
# script forces the RGB monitor (MAME defaults to the composite palette).
return mame.launch("coco3", [("cart", path)],
autoboot_script_text=mame.coco3_rgb_lua())
if platform == "nes":
# The cartridge resets into a 6502 viewer that programs the PPU; MAME
# shows an "imperfect graphics" warning the user dismisses with a key.
return mame.launch("nes", [("cart", path)])
if platform == "iigs":
# Boots a 5.25" disk (slot 6) into a loader that fills the Super Hi-Res
# screen ($E1) and turns SHR on. The 32K load takes ~30s; -nothrottle
# makes it appear as fast as possible.
return mame.launch("apple2gs", [("flop1", path)], throttle=False)
if platform in PET_CFG:
# quickload the .prg; a Lua script SYSes the viewer, which pokes the
# quadrant-block screen codes into the PET screen RAM at $8000.
return mame.launch(PET_CFG[platform][0], [("quik", path)],
autoboot_script_text=mame.pet_autorun_lua(),
autoboot_delay=3)
if platform == "sms":
# The cartridge resets into a Z80 viewer that programs the VDP and
# uploads tiles/name table/palette, then idles.
return mame.launch("sms", [("cart", path)])
if platform == "amiga":
# Bootable floppy: a 68000 boot block loads the bitplanes into chip RAM
# and points the Copper at them. MAME shows an "imperfect graphics"
# warning the user dismisses with a key.
return mame.launch("a500", [("flop", path)])
if platform == "atari":
# a800xl(p): the XL ROMs only raise MAME's soft "imperfect" notice, not the
# bad-dump notice the plain Atari 800 OS-B ROM triggers. The .atr self-boots.
machine = "a800xlp" if pal else "a800xl"
if str(path).lower().endswith(".car"):
return mame.launch(machine, [("cart", path)], throttle=False)
return mame.launch(machine, [("flop1", path)], throttle=False)
# C64: attach the .d64 to the 1541, then LOAD"*",8,1 + RUN. Run unthrottled so
# the (cycle-accurate, slow) 1541 load finishes fast; keep real-time for the
# interlace mode whose 50 Hz field-flip must not run faster than the display.
machine = "c64p" if pal else "c64"
if str(path).lower().endswith(".crt"):
# The exported .crt is one 16K CHIP (VICE's format); MAME needs two 8K
# CHIPs, so re-pack the ROM into a temp .crt just for the preview.
rom = crt.read_rom(path)
fd, mpath = tempfile.mkstemp(suffix=".crt", prefix="lenser_mame_")
os.close(fd)
crt.write_crt(rom, mpath, split=True)
return mame.launch(machine, [("cart", mpath)], # cartridge autostarts
throttle=(mode == "interlace"))
# Disk: a Lua script types LOAD, waits for the stock-1541 load to finish,
# then RUN (a single autoboot command loses the RUN during the slow load).
return mame.launch(machine, [("flop", path)],
autoboot_script_text=mame.c64_autorun_lua(),
throttle=(mode == "interlace"))