"""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