First public commit.

This commit is contained in:
The Dust Council 2026-07-03 19:35:35 -07:00
parent 2a48f52979
commit 4bac9d83ed
288 changed files with 18417 additions and 1076 deletions

188
lenser/mame.py Normal file
View file

@ -0,0 +1,188 @@
"""Shared MAME launcher used by every platform.
All targets (C64, Atari, Apple, TI-99/4A, and any future machine) run under MAME,
so the launch flags, autoboot handling, and a reliable hard-kill live here in one
place.
Note on MAME's startup warning screen: machines whose ROMs MAME flags as imperfect
or bad dumps (e.g. the Atari OS) show a one-key "known problems with this system"
notice that `-skip_gameinfo` does NOT remove (it only skips the system-info
screen). That notice is dismissed with a single keypress by whoever is watching the
window; we cannot and do not try to defeat it programmatically.
"""
from __future__ import annotations
import os
import shutil
import signal
import subprocess
import tempfile
# Common flags: windowed, cropped to the screen (no cabinet bezel), no info screen.
BASE_FLAGS = ["-window", "-nomaximize", "-artwork_crop", "-skip_gameinfo"]
def have_mame() -> bool:
return shutil.which("mame") is not None
def _default_screen() -> str | None:
"""Which host monitor MAME's window should open on (MAME's -screen value).
MAME (SDL) names monitors screen0, screen1, ... in host display order. We
prefer to throw the test window onto a *secondary* monitor so it doesn't
cover the user's primary display. Override with LENSER_MAME_SCREEN (e.g.
"screen0" to force the primary, or "" to let MAME choose); unset auto-picks
the second monitor when one is connected, else leaves it to MAME.
"""
env = os.environ.get("LENSER_MAME_SCREEN")
if env is not None:
return env or None
try:
out = subprocess.run(["xrandr", "--query"], capture_output=True,
text=True, timeout=5).stdout
connected = sum(1 for ln in out.splitlines() if " connected" in ln)
if connected >= 2:
return "screen1"
except (OSError, subprocess.SubprocessError):
pass
return None
def c64_autorun_lua(load_cmd: str = 'load "*",8,1') -> str:
"""Lua autoboot script that types ``load_cmd``, waits for the (slow, stock
1541) load to finish, then types RUN.
A single -autoboot_command "load ...\\nrun\\n" doesn't work: the RUN is typed
up front and lost while the drive loads. Here we post the LOAD, watch the
cursor-blink flag $CC (0 at the READY prompt, non-zero while BASIC is busy)
go busy then idle, and only then post RUN. ($CC read via the C64 CPU ":u7".)
"""
return f'''local mem = manager.machine.devices[":u7"].spaces["program"]
local nk = manager.machine.natkeyboard
local phase, t = 0, 0
emu.register_periodic(function()
local now = manager.machine.time.seconds
if phase == 0 then
if now > 2.0 then nk:post('{load_cmd}\\n'); phase, t = 1, now end
elseif phase == 1 then
if now > t + 0.5 and mem:read_u8(0xCC) ~= 0 then phase = 2 end
elseif phase == 2 then
if mem:read_u8(0xCC) == 0 then nk:post('run\\n'); phase = 3 end
end
end)
'''
def pet_autorun_lua(addr: int = 1056) -> str:
"""Lua autoboot for the PET: SYS the viewer (at $0420 = 1056) once BASIC is
ready. Quickload puts the program in RAM but doesn't set BASIC's pointers, so
SYS (not RUN) reliably starts the machine-language viewer."""
return f'''local nk = manager.machine.natkeyboard
local done = false
emu.register_periodic(function()
if (not done) and manager.machine.time.seconds > 3.0 then
nk:post("sys{addr}\\n"); done = true
end
end)
'''
def bbc_autorun_lua(command: str = "*RUN PIC") -> str:
"""Lua autoboot for the BBC: type ``command`` (CR-terminated) once the OS has
booted. natkeyboard:post is reliable where -autoboot_command isn't (the '!'
in !BOOT needs SHIFT and a single typed command races the warning screen)."""
return f'''local done = false
emu.register_periodic(function()
if (not done) and manager.machine.time.seconds > 3.0 then
manager.machine.natkeyboard:post("{command}\\r"); done = true
end
end)
'''
def c16_autorun_lua(addr: int = 4128) -> str:
"""Lua autoboot for the C16: SYS the viewer once BASIC is ready. MAME's c16
quickload copies the PRG into RAM but doesn't set BASIC's program pointers, so
RUN finds nothing -- but the machine-language viewer is in RAM, so SYS to its
entry ($1020 = 4128) starts it."""
return f'''local nk = manager.machine.natkeyboard
local done = false
emu.register_periodic(function()
if (not done) and manager.machine.time.seconds > 3.0 then
nk:post("sys{addr}\\n"); done = true
end
end)
'''
def coco3_rgb_lua() -> str:
"""Lua that flips the CoCo 3's 'Monitor Type' config from Composite (the MAME
default, an artifact-colour palette) to RGB, so the GIME shows the clean
digital RGB palette the encoder targets. (The cartridge still autostarts.)"""
return '''local done = false
emu.register_periodic(function()
if not done then
local p = manager.machine.ioport.ports[":screen_config"]
if p and p.fields["Monitor Type"] then
p.fields["Monitor Type"]:set_value(1); done = true
end
end
end)
'''
def launch(machine, media, autoboot=None, autoboot_script_text=None,
autoboot_delay=3, throttle=True, screen="auto"):
"""Start MAME on `machine`.
media list of (slot, path) pairs, e.g. [("flop", disk)].
autoboot keystrokes typed after `autoboot_delay` seconds. MAME wants
the literal escape "\\n" (backslash+n) for RETURN.
autoboot_script_text Lua driven startup (used for the C64 LOAD-then-RUN dance);
written to a temp file and passed as -autoboot_script.
throttle False adds -nothrottle so slow emulated disk loads finish
quickly (fine for a static image; leave True for real-time).
screen host monitor for MAME's window. "auto" (default) puts it on
a secondary monitor when present (see _default_screen / the
LENSER_MAME_SCREEN env); a literal "screenN" forces one;
None lets MAME decide.
"""
exe = shutil.which("mame")
if not exe:
raise RuntimeError("MAME was not found on PATH (sudo apt install mame).")
cmd = [exe, machine, *BASE_FLAGS]
if screen == "auto":
screen = _default_screen()
if screen:
cmd += ["-screen", screen]
for slot, path in media:
cmd += [f"-{slot}", os.path.abspath(path)]
if not throttle:
cmd.append("-nothrottle")
if autoboot_script_text:
fd, sp = tempfile.mkstemp(suffix=".lua", prefix="lenser_")
with os.fdopen(fd, "w") as f:
f.write(autoboot_script_text)
cmd += ["-autoboot_delay", str(autoboot_delay), "-autoboot_script", sp]
elif autoboot:
cmd += ["-autoboot_delay", str(autoboot_delay), "-autoboot_command", autoboot]
# start_new_session => MAME leads its own process group, so kill() can take the
# whole group (MAME spawns helper threads/children) with one signal.
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
def kill(proc):
"""Force a running MAME to close. MAME can ignore a polite SIGTERM while it
owns the display, so go straight to SIGKILL on its process group."""
if proc is None or proc.poll() is not None:
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
try:
proc.kill()
except ProcessLookupError:
pass