mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
chore: merge batocera lexaloffle launcher
This commit is contained in:
commit
0a618d0a05
4 files changed
+378
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(", ")
|
||||
|
||||
Reference in new issue
Block a user