diff --git a/emulators/lexaloffle.yml b/emulators/lexaloffle.yml index 9404f39b1..1fa22616a 100644 --- a/emulators/lexaloffle.yml +++ b/emulators/lexaloffle.yml @@ -214,7 +214,7 @@ files: required: true description: "Voxatron main executable" source_ref: "batocera lexaloffleGenerator.py:20, 42, 48-52" - note: "The Voxatron program, an x86_64 ELF with SDL2 linked in. Batocera's generator runs bios/voxatron/vox and raises when it is missing or not executable." + note: "The Voxatron program vox, an x86_64 ELF with SDL2 linked in. Batocera's generator runs bios/voxatron/vox and raises when it is missing or not executable." - name: vox_dyn path: voxatron/vox_dyn diff --git a/platforms/batocera.yml b/platforms/batocera.yml index 636ec81af..0d55954da 100644 --- a/platforms/batocera.yml +++ b/platforms/batocera.yml @@ -6508,6 +6508,52 @@ launchers: fails_when_missing: false depends_on_option: gsplus_bios_filename source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/gsplus/gsplusGenerator.py:105" +- emulator: lexaloffle + build: standalone + kind: fixed_name + slot: pico8 + names: + - pico-8/pico8 + case_insensitive: false + search_dir: /userdata/bios + passes_to: exec + fails_when_missing: Lexaloffle official binary not found at /userdata/bios/pico-8/pico8, + /userdata/bios/pico-8/pico8 is not set as executable + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/lexaloffle/lexaloffleGenerator.py:17,\ + \ 35, 37, 48-55" +- emulator: lexaloffle + build: standalone + kind: passes_path + slot: pico8 + names: [] + search_dir: /userdata/bios/pico-8 + passes_to: environment LD_LIBRARY_PATH + fails_when_missing: false + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/lexaloffle/lexaloffleGenerator.py:35-36,\ + \ 89" +- emulator: lexaloffle + build: standalone + kind: fixed_name + slot: voxatron + names: + - voxatron/vox + case_insensitive: false + search_dir: /userdata/bios + passes_to: exec + fails_when_missing: Lexaloffle official binary not found at /userdata/bios/voxatron/vox, + /userdata/bios/voxatron/vox is not set as executable + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/lexaloffle/lexaloffleGenerator.py:20,\ + \ 40, 42, 48-55" +- emulator: lexaloffle + build: standalone + kind: passes_path + slot: voxatron + names: [] + search_dir: /userdata/bios/voxatron + passes_to: environment LD_LIBRARY_PATH + fails_when_missing: false + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/lexaloffle/lexaloffleGenerator.py:40-41,\ + \ 89" - emulator: flycast build: standalone kind: passes_path diff --git a/scripts/scraper/batocera_launchers.py b/scripts/scraper/batocera_launchers.py index eb11b1489..ea6fc69aa 100644 --- a/scripts/scraper/batocera_launchers.py +++ b/scripts/scraper/batocera_launchers.py @@ -647,6 +647,153 @@ def gsplus(cg: Configgen) -> list[Launcher]: )] +def _system_branches(scope: ast.FunctionDef) -> list[tuple[str, ast.If]]: + """(system, branch) for each `if system.name == "":` of an if/elif chain.""" + branches: list[tuple[str, ast.If]] = [] + for node in ast.walk(scope): + if not isinstance(node, ast.If): + continue + test = node.test + if ( + isinstance(test, ast.Compare) + and ast.unparse(test.left) == "system.name" + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) + and isinstance(test.comparators[0], ast.Constant) + ): + branches.append((str(test.comparators[0].value), node)) + return sorted(branches, key=lambda item: item[1].lineno) + + +def _guard_message( + src: Source, scope: ast.FunctionDef, test: Callable[[ast.expr], bool], what: str +) -> tuple[ast.If, ast.JoinedStr]: + """The `if not : raise X(f"...")` statement and its message.""" + found = [ + node for node in ast.walk(scope) + if isinstance(node, ast.If) + and isinstance(node.test, ast.UnaryOp) + and isinstance(node.test.op, ast.Not) + and test(node.test.operand) + and isinstance(node.body[0], ast.Raise) + and isinstance(node.body[0].exc, ast.Call) + and node.body[0].exc.args + and isinstance(node.body[0].exc.args[0], ast.JoinedStr) + ] + if len(found) != 1: + src.fail(f"expected one `if not {what}: raise` with a message, found {len(found)}") + return found[0], found[0].body[0].exc.args[0] # type: ignore[attr-defined, no-any-return] + + +def _render(message: ast.JoinedStr, var: str, value: str) -> str: + parts = [] + for part in message.values: + if isinstance(part, ast.Constant): + parts.append(str(part.value)) + elif isinstance(part.value, ast.Name) and part.value.id == var: # type: ignore[attr-defined] + parts.append(value) + else: + parts.append(f"{{{ast.unparse(part.value)}}}") # type: ignore[attr-defined] + return "".join(parts) + + +def lexaloffle(cg: Configgen) -> list[Launcher]: + """Per system, a binary under BIOS run as the command, its folder on LD_LIBRARY_PATH.""" + module = "lexaloffle.lexaloffleGenerator" + src = cg.generator(module) + constants = dict(cg.paths) + defined: dict[str, ast.stmt] = {} + for node in src.tree.body: + name = _assigned_name(node) + value = _path_value(getattr(node, "value", None), constants) + if name is not None and value is not None: + constants[name] = value + defined[name] = node + generate = src.function("generate") + + command = [ + node for node in ast.walk(generate) + if _assigned_name(node) == "commandArray" + and isinstance(node.value, ast.List) # type: ignore[attr-defined] + and node.value.elts # type: ignore[attr-defined] + and isinstance(node.value.elts[0], ast.Name) # type: ignore[attr-defined] + ] + if len(command) != 1: + src.fail("generate no longer starts commandArray with the binary it runs") + binary = command[0].value.elts[0].id # type: ignore[attr-defined] + + exists, missing = _guard_message(src, generate, lambda test: ( + isinstance(test, ast.Call) + and isinstance(test.func, ast.Attribute) + and test.func.attr == "exists" + and ast.unparse(test.func.value) == binary + ), f"{binary}.exists()") + access, not_executable = _guard_message(src, generate, lambda test: ( + isinstance(test, ast.Call) + and ast.unparse(test.func) == "os.access" + and [ast.unparse(a) for a in test.args] == [binary, "os.X_OK"] + ), f"os.access({binary}, os.X_OK)") + + env = [ + (key, value) + for call in _calls(generate, attr="Command") + for keyword in call.keywords + if keyword.arg == "env" and isinstance(keyword.value, ast.Dict) + for key, value in zip(keyword.value.keys, keyword.value.values) + if isinstance(key, ast.Constant) and key.value == "LD_LIBRARY_PATH" + ] + if len(env) != 1: + src.fail("the command environment no longer sets LD_LIBRARY_PATH once") + env_key, env_value = env[0] + + prefix = cg.bios.rstrip("/") + "/" + launchers = [] + for system, branch in _system_branches(generate): + bound = { + name: (stmt, _path_value(stmt.value, constants)) # type: ignore[attr-defined] + for stmt in branch.body if (name := _assigned_name(stmt)) is not None + } + if binary not in bound or not (bound[binary][1] or "").startswith(prefix): + src.fail(f"the {system} branch binds {binary} to no path under {cg.bios}") + library = [ + name for name in bound + if name != binary and _mentions(env_value, set(), {name}) + ] + if len(library) != 1 or not (bound[library[0]][1] or "").startswith(prefix): + src.fail(f"the {system} branch puts no /userdata/bios folder on LD_LIBRARY_PATH") + binary_stmt, path = bound[binary] + library_stmt, folder = bound[library[0]] + test_line = src.span(branch.test) + constant = next( + (defined[n.id] for n in ast.walk(binary_stmt.value) # type: ignore[attr-defined] + if isinstance(n, ast.Name) and n.id in defined), + None, + ) + spans = [test_line, src.span(binary_stmt), src.span(exists), src.span(access), + src.span(command[0])] + if constant is not None: + spans.append(src.span(constant)) + launchers.append(Launcher( + emulator=cg.emulator(module), build="standalone", kind="fixed_name", + slot=system, names=[path[len(prefix):]], # type: ignore[index] + case_insensitive=False, search_dir=cg.bios, passes_to="exec", + fails_when_missing=", ".join( + _render(message, binary, path) for message in (missing, not_executable) # type: ignore[arg-type] + ), + source_ref=src.ref(spans), + )) + launchers.append(Launcher( + emulator=cg.emulator(module), build="standalone", kind="passes_path", + slot=system, names=[], search_dir=folder, + passes_to="environment LD_LIBRARY_PATH", + source_ref=src.ref([test_line, src.span(library_stmt), + (src.span(env_key)[0], src.span(env_value)[1])]), + )) + if not launchers: + src.fail("generate no longer branches on system.name") + return launchers + + # Generators that hand a directory of /userdata/bios to the emulator through # its environment, with the module holding their path constants. ENVIRONMENT_GENERATORS = (("flycast.flycastGenerator", "flycast/flycastPaths.py"),) @@ -741,7 +888,7 @@ def core_file_options(cg: Configgen) -> list[Launcher]: EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus, - x16emu, gsplus, environment_directories, core_file_options, + x16emu, gsplus, lexaloffle, environment_directories, core_file_options, ) diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 12a7d06ff..838704d04 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -32,6 +32,7 @@ from scraper.batocera_launchers import ( # noqa: E402 duckstation, extract_launchers, hatari, + lexaloffle, ) from scraper.retropie_launchers import Launcher as PackageLauncher # noqa: E402 @@ -57,6 +58,7 @@ _GENERATOR_MAP: Final[dict[str, tuple[str, str]]] = { DUCKSTATION = f"{GENERATORS}/duckstation/duckstationGenerator.py" HATARI = f"{GENERATORS}/hatari/hatariGenerator.py" +LEXALOFFLE = f"{GENERATORS}/lexaloffle/lexaloffleGenerator.py" DUCKSTATION_HEAD = '''from __future__ import annotations @@ -209,6 +211,102 @@ HATARI_FIND = ''' @staticmethod raise BatoceraException(f"No bios found for machine {machine}")''' +# lexaloffleGenerator.py, whole file +LEXALOFFLE_GENERATOR = '''from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Final + +from ... import Command +from ...batoceraPaths import BIOS, HOME, ROMS, SCREENSHOTS, ensure_parents_and_open +from ...controller import generate_sdl_game_controller_config +from ...exceptions import BatoceraException +from ..Generator import Generator + +if TYPE_CHECKING: + from pathlib import Path + + from ...types import HotkeysContext + +PICO8_BIN_PATH: Final = BIOS / "pico-8" / "pico8" +PICO8_ROOT_PATH: Final = ROMS / "pico8" +PICO8_CONTROLLERS: Final = HOME / ".lexaloffle" / "pico-8" / "sdl_controllers.txt" +VOX_BIN_PATH: Final = BIOS / "voxatron" / "vox" +VOX_ROOT_PATH: Final = ROMS / "voxatron" +VOX_CONTROLLERS: Final = HOME / ".lexaloffle" / "Voxatron" / "sdl_controllers.txt" + + +# Generator for the official pico8 binary from Lexaloffle +class LexaloffleGenerator(Generator): + + def getHotkeysContext(self) -> HotkeysContext: + return { + "name": "lexaloffle", + "keys": { "exit": ["KEY_LEFTCTRL", "KEY_Q"], "menu": "KEY_ENTER", "reset": [ "KEY_LEFTCTRL", "KEY_R" ] } + } + + def generate(self, system, rom, playersControllers, metadata, guns, wheels, gameResolution): + if (system.name == "pico8"): + LD_LIB=BIOS / "pico-8" + BIN_PATH=PICO8_BIN_PATH + CONTROLLERS=PICO8_CONTROLLERS + ROOT_PATH=PICO8_ROOT_PATH + elif (system.name == "voxatron"): + LD_LIB=BIOS / "voxatron" + BIN_PATH=VOX_BIN_PATH + CONTROLLERS=VOX_CONTROLLERS + ROOT_PATH=VOX_ROOT_PATH + else: + raise BatoceraException(f"The Lexaloffle generator has been called for an unknwon system: {system.name}.") + + if not BIN_PATH.exists(): + raise BatoceraException(f"Lexaloffle official binary not found at {BIN_PATH}") + + if not os.access(BIN_PATH, os.X_OK): + raise BatoceraException(f"{BIN_PATH} is not set as executable") + + # the command to run + commandArray: list[str | Path] = [BIN_PATH] + commandArray.extend(["-desktop", SCREENSHOTS]) # screenshots + commandArray.extend(["-windowed", "0"]) # full screen + # Display FPS + if system.config.show_fps: + commandArray.extend(["-show_fps", "1"]) + else: + commandArray.extend(["-show_fps", "0"]) + + rombase = rom.stem + + # .m3u support for multi-cart pico-8 + if rom.suffix.lower() == ".m3u": + with rom.open() as fpin: + lines = fpin.readlines() + fullpath = rom.absolute().parent / lines[0].strip() + commandArray.extend(["-root_path", fullpath.parent]) + rom = fullpath + else: + commandArray.extend(["-root_path", ROOT_PATH]) # store carts from splore + + if (rombase.lower() == "splore" or rombase.lower() == "console"): + commandArray.extend(["-splore"]) + else: + commandArray.extend(["-run", rom]) + + controllersconfig = generate_sdl_game_controller_config(playersControllers) + with ensure_parents_and_open(CONTROLLERS, "w") as file: + file.write(controllersconfig) + + existing_library_path = os.environ.get("LD_LIBRARY_PATH") + + return Command.Command(array=commandArray, env={ + "SDL_AUDIODRIVER": "alsa", + "LD_LIBRARY_PATH": f"{LD_LIB}:{existing_library_path}" if existing_library_path else LD_LIB + }) + + def getInGameRatio(self, config, gameResolution, rom): + return 4/3''' + + def placed(blocks: dict[int, str]) -> str: """Each block starting at its line, blank lines between.""" lines: list[str] = [] @@ -242,6 +340,10 @@ def hatari_tree(generate: str = HATARI_GENERATE, find: str = HATARI_FIND) -> Con return configgen({HATARI: placed({1: HATARI_HEAD, 32: generate, 157: find})}) +def lexaloffle_tree(text: str = LEXALOFFLE_GENERATOR) -> Configgen: + return configgen({LEXALOFFLE: text + "\n"}) + + class DuckStationSearch(unittest.TestCase): def setUp(self) -> None: self.records = duckstation(duckstation_tree()) @@ -339,6 +441,86 @@ class HatariTosSearch(unittest.TestCase): hatari(hatari_tree(find=find)) +class LexaloffleBinaries(unittest.TestCase): + def setUp(self) -> None: + self.records = [r.entry() for r in lexaloffle(lexaloffle_tree())] + + def test_each_system_runs_its_binary_and_loads_its_folder(self): + self.assertEqual( + [(r["slot"], r["kind"], r["names"], r["search_dir"], r["passes_to"]) + for r in self.records], + [ + ("pico8", "fixed_name", ["pico-8/pico8"], "/userdata/bios", "exec"), + ("pico8", "passes_path", [], "/userdata/bios/pico-8", + "environment LD_LIBRARY_PATH"), + ("voxatron", "fixed_name", ["voxatron/vox"], "/userdata/bios", "exec"), + ("voxatron", "passes_path", [], "/userdata/bios/voxatron", + "environment LD_LIBRARY_PATH"), + ], + ) + for record in self.records: + self.assertEqual((record["emulator"], record["build"]), ("lexaloffle", "standalone")) + + def test_a_missing_or_non_executable_binary_stops_the_launch(self): + pico8, library, vox, _ = self.records + self.assertEqual( + pico8["fails_when_missing"], + "Lexaloffle official binary not found at /userdata/bios/pico-8/pico8, " + "/userdata/bios/pico-8/pico8 is not set as executable", + ) + self.assertEqual( + vox["fails_when_missing"], + "Lexaloffle official binary not found at /userdata/bios/voxatron/vox, " + "/userdata/bios/voxatron/vox is not set as executable", + ) + self.assertFalse(pico8["case_insensitive"]) + self.assertFalse(library["fails_when_missing"]) + + def test_the_lines_cited_are_the_constant_the_branch_the_checks_and_the_command(self): + self.assertEqual( + [r["source_ref"] for r in self.records], + [ + f"{LEXALOFFLE}:17, 35, 37, 48-55", + f"{LEXALOFFLE}:35-36, 89", + f"{LEXALOFFLE}:20, 40, 42, 48-55", + f"{LEXALOFFLE}:40-41, 89", + ], + ) + + def test_a_binary_without_the_executable_check_raises(self): + text = LEXALOFFLE_GENERATOR.replace("os.access(BIN_PATH, os.X_OK)", "True") + with self.assertRaises(LauncherShapeError): + lexaloffle(lexaloffle_tree(text)) + + def test_a_binary_without_the_existence_check_raises(self): + text = LEXALOFFLE_GENERATOR.replace("BIN_PATH.exists()", "BIN_PATH") + with self.assertRaises(LauncherShapeError): + lexaloffle(lexaloffle_tree(text)) + + def test_a_command_started_another_way_raises(self): + text = LEXALOFFLE_GENERATOR.replace("= [BIN_PATH]", '= ["pico8"]') + with self.assertRaises(LauncherShapeError): + lexaloffle(lexaloffle_tree(text)) + + def test_a_library_path_set_elsewhere_raises(self): + text = LEXALOFFLE_GENERATOR.replace('"LD_LIBRARY_PATH": ', '"LIBRARY_PATH": ') + with self.assertRaises(LauncherShapeError): + lexaloffle(lexaloffle_tree(text)) + + def test_a_binary_outside_bios_raises(self): + text = LEXALOFFLE_GENERATOR.replace( + 'VOX_BIN_PATH: Final = BIOS / "voxatron" / "vox"', + 'VOX_BIN_PATH: Final = Path("/usr/bin/vox")', + ) + with self.assertRaises(LauncherShapeError): + lexaloffle(lexaloffle_tree(text)) + + def test_a_generator_that_no_longer_branches_on_the_system_raises(self): + text = LEXALOFFLE_GENERATOR.replace("system.name ==", "system.emulator ==") + with self.assertRaises(LauncherShapeError): + lexaloffle(lexaloffle_tree(text)) + + class ExtractionNeverEmpty(unittest.TestCase): def test_a_generator_of_unknown_shape_stops_the_whole_scrape(self): find = DUCKSTATION_FIND.replace("def find_bios(", "def locate_bios(") @@ -361,7 +543,7 @@ class PlatformRecords(unittest.TestCase): cls.schema = json.loads((REPO_ROOT / "schemas" / "platform.schema.json").read_text()) def test_every_emulator_is_one_batocera_names(self): - cores = set(self.config["cores"]) + cores = {*self.config["cores"], *self.config.get("standalone_cores", [])} named = { name for record in self.config["launchers"] for name in record["emulator"].split(", ")