feat: read the lexaloffle launcher for batocera

Batocera's lexaloffleGenerator runs bios/pico-8/pico8 and
bios/voxatron/vox, raises when the binary is missing or not
executable, and puts each folder on LD_LIBRARY_PATH. The new
extractor records both per system from the AST, with the lines
of batocera-43.1 that say it.
This commit is contained in:
Abdessamad Derraz committed 2026-11-09 17:06:09 +01:00
1 parent 63d94b9415
commit fed8bdddd8
4 files changed
+378 -3

No files matched your search

+1 -1
View File
@@ -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
+46
View File
@@ -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
+148 -1
View File
@@ -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 == "<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 <test>: 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,
)
+183 -1
View File
@@ -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(", ")