From fe9a081cfb705ba76269f56c7fe686c828df61ae Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:51:16 +0200 Subject: [PATCH] feat: read Recalbox launchers from configgen The scraper now records, from configgen at the pinned tag, the names each Recalbox launcher tries and where it hands them: the Hatari TOS cascades (standalone, hatari and hatarib cores), GSplus, Amiberry kickstarts, the PCSX2 region choice, the Dolphin IPL presence test, xemu, the mupen64plus 64DD IPL, the Mini vMac boot disk and the o2em_bios option. The system_directory libretroRetroarch gives each core, and the per-system overrides after it, are passes_path records. --- scripts/scraper/recalbox_launchers.py | 1191 +++++++++++++++++++ scripts/scraper/recalbox_scraper.py | 25 +- tests/test_launchers_recalbox.py | 1568 +++++++++++++++++++++++++ wiki/adding-a-platform.md | 7 + wiki/testing-guide.md | 2 +- 5 files changed, 2789 insertions(+), 4 deletions(-) create mode 100644 scripts/scraper/recalbox_launchers.py create mode 100644 tests/test_launchers_recalbox.py diff --git a/scripts/scraper/recalbox_launchers.py b/scripts/scraper/recalbox_launchers.py new file mode 100644 index 00000000..36f14835 --- /dev/null +++ b/scripts/scraper/recalbox_launchers.py @@ -0,0 +1,1191 @@ +"""Recalbox's launcher layer, read from the configgen generators. + +Recalbox's configgen decides which file name each emulator is given and +where it is handed over: Hatari's generator walks a TOS cascade per machine, +PCSX2's picks a BIOS by the region token of the rom name, and for every +libretro core libretroRetroarch chooses the directory RetroArch receives as +system_directory. That is the platform's behaviour, so it is recorded here +with the lines of the pinned tag that say it. + +Each extractor reads one known shape with ast and raises LauncherShapeError +when the shape is gone, as the Batocera reader does. +""" + +from __future__ import annotations + +import ast +import posixpath +import re +from collections.abc import Callable, Mapping +from typing import Any + +from .configgen_launchers import ( + Launcher, + LauncherShapeError, + Source, + _assigned_name, + _calls, + _enclosing_test, + _innermost_function, + _locate, +) + +CONFIGGEN = "projects/configgen/configgen" +GENERATORS = f"{CONFIGGEN}/generators" +FILES_MODULE = f"{CONFIGGEN}/recalboxFiles.py" +LAUNCHER = f"{CONFIGGEN}/emulatorlauncher.py" +RETROARCH = f"{GENERATORS}/libretro/libretroRetroarch.py" +CORES = f"{GENERATORS}/libretro/libretroCores.py" +LIBRETRO_GENERATOR = f"{GENERATORS}/libretro/libretroGenerator.py" +HATARI = f"{GENERATORS}/hatari/hatariGenerator.py" +GSPLUS = f"{GENERATORS}/gsplus/gsplusGenerator.py" +AMIBERRY_GENERATOR = f"{GENERATORS}/amiberry/amiberryGenerator.py" +AMIBERRY_KICKSTARTS = f"{GENERATORS}/amiberry/amiberryKickstarts.py" +AMIBERRY_SUBSYSTEMS = f"{GENERATORS}/amiberry/amiberrySubSystems.py" +AMIBERRY_CONFIG = f"{GENERATORS}/amiberry/amiberryConfig.py" +PCSX2 = f"{GENERATORS}/pcsx2/pcsx2Generator.py" +DOLPHIN = f"{GENERATORS}/dolphin/dolphinGenerator.py" +XEMU = f"{GENERATORS}/xemu/xemuGenerator.py" +MUPEN_GENERATOR = f"{GENERATORS}/mupen/mupenGenerator.py" +MUPEN_CONFIG = f"{GENERATORS}/mupen/mupenConfig.py" + +SOURCE_FILES = ( + FILES_MODULE, LAUNCHER, RETROARCH, CORES, LIBRETRO_GENERATOR, HATARI, GSPLUS, + AMIBERRY_KICKSTARTS, AMIBERRY_SUBSYSTEMS, AMIBERRY_CONFIG, PCSX2, DOLPHIN, XEMU, + MUPEN_CONFIG, +) + +# Recalbox's system.ini runs every libretro core as `emulator = libretro`: +# the name a record carries when the code applies to any core. +ALL_CORES = "libretro" +SYSTEM_PLACEHOLDER = "" +SYSTEM_NAMES = ("self.system.Name", "system.Name") +SYSTEM_DIRECTORY = "system_directory" +CORE_ASSETS = "core_assets_directory" + +# libretroCores functions that build a TOS path through a cascade. +TOS_FUNCTIONS = ("configureAtariST", "configureAtariSTNew") +# libretro core options whose value names the file the core opens. +FILE_OPTIONS = {"o2em_bios": "o2em"} + +# A name the core resolves itself, such as hatarib's . +BUILTIN_TOKEN = re.compile(r"<[^<>/]+>") + + +def spell(node: ast.AST | None, env: Mapping[str, str]) -> str | None: + """The string an expression always evaluates to, if it can be read.""" + if isinstance(node, ast.Constant): + return node.value if isinstance(node.value, str) else None + if isinstance(node, (ast.Name, ast.Attribute)): + return env.get(ast.unparse(node)) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left, right = spell(node.left, env), spell(node.right, env) + return None if left is None or right is None else left + right + if isinstance(node, ast.JoinedStr): + parts = [] + for value in node.values: + if isinstance(value, ast.FormattedValue): + if value.conversion != -1 or value.format_spec is not None: + return None + value = value.value + part = spell(value, env) + if part is None: + return None + parts.append(part) + return "".join(parts) + if isinstance(node, ast.Call) and not node.keywords: + args = [spell(arg, env) for arg in node.args] + if any(arg is None for arg in args): + return None + func = ast.unparse(node.func) + if func == "os.path.join" and args: + return posixpath.join(*args) + if func == "str" and len(args) == 1: + return args[0] + return None + + +def module_strings(source: Source, prefix: str) -> dict[str, str]: + """Module-level string constants, keyed as `prefix.NAME`.""" + local: dict[str, str] = {} + for node in source.tree.body: + name = _assigned_name(node) + if name is not None: + value = spell(node.value, local) + if value is not None: + local[name] = value + return {f"{prefix}.{name}": value for name, value in local.items()} + + +def _class_strings(source: Source, env: dict[str, str]) -> dict[str, Any]: + """Class attributes readable as strings or as tuples of strings.""" + found: dict[str, Any] = {} + for cls in source.tree.body: + if not isinstance(cls, ast.ClassDef): + continue + # The class body reads its own attributes by their bare names. + bare: dict[str, str] = {} + for node in cls.body: + name = _assigned_name(node) + if name is None: + continue + scope = {**env, **found, **bare} + value: Any = spell(node.value, scope) + if value is None and isinstance(node.value, ast.Tuple): + items = [spell(e, scope) for e in node.value.elts] + value = items if all(isinstance(i, str) for i in items) else None + if value is None: + continue + if isinstance(value, str): + bare[name] = value + for owner in ("self", cls.name): + found[f"{owner}.{name}"] = value + return found + + +def _bound_once(scope: ast.AST, env: dict[str, str]) -> dict[str, str]: + """Names a function binds exactly once to a readable string.""" + counts: dict[str, int] = {} + nodes = [] + for node in ast.walk(scope): + name = _assigned_name(node) + if name is not None: + counts[name] = counts.get(name, 0) + 1 + nodes.append(node) + local: dict[str, str] = {} + for node in sorted(nodes, key=lambda n: n.lineno): + name = _assigned_name(node) + if name is not None and counts[name] == 1: + value = spell(node.value, {**env, **local}) + if value is not None: + local[name] = value + return local + + +def launcher_names(source: Source) -> dict[str, list[str]]: + """Generator module -> the emulator names getGenerator loads it for.""" + names: dict[str, list[str]] = {} + for node in ast.walk(source.function("getGenerator")): + if not ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.ops[0], ast.Eq) + and isinstance(node.test.comparators[0], ast.Constant) + ): + continue + for call in _calls(ast.Module(body=node.body, type_ignores=[]), name="__import__"): + if call.args and isinstance(call.args[0], ast.Constant): + names.setdefault(call.args[0].value, []).append(node.test.comparators[0].value) + if not names: + source.fail("getGenerator imports no generator module") + return names + + +def _handler_map(source: Source, function: str, table: str) -> dict[str, list[str]]: + """Function name -> the keys a dispatch table of `function` sends to it.""" + node = source.assignment(table, source.function(function)).value + if not isinstance(node, ast.Dict): + source.fail(f"{table} is not a dict display") + handlers: dict[str, list[str]] = {} + for key, value in zip(node.keys, node.values): + if isinstance(key, ast.Constant) and isinstance(value, ast.Attribute): + handlers.setdefault(value.attr, []).append(key.value) + if not handlers: + source.fail(f"{table} sends nothing to a handler") + return handlers + + +class Configgen: + """Recalbox's configgen at one tag, read through `read(path)`.""" + + def __init__(self, read: Callable[[str], str]) -> None: + self._read = read + self._sources: dict[str, Source] = {} + files = self.source(FILES_MODULE) + self.files = module_strings(files, "recalboxFiles") + if "recalboxFiles.BIOS" not in self.files: + files.fail("no BIOS path constant") + self.bios = self.files["recalboxFiles.BIOS"] + self._emulators = launcher_names(self.source(LAUNCHER)) + + def source(self, path: str) -> Source: + if path not in self._sources: + self._sources[path] = Source(path, self._read(path)) + return self._sources[path] + + def emulator(self, path: str) -> str: + """The emulator names getGenerator loads the generator at `path` for.""" + module = "configgen." + path.removeprefix(f"{CONFIGGEN}/").removesuffix(".py") + module = module.replace("/", ".") + if module not in self._emulators: + raise LauncherShapeError(f"{LAUNCHER}: no emulator loads {module}") + return ", ".join(self._emulators[module]) + + def core(self, function: str) -> str: + """The libretro cores fillCoresConfiguration hands to `function`.""" + cores = _handler_map(self.source(CORES), "fillCoresConfiguration", "specificCoreHandlers") + if function not in cores: + self.source(CORES).fail(f"no core is configured by {function}") + return ", ".join(cores[function]) + + def env(self, source: Source, scope: ast.AST | None = None) -> dict[str, Any]: + """recalboxFiles, the module's class attributes and the strings + `scope` binds once, with the system name left as a placeholder.""" + env: dict[str, Any] = dict(self.files) + env.update(_class_strings(source, env)) + env.update({name: SYSTEM_PLACEHOLDER for name in SYSTEM_NAMES}) + if scope is not None: + env.update(_bound_once(scope, env)) + return env + + def under_bios(self, value: str | None) -> str | None: + """`value` relative to the BIOS directory, or None outside it.""" + if value is None: + return None + value = value.strip("'\"") + prefix = self.bios.rstrip("/") + "/" + return value[len(prefix):] if value.startswith(prefix) else None + + def bios_dir(self, value: str | None, source: Source, what: str) -> str: + if value is None or not (value == self.bios or self.under_bios(value) is not None): + source.fail(f"{what} is not a directory under {self.bios}") + return value.rstrip("/") + + +def _setter(call: ast.Call, key: str, attr: str = "setString") -> bool: + return ( + isinstance(call.func, ast.Attribute) + and call.func.attr == attr + and bool(call.args) + and isinstance(call.args[0], ast.Constant) + and call.args[0].value == key + ) + + +def _expr_call(stmt: ast.stmt) -> ast.Call | None: + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + return stmt.value + return None + + +def _systems(test: ast.expr, lists: Mapping[str, list[str]]) -> list[str] | None: + """The systems a `system.Name == x`, `or` or `in list` test accepts.""" + if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.Or): + parts = [_systems(value, lists) for value in test.values] + if any(part is None for part in parts): + return None + return [system for part in parts for system in part] + if ( + isinstance(test, ast.Compare) + and len(test.ops) == 1 + and ast.unparse(test.left) in SYSTEM_NAMES + ): + right = test.comparators[0] + if isinstance(test.ops[0], ast.Eq) and isinstance(right, ast.Constant): + return [right.value] + if isinstance(test.ops[0], ast.In) and isinstance(right, ast.Name) and right.id in lists: + return list(lists[right.id]) + return None + + +def _flag(test: ast.expr) -> str | None: + """X for a bare `self.system.X` test.""" + if isinstance(test, ast.Attribute) and ast.unparse(test.value) == "self.system": + return test.attr + return None + + +def _condition(source: Source, tests: list[ast.expr], negated: bool, + lists: Mapping[str, list[str]]) -> str: + """A branch condition in words, for the taken branch or the else.""" + systems: list[str] = [] + flags: list[str] = [] + for test in tests: + found = _systems(test, lists) + flag = _flag(test) + if found is not None: + systems += found + elif flag is not None: + flags.append(flag) + else: + source.fail(f"line {test.lineno}: branch test {ast.unparse(test)} is not read") + words = [] + if systems: + words.append(("system other than " if negated else "system ") + ", ".join(systems)) + if flags: + words.append(("without system flag " if negated else "system flag ") + ", ".join(flags)) + return ", ".join(words) + + +def _is_elif(source: Source, node: ast.If) -> bool: + return source.lines[node.lineno - 1].lstrip().startswith("elif") + + +def _else_line(source: Source, first: ast.stmt) -> int: + line = first.lineno - 1 + while line > 0 and source.lines[line - 1].strip() != "else:": + line -= 1 + return line if line > 0 else first.lineno + + +def _setter_branches( + source: Source, stmts: list[ast.stmt], key: str, lists: Mapping[str, list[str]] +) -> list[tuple[str | None, ast.Call, int]]: + """(condition, setter call, first line) for each branch writing `key`.""" + found: list[tuple[str | None, ast.Call, int]] = [] + for stmt in stmts: + call = _expr_call(stmt) + if call is not None and _setter(call, key): + found.append((None, call, stmt.lineno)) + continue + if not isinstance(stmt, ast.If): + if any(_setter(c, key) for c in _calls(stmt, attr="setString")): + source.fail(f"line {stmt.lineno}: {key} is written outside an if chain") + continue + node, tests = stmt, [] + while True: + tests.append(node.test) + found += _branch(source, node.body, key, _condition(source, [node.test], False, lists), + node.lineno) + orelse = node.orelse + if len(orelse) == 1 and isinstance(orelse[0], ast.If) and _is_elif(source, orelse[0]): + node = orelse[0] + continue + if orelse: + found += _branch(source, orelse, key, _condition(source, tests, True, lists), + _else_line(source, orelse[0])) + break + return found + + +def _branch( + source: Source, stmts: list[ast.stmt], key: str, condition: str, start: int +) -> list[tuple[str | None, ast.Call, int]]: + calls = [c for stmt in stmts if (c := _expr_call(stmt)) is not None and _setter(c, key)] + nested = [c for stmt in stmts for c in _calls(stmt, attr="setString") if _setter(c, key)] + if len(calls) != len(nested) or len(calls) > 1: + source.fail(f"line {start}: {key} is written in a nested branch") + return [(condition, call, start) for call in calls] + + +def _local_lists(source: Source, scope: ast.AST) -> dict[str, tuple[list[str], ast.stmt]]: + found = {} + for node in ast.walk(scope): + name = _assigned_name(node) + if name is not None and isinstance(node.value, ast.List): + values = [e.value for e in node.value.elts if isinstance(e, ast.Constant)] + if len(values) == len(node.value.elts): + found[name] = (values, node) + return found + + +def system_directories(cg: Configgen) -> list[Launcher]: + """`match self.system.Core` per core, then the overrides per system.""" + src = cg.source(RETROARCH) + matches = [ + node for node in ast.walk(src.tree) + if isinstance(node, ast.Match) + and ast.unparse(node.subject) == "self.system.Core" + and any(_setter(c, SYSTEM_DIRECTORY) for c in _calls(node, attr="setString")) + ] + if len(matches) != 1: + src.fail(f"expected one match on the core writing {SYSTEM_DIRECTORY}, " + f"found {len(matches)}") + match = matches[0] + scope = _innermost_function(src, match) + env = cg.env(src, scope) + local_lists = _local_lists(src, scope) + lists = {name: values for name, (values, _node) in local_lists.items()} + + def record(emulator: str, condition: str | None, call: ast.Call, + spans: list[tuple[int, int]]) -> Launcher: + directory = cg.bios_dir(spell(call.args[1], env), src, f"line {call.lineno}") + return Launcher( + emulator=emulator, build="libretro", kind="passes_path", names=[], + search_dir=directory, passes_to=f"retroarch {SYSTEM_DIRECTORY}", + depends_on_option=condition, source_ref=src.ref(spans), + ) + + launchers = [] + for case in match.cases: + if case.guard is not None: + src.fail(f"line {case.pattern.lineno}: a guarded case is not read") + pattern = case.pattern + options = pattern.patterns if isinstance(pattern, ast.MatchOr) else [pattern] + cores = [p.value.value for p in options + if isinstance(p, ast.MatchValue) and isinstance(p.value, ast.Constant)] + wildcard = isinstance(pattern, ast.MatchAs) and pattern.pattern is None + if not wildcard and len(cores) != len(options): + src.fail(f"line {pattern.lineno}: case pattern is not a list of core names") + branches = _setter_branches(src, case.body, SYSTEM_DIRECTORY, lists) + if not branches: + src.fail(f"line {pattern.lineno}: case writes no {SYSTEM_DIRECTORY}") + for condition, call, start in branches: + if wildcard: + words = ["core with no case of its own"] + ([condition] if condition else []) + condition = ", ".join(words) + launchers.append(record( + ", ".join(cores) or ALL_CORES, condition, call, + [(pattern.lineno, pattern.lineno), (start, call.end_lineno)], + )) + + body, index = _locate(scope, lambda stmt: stmt is match) + overrides = _setter_branches(src, body[index + 1:], SYSTEM_DIRECTORY, lists) + if not overrides: + src.fail(f"no per-system {SYSTEM_DIRECTORY} after the match on the core") + tests = {node.lineno: node.test for node in ast.walk(scope) if isinstance(node, ast.If)} + for condition, call, start in overrides: + if condition is None: + src.fail(f"line {call.lineno}: {SYSTEM_DIRECTORY} rewritten for every system") + read = {n.id for n in ast.walk(tests[start]) if isinstance(n, ast.Name)} \ + if start in tests else set() + spans = [(start, call.end_lineno)] + spans += [src.span(node) for name, (_v, node) in local_lists.items() if name in read] + launchers.append(record( + ALL_CORES, f"{condition}, replacing the directory chosen by core", call, spans, + )) + return launchers + + +def core_directory(directories: list[Launcher], core: str) -> Launcher: + """The unconditional system_directory record that applies to `core`.""" + for launcher in directories: + if core in launcher.emulator.split(", ") and launcher.depends_on_option is None: + return launcher + for launcher in directories: + if launcher.emulator == ALL_CORES and launcher.depends_on_option == \ + "core with no case of its own": + return launcher + raise LauncherShapeError(f"{RETROARCH}: no {SYSTEM_DIRECTORY} applies to {core}") + + +def core_assets(cg: Configgen) -> list[Launcher]: + """`if self.system.Core == x: setString("core_assets_directory", dir)`.""" + src = cg.source(RETROARCH) + env = cg.env(src) + launchers = [] + for node in ast.walk(src.tree): + if not ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and ast.unparse(node.test.left) == "self.system.Core" + and isinstance(node.test.ops[0], ast.Eq) + and isinstance(node.test.comparators[0], ast.Constant) + ): + continue + calls = [c for stmt in node.body if (c := _expr_call(stmt)) and _setter(c, CORE_ASSETS)] + if len(calls) != 1: + continue + call = calls[0] + launchers.append(Launcher( + emulator=node.test.comparators[0].value, build="libretro", kind="passes_path", + names=[], search_dir=cg.bios_dir(spell(call.args[1], env), src, CORE_ASSETS), + passes_to=f"retroarch {CORE_ASSETS}", + source_ref=src.ref([(node.lineno, call.end_lineno)]), + )) + if not launchers: + src.fail(f"no core is given a {CORE_ASSETS}") + return launchers + + +def _exists_arg(test: ast.expr) -> ast.expr | None: + """X in `not os.path.exists(X)`.""" + if ( + isinstance(test, ast.UnaryOp) + and isinstance(test.op, ast.Not) + and isinstance(test.operand, ast.Call) + and ast.unparse(test.operand.func) == "os.path.exists" + and len(test.operand.args) == 1 + ): + return test.operand.args[0] + return None + + +def _assigns(stmt: ast.stmt, var: str) -> ast.expr | None: + if isinstance(stmt, (ast.Assign, ast.AnnAssign)) and _assigned_name(stmt) == var: + return stmt.value + return None + + +def _skip_imports(body: list[ast.stmt], index: int) -> int: + while index < len(body) and isinstance(body[index], (ast.Import, ast.ImportFrom)): + index += 1 + return index + + +def tos_cascade(cg: Configgen, src: Source, scope: ast.FunctionDef, emulator: str, + build: str) -> list[Launcher]: + """A TOS path chosen per machine, then tried down an existence cascade.""" + env = cg.env(src, scope) + paths = { + name for node in ast.walk(scope) + if (name := _assigned_name(node)) is not None + and isinstance(node.value, ast.Call) + and ast.unparse(node.value.func) == "os.path.join" + } + setters = [ + call for call in _calls(scope, attr="setString") + if call.args + and isinstance(call.args[-1], ast.Call) + and ast.unparse(call.args[-1].func) == "str" + and isinstance(call.args[-1].args[0], ast.Name) + and call.args[-1].args[0].id in paths + ] + if len(setters) != 1: + src.fail(f"{scope.name}: expected one setting written from str()") + setter = setters[0] + var = setter.args[-1].args[0].id + keys = [spell(arg, env) for arg in setter.args[:-1]] + if None in keys or len(keys) not in (1, 2): + src.fail(f"{scope.name}: the TOS is written under a key that is not read") + passes_to = f"[{keys[0]}] {keys[1]}" if len(keys) == 2 else f"core option {keys[0]}" + + located = _locate(scope, lambda stmt: _assigns(stmt, var) is not None) + if located is None: + src.fail(f"{scope.name}: {var} is never assigned") + body, index = located + initial = spell(body[index].value, env) + chosen: dict[str, str] = {} + fallbacks: list[str] = [] + absolute = None + override: tuple[str, str] | None = None + end = body[index] + i = _skip_imports(body, index + 1) + while i < len(body) and body[i] is not _outermost_stmt(body, setter): + stmt = body[i] + if not isinstance(stmt, ast.If): + src.fail(f"line {stmt.lineno}: unexpected statement in the TOS cascade") + if ( + isinstance(stmt.test, ast.Compare) + and isinstance(stmt.test.comparators[0], ast.Name) + and stmt.test.comparators[0].id.startswith("MACHINE_") + ): + node: ast.If | None = stmt + while node is not None: + value = _assigns(node.body[0], var) if len(node.body) == 1 else None + machine = node.test.comparators[0] + if value is None or not isinstance(machine, ast.Name): + src.fail(f"line {node.lineno}: machine branch does not set {var}") + chosen[machine.id] = spell(value, env) + orelse = node.orelse + node = orelse[0] if len(orelse) == 1 and isinstance(orelse[0], ast.If) else None + elif _exists_arg(stmt.test) is not None: + node = stmt + while node is not None: + tested = _exists_arg(node.test) + is_abs = isinstance(tested, ast.Name) and tested.id == var + is_rel = ( + isinstance(tested, ast.Call) + and ast.unparse(tested.func) == "os.path.join" + and spell(tested.args[0], env) == cg.bios + and ast.unparse(tested.args[1]) == var + ) + if not (is_abs or is_rel) or (absolute is not None and absolute != is_abs): + src.fail(f"line {node.lineno}: the cascade tests something else than {var}") + absolute = is_abs + value = _assigns(node.body[0], var) + if value is None: + src.fail(f"line {node.lineno}: cascade step does not set {var}") + fallbacks.append(spell(value, env)) + inner = node.body[1:] + node = inner[0] if len(inner) == 1 and isinstance(inner[0], ast.If) else None + elif ( + isinstance(stmt.test, ast.Compare) + and isinstance(stmt.test.left, ast.Constant) + and isinstance(stmt.test.ops[0], ast.In) + and ast.unparse(stmt.test.comparators[0]) in ("rom", "self.rom", "args.rom") + and len(stmt.body) == 1 + and _assigns(stmt.body[0], var) is not None + ): + override = (stmt.test.left.value, spell(_assigns(stmt.body[0], var), env)) + else: + src.fail(f"line {stmt.lineno}: unexpected branch in the TOS cascade") + end = stmt + i = _skip_imports(body, i + 1) + machines = [ + (name, name.removeprefix("MACHINE_").replace("_", "").lower()) + for node in scope.body if (name := _assigned_name(node)) and name.startswith("MACHINE_") + and name != "MACHINE_UNKNOWN" + ] + if not chosen or not fallbacks or absolute is None or not machines or initial is None: + src.fail(f"{scope.name}: no machine choice followed by an existence cascade") + + def relative(value: str | None) -> str: + name = cg.under_bios(value) if absolute else value + if name is None: + src.fail(f"{scope.name}: {value} is not under {cg.bios}") + return name + + tokens = [v for v in fallbacks if BUILTIN_TOKEN.fullmatch(posixpath.basename(v))] + files = [relative(v) for v in fallbacks if v not in tokens] + if tokens: + passes_to += f" ({', '.join(tokens)} when none is found)" + dicts = [ + node for node in scope.body + if isinstance(node, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Dict) + and all(isinstance(v, ast.Name) and v.id.startswith("MACHINE_") for v in node.value.values) + ] + if len(dicts) != 1: + src.fail(f"{scope.name}: no single rom path -> machine table") + bound = {a.arg for a in scope.args.args} | { + n for node in ast.walk(scope) if (n := _assigned_name(node)) + } + options = [ + call.args[0].value for call in _calls(scope, attr="getInt") + if call.args and isinstance(call.args[0], ast.Constant) + and isinstance(call.func.value, ast.Name) and call.func.value.id in bound + ] + depends = f"machine read from the rom path through {_assigned_name(dicts[0])}" + if options: + depends += f", else option {', '.join(options)}" + machine_lines = [n.lineno for n in scope.body if _assigned_name(n) in dict(machines)] + cascade = body[index:body.index(_outermost_stmt(body, setter)) + 1] + read = {n.id for stmt in cascade for n in ast.walk(stmt) if isinstance(n, ast.Name)} + helpers = [src.span(n) for n in scope.body + if _assigned_name(n) in read - {var} and spell(n.value, env) is not None] + ref = src.ref([ + (min(machine_lines), max(machine_lines)), + src.span(dicts[0]), + *helpers, + (src.span(body[index])[0], max(end.end_lineno, setter.end_lineno)), + ]) + launchers = [] + for constant, slot in machines: + first = relative(chosen.get(constant, initial)) + launchers.append(Launcher( + emulator=emulator, build=build, kind="names_searched", slot=slot, + names=list(dict.fromkeys([first, *files])), case_insensitive=False, + search_dir=cg.bios, passes_to=passes_to, fails_when_missing=False, + depends_on_option=depends, source_ref=ref, + )) + if override is not None and not BUILTIN_TOKEN.search(override[1]): + launchers.append(Launcher( + emulator=emulator, build=build, kind="fixed_name", slot=override[0], + names=[relative(override[1])], case_insensitive=False, search_dir=cg.bios, + passes_to=passes_to, fails_when_missing=False, + depends_on_option=f'"{override[0]}" in the rom path', source_ref=ref, + )) + return launchers + + +def _outermost_stmt(body: list[ast.stmt], node: ast.AST) -> ast.stmt | None: + for stmt in body: + if any(child is node for child in ast.walk(stmt)): + return stmt + return None + + +def hatari(cg: Configgen) -> list[Launcher]: + src = cg.source(HATARI) + return tos_cascade(cg, src, src.function("SetMachineAndBios"), cg.emulator(HATARI), + "standalone") + + +def hatari_libretro(cg: Configgen) -> list[Launcher]: + src = cg.source(CORES) + return [ + launcher for function in TOS_FUNCTIONS + for launcher in tos_cascade(cg, src, src.function(function), cg.core(function), "libretro") + ] + + +def gsplus(cg: Configgen) -> list[Launcher]: + """A path tried with os.path.exists down an else chain, and the + placeholders a per-game .gsplus file can name.""" + src = cg.source(GSPLUS) + generate = src.function("generate") + env = cg.env(src, generate) + emulator = cg.emulator(GSPLUS) + + def exists_of(test: ast.expr, var: str) -> bool: + return ( + isinstance(test, ast.Call) + and ast.unparse(test.func) == "os.path.exists" + and ast.unparse(test.args[0]) == var + ) + + located = _locate(generate, lambda stmt: ( + _assigned_name(stmt) is not None + and cg.under_bios(spell(stmt.value, env)) is not None + )) + if located is None: + src.fail("generate assigns no path under BIOS") + body, index = located + names: list[str] = [] + key = None + stmts, i = body, index + last: ast.stmt = body[index] + while True: + var = _assigned_name(stmts[i]) + name = cg.under_bios(spell(stmts[i].value, env)) + test = stmts[i + 1] if i + 1 < len(stmts) else None + tried = isinstance(test, ast.If) and var is not None and exists_of(test.test, var) + if name is None or not tried: + src.fail(f"line {stmts[i].lineno}: path is not tried with os.path.exists") + written = [ + c for stmt in test.body + if (c := _expr_call(stmt)) and c.func.attr == "setString" + and ast.unparse(c.args[1]) == var + ] + if len(written) != 1 or (key is not None and written[0].args[0].value != key): + src.fail(f"line {test.lineno}: the found path is not written to one key") + key = written[0].args[0].value + names.append(name) + last = test + orelse = test.orelse + if len(orelse) == 2 and isinstance(orelse[1], ast.If): + stmts, i = orelse, 0 + continue + removed = [ + c for stmt in orelse if (c := _expr_call(stmt)) and _setter(c, key, "removeOption") + ] + if len(orelse) != 1 or not removed: + src.fail(f"line {test.lineno}: the last miss does not remove {key}") + break + guard = next( + node for node in ast.walk(generate) + if isinstance(node, ast.If) and any(child is body[index] for child in node.orelse) + ) + if not (isinstance(guard.test, ast.Compare) and isinstance(guard.test.left, ast.Constant)): + src.fail("the BIOS search is not the else of a rom path test") + marker = guard.test.left.value + launchers = [Launcher( + emulator=emulator, build="standalone", kind="names_searched", names=names, + case_insensitive=False, search_dir=cg.bios, passes_to=key, fails_when_missing=False, + depends_on_option=f'rom path without "{marker}"', + source_ref=src.ref([(src.span(body[index])[0], last.end_lineno)]), + )] + for node in ast.walk(ast.Module(body=guard.body, type_ignores=[])): + if not ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Call) + and _setter(node.test.left, key, "getString") + and isinstance(node.test.comparators[0], ast.Constant) + ): + continue + written = [c for stmt in node.body if (c := _expr_call(stmt)) and _setter(c, key)] + name = cg.under_bios(spell(written[0].args[1], env)) if len(written) == 1 else None + if name is None: + src.fail(f"line {node.lineno}: placeholder branch writes no BIOS path") + placeholder = node.test.comparators[0].value + launchers.append(Launcher( + emulator=emulator, build="standalone", kind="fixed_name", slot=placeholder, + names=[name], case_insensitive=False, search_dir=cg.bios, passes_to=key, + fails_when_missing=False, + depends_on_option=f'"{marker}" game file whose {key} is {placeholder}', + source_ref=src.ref([(node.lineno, written[0].end_lineno)]), + )) + if len(launchers) == 1: + src.fail("no placeholder of a .gsplus game file is replaced") + return launchers + + +def amiberry(cg: Configgen) -> list[Launcher]: + """BIOS_PER_SYSTEM tried in order, the AROS pair, then Amiberry's own.""" + kick = cg.source(AMIBERRY_KICKSTARTS) + subsystems = _class_strings(cg.source(AMIBERRY_SUBSYSTEMS), {}) + computers = subsystems.get("SubSystems.COMPUTERS") + if not isinstance(computers, list): + cg.source(AMIBERRY_SUBSYSTEMS).fail("SubSystems.COMPUTERS is not a tuple of systems") + + helper = kick.function("AmigaBios") + returned = helper.body[-1] + if not ( + isinstance(returned, ast.Return) + and isinstance(returned.value, ast.Call) + and ast.unparse(returned.value.func) == "os.path.join" + and ast.unparse(returned.value.args[-1]) == helper.args.args[0].arg + ): + kick.fail("AmigaBios no longer joins a directory and the name") + search_dir = cg.bios_dir( + spell(ast.Call(func=returned.value.func, args=returned.value.args[:-1], keywords=[]), + cg.env(kick)), + kick, "AmigaBios", + ) + + table = kick.assignment("BIOS_PER_SYSTEM") + if not isinstance(table.value, ast.Dict): + kick.fail("BIOS_PER_SYSTEM is not a dict display") + chooser = kick.function("GetKickstartsFor") + messages = [ + node.exc.args[0].value for node in ast.walk(chooser) + if isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call) + and node.exc.args and isinstance(node.exc.args[0], ast.Constant) + ] + aros = [ + call.args[0].value for call in _calls(chooser, name="AmigaBios") + if call.args and isinstance(call.args[0], ast.Constant) + ] + builtin = [ + node.value.value for node in ast.walk(chooser) + if isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) == "self.rom" + and isinstance(node.value, ast.Constant) + ] + if len(messages) != 2 or len(aros) != 2 or len(builtin) != 1: + kick.fail("GetKickstartsFor no longer has two failures, the AROS pair and a built-in") + config = cg.source(AMIBERRY_CONFIG) + setter = config.function("SetKickstarts") + keys = [c.args[0].value for c in _calls(setter, attr="setString") + if isinstance(c.args[0], ast.Constant)] + if len(keys) != 2: + config.fail("SetKickstarts no longer writes a kickstart and an extended ROM") + rom_key, ext_key = keys + tail = [(helper.lineno, helper.end_lineno), (chooser.lineno, chooser.end_lineno)] + config_ref = config.ref([(setter.lineno, setter.end_lineno)]) + + emulator = cg.emulator(AMIBERRY_GENERATOR) + launchers = [] + for key, value in zip(table.value.keys, table.value.values): + system = subsystems.get(ast.unparse(key)) if key is not None else None + if not isinstance(system, str) or not isinstance(value, ast.List): + kick.fail(f"line {table.lineno}: BIOS_PER_SYSTEM key is not a SubSystems name") + for entry in value.elts: + if not (isinstance(entry, ast.Tuple) and len(entry.elts) == 2 + and isinstance(entry.elts[0], ast.List)): + kick.fail(f"line {entry.lineno}: entry is not (rom types, names)") + types = ", ".join(ast.unparse(t).removeprefix("RomType.") for t in entry.elts[0].elts) + names = kick.literal(entry.elts[1], "kickstart names") + ref = f"{kick.ref([kick.span(entry), *tail])}, {config_ref}" + common = dict(emulator=emulator, build="standalone", slot=system, + case_insensitive=False, search_dir=search_dir, + depends_on_option=f"rom type {types}", source_ref=ref) + if any("|" in name for name in names): + if len(names) != 1: + kick.fail(f"line {entry.lineno}: several kickstart pairs are not read") + launchers.append(Launcher( + kind="fixed_name", names=names[0].split("|"), + passes_to=f"{rom_key}, {ext_key}", + fails_when_missing=", ".join(messages), **common, + )) + else: + launchers.append(Launcher( + kind="names_searched", names=names, passes_to=rom_key, + fails_when_missing=False if system in computers else messages[0], + **common, + )) + launchers.append(Launcher( + emulator=emulator, build="standalone", kind="fixed_name", slot=", ".join(computers), + names=aros, case_insensitive=False, search_dir=search_dir, + passes_to=f"{rom_key}, {ext_key} ({builtin[0]} built in when either is missing)", + fails_when_missing=False, depends_on_option="none of the listed kickstarts found", + source_ref=f"{kick.ref(tail)}, {config_ref}", + )) + return launchers + + +def pcsx2(cg: Configgen) -> list[Launcher]: + """A region token of the rom name picks a BIOS, then a fixed order.""" + src = cg.source(PCSX2) + env = cg.env(src) + chooser = src.function("__CheckPs2Bios") + exists = next((f for f in chooser.body if isinstance(f, ast.FunctionDef)), None) + joined = exists.body[-1].value.args[0] if exists and isinstance(exists.body[-1], ast.Return) \ + and isinstance(exists.body[-1].value, ast.Call) and exists.body[-1].value.args else None + if not ( + isinstance(joined, ast.Call) and ast.unparse(joined.func) == "os.path.join" + and ast.unparse(joined.args[-1]) == exists.args.args[0].arg + ): + src.fail("__CheckPs2Bios has no existence helper joining a directory and a name") + search_dir = cg.bios_dir( + spell(ast.Call(func=joined.func, args=joined.args[:-1], keywords=[]), env), src, "Exists" + ) + helper = exists.name + + groups: dict[tuple[str, str], list[str]] = {} + chain = next((s for s in chooser.body if isinstance(s, ast.If)), None) + node = chain + while node is not None: + test = node.test + ok = ( + isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And) and len(test.values) == 2 + and isinstance(test.values[0], ast.Compare) + and isinstance(test.values[0].left, ast.Constant) + and isinstance(test.values[1], ast.Call) + and ast.unparse(test.values[1].func) == helper + and len(node.body) == 1 and isinstance(node.body[0], ast.Return) + ) + if not ok: + src.fail(f"line {node.lineno}: region branch is not `token in rom and Exists(name)`") + tested = spell(test.values[1].args[0], env) + given = spell(node.body[0].value, env) + if tested is None or given is None: + src.fail(f"line {node.lineno}: region branch names no BIOS constant") + groups.setdefault((given, tested), []).append(test.values[0].left.value) + orelse = node.orelse + node = orelse[0] if len(orelse) == 1 and isinstance(orelse[0], ast.If) else None + loops = [s for s in chooser.body if isinstance(s, ast.For) and isinstance(s.iter, ast.Tuple)] + if not groups or len(loops) != 1: + src.fail("__CheckPs2Bios no longer tries region tokens then a fixed order") + fallback = [spell(e, env) for e in loops[0].iter.elts] + if None in fallback: + src.fail(f"line {loops[0].lineno}: fallback names are not BIOS constants") + + writes = [ + call for call in _calls(src.tree, attr="setString") + if len(call.args) == 3 and isinstance(call.args[2], ast.Call) + and isinstance(call.args[2].func, ast.Attribute) + and call.args[2].func.attr == chooser.name + ] + section = spell(writes[0].args[0], env) if len(writes) == 1 else None + key = spell(writes[0].args[1], env) if len(writes) == 1 else None + if section is None or key is None: + src.fail("the chosen BIOS is not written to one config key") + constants = {v: k.removeprefix("self.").removeprefix("Pcsx2Generator.") + for k, v in env.items() if k.startswith("Pcsx2Generator.BIOS_")} + names_lines = [n.lineno for n in ast.walk(src.tree) + if _assigned_name(n) in {c for c in constants.values()}] + ref = src.ref([(min(names_lines), max(names_lines)), (chooser.lineno, chooser.end_lineno), + (writes[0].lineno, writes[0].end_lineno)]) + common = dict(emulator=cg.emulator(PCSX2), build="standalone", case_insensitive=False, + search_dir=search_dir, passes_to=f"[{section}] {key}", + fails_when_missing=False, source_ref=ref) + launchers = [] + for (given, tested), tokens in groups.items(): + slot = constants.get(given, given).removeprefix("BIOS_") + depends = f"rom name contains {', '.join(tokens)}" + if tested == given: + launchers.append(Launcher( + kind="names_searched", slot=slot, + names=list(dict.fromkeys([given, *fallback])), + depends_on_option=depends, **common, + )) + else: + # The name written is not the one tested: no search, one name. + launchers.append(Launcher( + kind="fixed_name", slot=slot, names=[given], + depends_on_option=f"{depends} and {tested} is present", + **common, + )) + launchers.append(Launcher( + kind="names_searched", slot="default", names=fallback, + depends_on_option="rom name with no region token, or its BIOS absent", + **common, + )) + return launchers + + +def _presence(cg: Configgen, src: Source, scope: ast.FunctionDef) -> tuple[list[str], ast.If]: + """Paths tested with `os.path.exists(a) or os.path.exists(b) ...`.""" + env = cg.env(src, scope) + for node in ast.walk(scope): + if not (isinstance(node, ast.If) and isinstance(node.test, ast.BoolOp) + and isinstance(node.test.op, ast.Or)): + continue + names = [ + cg.under_bios(spell(value.args[0], env)) for value in node.test.values + if isinstance(value, ast.Call) and ast.unparse(value.func) == "os.path.exists" + ] + if names and len(names) == len(node.test.values) and None not in names: + return names, node + src.fail(f"{scope.name}: no presence test of BIOS paths") + + +def _returned(stmts: list[ast.stmt]) -> str | None: + if len(stmts) == 1 and isinstance(stmts[0], ast.Return): + value = stmts[0].value + return value.value if isinstance(value, ast.Constant) else None + return None + + +def dolphin(cg: Configgen) -> list[Launcher]: + """The IPL is only tested for presence, to decide whether to skip it.""" + src = cg.source(DOLPHIN) + env = cg.env(src) + checker = src.function("CheckGamecubeIpl") + names, test = _presence(cg, src, checker) + found, missing = _returned(test.body), _returned(test.orelse) + calls = [ + call for call in _calls(src.tree, attr="setString") + if len(call.args) == 3 and isinstance(call.args[2], ast.Name) + and any( + _assigned_name(n) == call.args[2].id and isinstance(n.value, ast.Call) + and isinstance(n.value.func, ast.Attribute) and n.value.func.attr == checker.name + for n in ast.walk(src.tree) + ) + ] + if found is None or missing is None or len(calls) != 1: + src.fail("CheckGamecubeIpl's answer is not written to one config key") + call = calls[0] + section, key = spell(call.args[0], env), spell(call.args[1], env) + binding = next(n for n in ast.walk(src.tree) + if _assigned_name(n) == call.args[2].id) + standalone = Launcher( + emulator=cg.emulator(DOLPHIN), build="standalone", kind="fixed_name", names=names, + case_insensitive=False, search_dir=cg.bios, + passes_to=f"[{section}] {key} ({found} when one is present, else {missing})", + fails_when_missing=False, + source_ref=src.ref([(checker.lineno, checker.end_lineno), src.span(binding), + (call.lineno, call.end_lineno)]), + ) + cores = cg.source(CORES) + configure = cores.function("configureDolphin") + names, test = _presence(cg, cores, configure) + options = [(c.args[0].value, c.args[1].value) for branch in (test.body, test.orelse) + for stmt in branch if (c := _expr_call(stmt)) and len(c.args) == 2 + and all(isinstance(a, ast.Constant) for a in c.args)] + if len(options) != 2 or options[0][0] != options[1][0]: + cores.fail("configureDolphin's presence test does not set one core option both ways") + option = options[0][0] + on, off = (value.strip('"') for _o, value in options) + first = min(n.lineno for n in ast.walk(configure) + if _assigned_name(n) is not None and cg.under_bios( + spell(n.value, cg.env(cores, configure))) is not None) + libretro = Launcher( + emulator=cg.core("configureDolphin"), build="libretro", kind="fixed_name", names=names, + case_insensitive=False, search_dir=cg.bios, + passes_to=f"core option {option} ({on} when one is present, else {off})", + fails_when_missing=False, source_ref=cores.ref([(first, test.end_lineno)]), + ) + return [standalone, libretro] + + +def _config_paths(cg: Configgen, path: str, emulator: str) -> Launcher: + """setString(section, key, ) calls of one module.""" + src = cg.source(path) + writes = [] + for function in (n for n in ast.walk(src.tree) if isinstance(n, ast.FunctionDef)): + env = cg.env(src, function) + for call in _calls(function, attr="setString"): + if len(call.args) != 3: + continue + section, key = spell(call.args[0], env), spell(call.args[1], env) + name = cg.under_bios(spell(call.args[2], env)) + if section is not None and key is not None and name is not None: + writes.append((function, call, section, key, name)) + if not writes: + src.fail("writes no BIOS path into its config") + conditions: list[str] = [] + spans = [] + for function, call, *_rest in writes: + spans.append((call.lineno, call.end_lineno)) + test = next( + (n for n in ast.walk(function) if isinstance(n, ast.If) + and any(child is call for stmt in n.body for child in ast.walk(stmt))), None, + ) + if test is not None: + spans.append((test.lineno, test.lineno)) + systems = _systems(test.test, {}) + words = f"system {', '.join(systems)}" if systems else _enclosing_test(function, call) + if words not in conditions: + conditions.append(words) + read = {ast.unparse(n) for arg in call.args for n in ast.walk(arg) + if isinstance(n, (ast.Name, ast.Attribute))} + spans += [src.span(n) for n in function.body + if _assigned_name(n) is not None and _assigned_name(n) in read] + spans += [src.span(n) for cls in src.tree.body if isinstance(cls, ast.ClassDef) + for n in cls.body if _assigned_name(n) is not None + and {f"self.{_assigned_name(n)}", f"{cls.name}.{_assigned_name(n)}"} & read] + return Launcher( + emulator=emulator, build="standalone", kind="fixed_name", + names=list(dict.fromkeys(name for *_rest, name in writes)), case_insensitive=False, + search_dir=cg.bios, + passes_to=", ".join(dict.fromkeys(f"[{s}] {k}" for _f, _c, s, k, _n in writes)), + fails_when_missing=False, depends_on_option=", ".join(conditions) or None, + source_ref=src.ref(spans), + ) + + +def xemu(cg: Configgen) -> list[Launcher]: + return [_config_paths(cg, XEMU, cg.emulator(XEMU))] + + +def mupen64plus(cg: Configgen) -> list[Launcher]: + return [_config_paths(cg, MUPEN_CONFIG, cg.emulator(MUPEN_GENERATOR))] + + +def content_files(cg: Configgen) -> list[Launcher]: + """`if core == x:` writing a BIOS path into a file loaded as the content.""" + src = cg.source(LIBRETRO_GENERATOR) + files = cg.source(FILES_MODULE) + env = cg.env(src) + launchers = [] + for node in ast.walk(src.tree): + if not ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and ast.unparse(node.test.left) == "core" + and isinstance(node.test.comparators[0], ast.Constant) + ): + continue + refs = [n for n in ast.walk(node) if isinstance(n, ast.Attribute) + and ast.unparse(n.value) == "recalboxFiles"] + names = list(dict.fromkeys( + name for n in refs if (name := cg.under_bios(spell(n, env))) is not None + )) + opened = [c for c in _calls(node, name="open") + if len(c.args) == 2 and spell(c.args[1], env) == "w"] + if not names: + continue + target = spell(opened[0].args[0], env) if len(opened) == 1 else None + returned = [n for n in ast.walk(node) if isinstance(n, ast.Return)] + if target is None or not returned: + src.fail(f"line {node.lineno}: BIOS path is not written to a file passed on") + constants = {n.attr for n in refs if cg.under_bios(spell(n, env)) is not None} + lines = [n.lineno for n in files.tree.body if _assigned_name(n) in constants] + launchers.append(Launcher( + emulator=node.test.comparators[0].value, build="libretro", kind="fixed_name", + names=names, case_insensitive=False, search_dir=cg.bios, + passes_to=f"written into {target}, which is loaded as the content", + fails_when_missing=False, + source_ref=f"{src.ref([(node.lineno, node.end_lineno)])}, " + f"{files.ref([(line, line) for line in lines])}", + )) + if not launchers: + src.fail("no core is given a BIOS path through a content file") + return launchers + + +def core_file_options(cg: Configgen, directories: list[Launcher]) -> list[Launcher]: + """System handlers that set a core option naming the file the core opens.""" + src = cg.source(CORES) + handlers = _handler_map(src, "fillCoresConfiguration", "specificSystemHandlers") + table = src.assignment("specificSystemHandlers", src.function("fillCoresConfiguration")) + defined = {n.name: n for n in ast.walk(src.tree) if isinstance(n, ast.FunctionDef)} + launchers = [] + for function_name, systems in handlers.items(): + function = defined.get(function_name) + if function is None: + continue + for call in _calls(function, attr="setString"): + if not (len(call.args) == 2 and all(isinstance(a, ast.Constant) for a in call.args) + and call.args[0].value in FILE_OPTIONS): + continue + option = call.args[0].value + core = FILE_OPTIONS[option] + directory = core_directory(directories, core) + spans = [(k.lineno, k.lineno) for k, v in zip(table.value.keys, table.value.values) + if isinstance(v, ast.Attribute) and v.attr == function_name] + launchers.append(Launcher( + emulator=core, build="libretro", kind="core_option", + names=[call.args[1].value.strip('"')], search_dir=directory.search_dir, + passes_to=f"core option {option}", + depends_on_option=f"system {', '.join(systems)}", + source_ref=f"{src.ref([(function.lineno, function.end_lineno), *spans])}, " + f"{directory.source_ref}", + )) + found = {str(launcher.passes_to).split()[-1] for launcher in launchers} + missing = sorted(set(FILE_OPTIONS) - found) + if missing: + src.fail(f"no system handler sets {', '.join(missing)}") + return launchers + + +STANDALONE: tuple[Callable[[Configgen], list[Launcher]], ...] = ( + hatari, gsplus, amiberry, pcsx2, xemu, mupen64plus, +) +LIBRETRO: tuple[Callable[[Configgen], list[Launcher]], ...] = ( + core_assets, hatari_libretro, content_files, +) + + +def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]: + """Every launcher record, read through `read(repo-relative path)`.""" + cg = Configgen(read) + directories = system_directories(cg) + launchers = [launcher for extractor in STANDALONE for launcher in extractor(cg)] + launchers += dolphin(cg) + launchers += directories + launchers += [launcher for extractor in LIBRETRO for launcher in extractor(cg)] + launchers += core_file_options(cg, directories) + return [launcher.entry() for launcher in launchers] diff --git a/scripts/scraper/recalbox_scraper.py b/scripts/scraper/recalbox_scraper.py index 30dd4fb9..b8ce473c 100644 --- a/scripts/scraper/recalbox_scraper.py +++ b/scripts/scraper/recalbox_scraper.py @@ -18,6 +18,8 @@ from __future__ import annotations from common import parse_untrusted_xml from .base_scraper import BaseScraper, BiosRequirement, fetch_bytes, requirement_entry +from .recalbox_launchers import SOURCE_FILES as LAUNCHER_FILES +from .recalbox_launchers import LauncherShapeError, extract_launchers PLATFORM_NAME = "recalbox" @@ -218,9 +220,14 @@ class Scraper(BaseScraper): def _system_inis(self, systems: list[str]) -> list[str]: """Every system's system.ini at the tag, read in batches.""" + paths = [f"{SYSTEMS_DIR}/{system}/system.ini" for system in systems] + texts = self._blobs(paths) + return [texts[path] for path in paths] + + def _blobs(self, paths: list[str]) -> dict[str, str]: + """The files at `paths` at the tag, read in batches.""" import json - paths = [f"{SYSTEMS_DIR}/{system}/system.ini" for system in systems] texts: dict[str, str] = {} for start in range(0, len(paths), BLOBS_PER_QUERY): batch = paths[start:start + BLOBS_PER_QUERY] @@ -236,8 +243,19 @@ class Scraper(BaseScraper): texts.update({n["path"]: n["rawBlob"] for n in nodes if n.get("rawBlob")}) missing = [path for path in paths if path not in texts] if missing: - raise RuntimeError(f"no system.ini at {self.tag} for {', '.join(missing[:5])}") - return [texts[path] for path in paths] + raise RuntimeError(f"nothing at {self.tag} for {', '.join(missing[:5])}") + return texts + + def _fetch_launchers(self) -> list[dict]: + """Launcher records read from configgen at the scraped tag.""" + texts = self._blobs(list(LAUNCHER_FILES)) + + def read(path: str) -> str: + if path not in texts: + raise LauncherShapeError(f"{path} is read but not in SOURCE_FILES") + return texts[path] + + return extract_launchers(read) def _fetch_cores(self) -> tuple[list[str], list[str], list[str]]: """Every core Recalbox's systems offer, those it runs standalone, and @@ -379,6 +397,7 @@ class Scraper(BaseScraper): # Launched both ways: each layout gets its files. **({"dual_mode_cores": dual} if dual else {}), "systems": systems, + "launchers": self._fetch_launchers(), } diff --git a/tests/test_launchers_recalbox.py b/tests/test_launchers_recalbox.py new file mode 100644 index 00000000..5c806f75 --- /dev/null +++ b/tests/test_launchers_recalbox.py @@ -0,0 +1,1568 @@ +#!/usr/bin/env python3 +"""Recalbox's launcher layer, read from configgen with ast. + +The blocks below are copied from Recalbox 10.1.1 (projects/configgen) and +placed at the lines they hold there, so the source_ref an extraction writes +is checked against the tag itself. +""" + +from __future__ import annotations + +import json +import sys +import unittest +from collections.abc import Callable +from pathlib import Path +from unittest import mock + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from scraper import recalbox_scraper # noqa: E402 +from scraper.recalbox_launchers import ( # noqa: E402 + ALL_CORES, + AMIBERRY_CONFIG, + AMIBERRY_KICKSTARTS, + AMIBERRY_SUBSYSTEMS, + CONFIGGEN, + CORES, + DOLPHIN, + FILES_MODULE, + GSPLUS, + HATARI, + LAUNCHER, + LIBRETRO_GENERATOR, + MUPEN_CONFIG, + PCSX2, + RETROARCH, + SOURCE_FILES, + XEMU, + Configgen, + Launcher, + LauncherShapeError, + amiberry, + content_files, + core_assets, + core_file_options, + dolphin, + extract_launchers, + gsplus, + hatari, + hatari_libretro, + mupen64plus, + pcsx2, + system_directories, + xemu, +) + +from tests.test_launchers import placed # noqa: E402 + +# recalboxFiles.py:1-12, 173-174 +FILES_MODULE_BLOCKS = { + 1: """#!/usr/bin/env python +HOME_INIT = '/recalbox/share_init/system/' +HOME = '/recalbox/share/system' +CONF = HOME + '/configs' +SAVES = '/recalbox/share/saves' +SAVES_INIT = '/recalbox/share_init/saves' +SCREENSHOTS = '/recalbox/share/screenshots' +BIOS = '/recalbox/share/bios' +BIOS_INIT = '/recalbox/share_init/bios' +OVERLAYS = '/recalbox/share/overlays' +RECALBOX_OVERLAYS = '/recalbox/share_init/overlays' +RECALBOX_240P_OVERLAYS = '/recalbox/share_init/240poverlays'""", + 173: """minivmacRomFile = '/tmp/minivmac.cmd' +minivmacOsFile = BIOS + '/macintosh/MinivMacBootv2.dsk'""", +} + +# emulatorlauncher.py:10-19, 29-34, 47-52, 65-67, 80-82, 125-127, 131-136 +LAUNCHER_BLOCKS = { + 10: '''def getGenerator(emulator): + + # We use a if...elif...else structure in order to instantiate the strict minimum required for an emulator + # and minimize static imports that are very time consuming, specially on low-end boards + if emulator == "advancemame": + module = __import__("configgen.generators.advancemame.advMameGenerator", fromlist=["AdvMameGenerator"]) + generatorClass = getattr(module, "AdvMameGenerator") + elif emulator == "amiberry": + module = __import__("configgen.generators.amiberry.amiberryGenerator", fromlist=["AmiberryGenerator"]) + generatorClass = getattr(module, "AmiberryGenerator")''', + 29: ''' elif emulator == "dolphin": + module = __import__("configgen.generators.dolphin.dolphinGenerator", fromlist=["DolphinGenerator"]) + generatorClass = getattr(module, "DolphinGenerator") + elif emulator == "dolphin-gui": + module = __import__("configgen.generators.dolphin.dolphinGenerator", fromlist=["DolphinGenerator"]) + generatorClass = getattr(module, "DolphinGenerator")''', + 47: ''' elif emulator == "gsplus": + module = __import__("configgen.generators.gsplus.gsplusGenerator", fromlist=["GSplusGenerator"]) + generatorClass = getattr(module, "GSplusGenerator") + elif emulator == "hatari": + module = __import__("configgen.generators.hatari.hatariGenerator", fromlist=["HatariGenerator"]) + generatorClass = getattr(module, "HatariGenerator")''', + 65: ''' elif emulator == "mupen64plus": + module = __import__("configgen.generators.mupen.mupenGenerator", fromlist=["MupenGenerator"]) + generatorClass = getattr(module, "MupenGenerator")''', + 80: ''' elif emulator == "pcsx2": + module = __import__("configgen.generators.pcsx2.pcsx2Generator", fromlist=["Pcsx2Generator"]) + generatorClass = getattr(module, "Pcsx2Generator")''', + 125: ''' elif emulator == "xemu": + module = __import__("configgen.generators.xemu.xemuGenerator", fromlist=["XemuGenerator"]) + generatorClass = getattr(module, "XemuGenerator")''', + 131: ''' else: + print("Missing generator for {}".format(emulator)) + raise ValueError + pass + + return generatorClass()''', +} + +# libretroRetroarch.py:1-11, 251-253, 421-529 +RETROARCH_BLOCKS = { + 1: '''from typing import List + +import configgen.recalboxFiles as recalboxFiles +from configgen.Emulator import Emulator +from configgen.controllers.controller import ControllerPerPlayer +from configgen.settings.keyValueSettings import keyValueSettings +from configgen.generators.libretro.libretroChallengeLock import ChallengeLock +from configgen.utils.architecture import Architecture + + +class LibretroRetroarch:''', + 251: ''' # Fill retroarch configuration + def fillRetroarchConfiguration(self): + settings: keyValueSettings = self.settings''', + 421: ''' + # Core assets path for dolphin only + if self.system.Core == "dolphin": + settings.setString("core_assets_directory", "/recalbox/share/bios/dolphin-emu/") + else: + settings.removeOption("core_assets_directory") + + # Audio buffer (naomi2 anti-crackle, HDMI + CRT) + self.manage_audio_buffer(settings) + + # Use bios in directories + # Most of the case are by core name + match self.system.Core: + case 'a5200': + settings.setString("system_directory", recalboxFiles.BIOS + "/atari5200") + case 'bsnes': + if self.system.Name == 'gb' or self.system.Name == 'gbc': + settings.setString("system_directory", recalboxFiles.BIOS + "/sgb") + elif self.system.Name == 'satellaview': + settings.setString("system_directory", recalboxFiles.BIOS + "/satellaview") + else: + settings.setString("system_directory", recalboxFiles.BIOS) + case 'bsneshd': + if self.system.Name == 'gb' or self.system.Name == 'gbc': + settings.setString("system_directory", recalboxFiles.BIOS + "/sgb") + elif self.system.Name == 'satellaview': + settings.setString("system_directory", recalboxFiles.BIOS + "/satellaview") + else: + settings.setString("system_directory", recalboxFiles.BIOS) + case 'cdi2015': + settings.setString("system_directory", recalboxFiles.BIOS + "/cdi") + case 'dolphin': + settings.setString("system_directory", recalboxFiles.BIOS + "/gamecube") + case 'emuscv': + settings.setString("system_directory", recalboxFiles.BIOS + "/scv") + case 'fmsx': + settings.setString("system_directory", recalboxFiles.BIOS + "/msx") + case 'freechaf': + settings.setString("system_directory", recalboxFiles.BIOS + "/channelf") + case 'freeintv': + settings.setString("system_directory", recalboxFiles.BIOS + "/intellivision") + case 'gambatte': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'gearsystem': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'genesisplusgx': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'genesisplusgxwide': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'geolith': + settings.setString("system_directory", recalboxFiles.BIOS + "/geolith") + case 'gpsp': + settings.setString("system_directory", recalboxFiles.BIOS + "/gba") + case 'mednafen_pce_fast': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'mednafen_pcfx': + settings.setString("system_directory", recalboxFiles.BIOS + "/pcfx") + case 'mednafen_supergrafx': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'geargrafx': + settings.setString("system_directory", recalboxFiles.BIOS + "/pcenginecd") + case 'mesen_s': + if self.system.SuperGameBoy: + settings.setString("system_directory", recalboxFiles.BIOS + "/sgb") + else: + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'minivmac': + settings.setString("system_directory", recalboxFiles.BIOS + "/macintosh") + case 'mu': + settings.setString("system_directory", recalboxFiles.BIOS + "/palm") + case 'o2em': + settings.setString("system_directory", recalboxFiles.BIOS + "/o2em") + case 'opera': + settings.setString("system_directory", recalboxFiles.BIOS + "/3do") + case 'picodrive': + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'pokemini': + settings.setString("system_directory", recalboxFiles.BIOS + "/pokemini") + case 'prosystem': + settings.setString("system_directory", recalboxFiles.BIOS + "/atari7800") + case 'sameboy': + if self.system.SuperGameBoy: + settings.setString("system_directory", recalboxFiles.BIOS + "/sgb") + else: + settings.setString("system_directory", recalboxFiles.BIOS + "/" + self.system.Name) + case 'snes9x': + if self.system.Name == 'satellaview': + settings.setString("system_directory", recalboxFiles.BIOS + "/satellaview") + elif self.system.Name == 'sufami': + settings.setString("system_directory", recalboxFiles.BIOS + "/sufami") + else: + settings.setString("system_directory", recalboxFiles.BIOS) + case _: + settings.setString("system_directory", recalboxFiles.BIOS) + + # We have a few to use by system name - when there are multiple cores with same bios + amigaSystems = ["amiga600", "amiga1200", "amigacdtv", "amigacd32"] + if self.system.Name in amigaSystems: + settings.setString("system_directory", recalboxFiles.BIOS + "/amiga/bios") + elif self.system.Name == 'fds': + settings.setString("system_directory", recalboxFiles.BIOS + "/fds") + elif self.system.Name == 'lynx': + settings.setString("system_directory", recalboxFiles.BIOS + "/lynx") + elif self.system.Name == 'nds': + settings.setString("system_directory", recalboxFiles.BIOS + "/nds") + elif self.system.Name == 'saturn': + settings.setString("system_directory", recalboxFiles.BIOS + "/saturn") + elif self.system.Name == 'stv': + settings.setString("system_directory", recalboxFiles.BIOS + "/stv")''', +} + +# libretroCores.py:1-17, 165-298, 348-355, 492-504, 633-705 +CORES_BLOCKS = { + 1: '''from typing import Dict +from configgen.Emulator import Emulator +from configgen.crt.CRTTypes import CRTAdapter +from configgen.settings.keyValueSettings import keyValueSettings +from configgen.controllers.controller import ControllerPerPlayer +from configgen.utils.architecture import Architecture + + +def _jvsCabFamily() -> str: + try: + with open("/sys/devices/platform/recalboxjvs/cab_family") as f: + return f.read().strip() + except OSError: + return "" + + +class LibretroCores:''', + 165: r''' def configureAtariSTNew(self, coreSettings: keyValueSettings): + # Machine list as defined in HatariB core + MACHINE_UNKNOWN = -1 + MACHINE_ST = 0 + MACHINE_MEGA_ST = 1 + MACHINE_STE = 2 + MACHINE_MEGA_STE = 3 + MACHINE_TT = 4 + MACHINE_FALCON = 5 + + # Try identify machine folder + machines: dict[str, int] = \ + { + "atari-st": MACHINE_ST, + "/st/": MACHINE_ST, + "atarimegast": MACHINE_MEGA_ST, + "atari-megast": MACHINE_MEGA_ST, + "atari-mega-st": MACHINE_MEGA_ST, + "megast": MACHINE_MEGA_ST, + "mega-st": MACHINE_MEGA_ST, + "atariste": MACHINE_STE, + "atari-ste": MACHINE_STE, + "/ste/": MACHINE_STE, + "atarimegaste": MACHINE_MEGA_STE, + "atari-megaste": MACHINE_MEGA_STE, + "atari-mega-ste": MACHINE_MEGA_STE, + "megaste": MACHINE_MEGA_STE, + "mega-ste": MACHINE_MEGA_STE, + "ataritt": MACHINE_TT, + "atari-tt": MACHINE_TT, + "/tt/": MACHINE_TT, + "falcon": MACHINE_FALCON, + "atari-falcon": MACHINE_FALCON, + } + defaultMachine: int = MACHINE_UNKNOWN + romLower: str = self.rom.lower() + for key in machines: + if key in romLower: + defaultMachine: int = machines[key] + break + if not defaultMachine == MACHINE_UNKNOWN: + coreSettings.setInt("hatarib_machine", defaultMachine) + print("MACHINE: {}".format(defaultMachine)) + else: + defaultMachine = coreSettings.getInt("hatarib_machine", MACHINE_ST) + + # Select bios + subdir: str = "atarist" + import os + biosFile = os.path.join(subdir, "st.img") + # ST/MEGAST use both st.img + if defaultMachine == MACHINE_FALCON : biosFile = os.path.join(subdir, "falcon.img") + elif defaultMachine == MACHINE_MEGA_STE: biosFile = os.path.join(subdir, "megaste.img") + elif defaultMachine == MACHINE_TT : biosFile = os.path.join(subdir, "tt.img") + elif defaultMachine == MACHINE_STE : biosFile = os.path.join(subdir, "ste.img") + import configgen.recalboxFiles as recalboxFiles + if not os.path.exists(os.path.join(recalboxFiles.BIOS, biosFile)): + biosFile = os.path.join(subdir, "tos.img") + if not os.path.exists(os.path.join(recalboxFiles.BIOS, biosFile)): + biosFile = "tos.img" + if not os.path.exists(os.path.join(recalboxFiles.BIOS, biosFile)): + biosFile = "" + if "emutos" in self.rom: + biosFile = os.path.join(recalboxFiles.BIOS, subdir, "") + coreSettings.setString("hatarib_tos", str(biosFile)) + print("BIOS : {}".format(biosFile)) + + def configureAtariST(self, _): + # Machines as defined in old Hatari core + MACHINE_UNKNOWN = -1 + MACHINE_ST = 0 + MACHINE_STE = 1 + MACHINE_TT = 2 + MACHINE_FALCON = 3, + MACHINE_MEGA_STE = 4 + + # Load config + from configgen.settings.iniSettings import IniSettings + import configgen.recalboxFiles as recalboxFiles + atariStSettings = IniSettings(recalboxFiles.hatariCustomConfig) + atariStSettings.loadFile(True) + + # Auto set machine + from typing import Dict + # Try identify machine folder + machines: Dict[str, int] = \ + { + "atari-st": MACHINE_ST, + "/st/": MACHINE_ST, + "atariste": MACHINE_STE, + "atari-ste": MACHINE_STE, + "/ste/": MACHINE_STE, + "ataritt": MACHINE_TT, + "atari-tt": MACHINE_TT, + "/tt/": MACHINE_TT, + "atarimegaste": MACHINE_MEGA_STE, + "atari-megaste": MACHINE_MEGA_STE, + "atari-mega-ste": MACHINE_MEGA_STE, + "megaste": MACHINE_MEGA_STE, + "mega-ste": MACHINE_MEGA_STE, + "falcon": MACHINE_FALCON, + "atari-falcon": MACHINE_FALCON, + } + defaultMachine: int = MACHINE_UNKNOWN + romLower: str = self.rom.lower() + for key in machines: + if key in romLower: + defaultMachine: int = machines[key] + break + if not defaultMachine == MACHINE_UNKNOWN: + print("MACHINE: {}".format(defaultMachine)) + atariStSettings.setString("System", "nMachineType", str(defaultMachine)) + + # Select bios + import os + subdir: str = "atarist" + biosPath = os.path.join(recalboxFiles.BIOS, subdir, "st.img") + if defaultMachine == MACHINE_STE : biosPath = os.path.join(recalboxFiles.BIOS, subdir, "ste.img") + elif defaultMachine == MACHINE_TT : biosPath = os.path.join(recalboxFiles.BIOS, subdir, "tt.img") + elif defaultMachine == MACHINE_MEGA_STE: biosPath = os.path.join(recalboxFiles.BIOS, subdir, "megaste.img") + elif defaultMachine == MACHINE_FALCON : biosPath = os.path.join(recalboxFiles.BIOS, subdir, "falcon.img") + if not os.path.exists(biosPath): + biosPath = os.path.join(recalboxFiles.BIOS, subdir, "tos.img") + if not os.path.exists(biosPath): + biosPath = os.path.join(recalboxFiles.BIOS, "tos.img") + if not os.path.exists(biosPath): + biosPath = os.path.join(recalboxFiles.BIOS, subdir, "emutos.img") + if "emutos" in self.rom: + biosPath = os.path.join(recalboxFiles.BIOS, subdir, "emutos.img") + atariStSettings.setString("ROM", "szTosImageFileName", str(biosPath)) + print("BIOS : {}".format(biosPath)) + + # Save config + atariStSettings.saveFile()''', + 348: ''' + @staticmethod + def configureOdyssey2(coreSettings: keyValueSettings): + coreSettings.setString("o2em_bios", '"o2rom.bin"') + + @staticmethod + def configureVideoPacPlus(coreSettings: keyValueSettings): + coreSettings.setString("o2em_bios", '"g7400.bin"')''', + 492: ''' def configureDolphin(self, coreSettings: keyValueSettings) -> None: + coreSettings.setString("dolphin_emulator_speed", '"1.0"') + coreSettings.setString("dolphin_cpu_clock_rate", '"1.0"') + + ipl0 = "/recalbox/share/bios/gamecube/EUR/IPL.bin" + ipl1 = "/recalbox/share/bios/gamecube/JP/IPL.bin" + ipl2 = "/recalbox/share/bios/gamecube/USA/IPL.bin" + + import os.path + if os.path.exists(ipl0) or os.path.exists(ipl1) or os.path.exists(ipl2): + coreSettings.setString("dolphin_skip_gc_bios", '"disabled"') + else: + coreSettings.setString("dolphin_skip_gc_bios", '"enabled"')''', + 633: r''' # Fill cores configuration + def fillCoresConfiguration(self): + settings = self.settings + + # Specific configuration handlers per core + specificCoreHandlers =\ + { + "bluemsx" : LibretroCores.configureBlueMSX, + "dolphin": self.configureDolphin, + "mame2003_plus": LibretroCores.configureMAME2003plus, + "mame": self.configureMAME, + "mame0258": self.configureMAME, + "mame0278": self.configureMAME, + "mupen64plus_nx" : LibretroCores.configureMUPEN64, + "opera": LibretroCores.configureOpera, + "parallel_n64": LibretroCores.configurePARALLELN64, + "np2kai" : LibretroCores.configureNPKAI, + "picodrive" : self.configurePicodrive, + "swanstation" : self.configureSwanstation, + "dosbox_pure" : LibretroCores.configureDosBoxPure, + "flycast": self.configureFlycast, + "flycast-next": self.configureFlycast, + "fbneo": self.configureFBNeo, + "scummvm": self.configureScummvm, + "hatari": self.configureAtariST, + "hatarib": self.configureAtariSTNew, + "melonds": LibretroCores.configureMelonDS, + "desmume": LibretroCores.configureDesmume, + "ppsspp": self.configurePPSSPP, + "pcsx_rearmed": self.configurePCSXRearmed, + "mednafen_psx_hw": self.configureBeetlePSX, + "mednafen_psx": self.configureBeetlePSX, + "gambatte": self.configureGambatte, + "mednafen_stv" : self.configureSTV, + "eightyone": self.configureEightyOne, + "tgbdual": self.configureTGBDual, + } + + # Get handler and execute + core = self.system.Core + if core in specificCoreHandlers: + function = specificCoreHandlers.get(core) + function(settings) + + # Specific configuration handlers per system + specificSystemHandlers =\ + { + "amiga600" : LibretroCores.configureAmiga600, + "amiga1200" : LibretroCores.configureAmiga1200, + "amigacdtv" : LibretroCores.configureAmigaCDTV, + "amigacd32" : LibretroCores.configureAmigaCD32, + "atari5200" : LibretroCores.configureAtari5200, + "atari800" : LibretroCores.configureAtari800, + "amstradcpc" : LibretroCores.configureAmstradCPC, + "gx4000" : LibretroCores.configureAmstradGX4000, + "nds" : self.configureNDS, + "spectravideo" : LibretroCores.configureSpectravideo, + "64dd" : LibretroCores.configure64DD, + "msx1" : LibretroCores.configureMsx1, + "msx2" : LibretroCores.configureMsx2, + "msxturbor" : LibretroCores.configureMsxTurboR, + "pc98" : LibretroCores.configureNPKAI, + "odyssey2" : LibretroCores.configureOdyssey2, + "videopacplus" : LibretroCores.configureVideoPacPlus, + "sg1000" : LibretroCores.configureSg1000, + "naomi2" : self.configureNaomi2, + "saturn" : self.configureSaturn, + } + + # Get handler and execute + if self.system.Name in specificSystemHandlers: + function = specificSystemHandlers.get(self.system.Name) + function(settings)''', +} + +# libretroGenerator.py:7, 22, 814-815, 876-883 +LIBRETRO_GENERATOR_BLOCKS = { + 7: '''import configgen.recalboxFiles as recalboxFiles''', + 22: '''class LibretroGenerator(Generator):''', + 814: ''' @staticmethod + def buildRomArguments(rom: str, core: str, verbose: bool, demo: bool) -> List[str]:''', + 876: ''' # macintosh + if core == "minivmac": + with open(recalboxFiles.minivmacRomFile, 'w') as f: + f.write('"Mini vMac" "' + recalboxFiles.minivmacOsFile + '" "' + rom + '"') + + rom = [recalboxFiles.minivmacRomFile] + return rom +''', +} + +# hatariGenerator.py:1-14, 103-167 +HATARI_BLOCKS = { + 1: '''import os +from typing import List, Dict + +from configgen.Command import Command +from configgen.Emulator import Emulator +from configgen.controllers.controller import ControllerPerPlayer +from configgen.generators.Generator import Generator +import configgen.recalboxFiles as recalboxFiles +from configgen.settings.iniSettings import IniSettings +import configgen.settings.configOverriding as configOverriding +from configgen.settings.keyValueSettings import keyValueSettings + + +class HatariGenerator(Generator):''', + 103: r''' + @staticmethod + def SetMachineAndBios(config: IniSettings, rom: str): + # Machine list + MACHINE_UNKNOWN = -1 + MACHINE_ST = 0 + MACHINE_MEGA_ST = 1 + MACHINE_STE = 2 + MACHINE_MEGA_STE = 3 + MACHINE_TT = 4 + MACHINE_FALCON = 5 + + # Try identify machine folder + machines: Dict[str, int] = \ + { + "atari-st": MACHINE_ST, + "/st/": MACHINE_ST, + "atarimegast": MACHINE_MEGA_ST, + "atari-megast": MACHINE_MEGA_ST, + "atari-mega-st": MACHINE_MEGA_ST, + "megast": MACHINE_MEGA_ST, + "mega-st": MACHINE_MEGA_ST, + "atariste": MACHINE_STE, + "atari-ste": MACHINE_STE, + "/ste/": MACHINE_STE, + "atarimegaste": MACHINE_MEGA_STE, + "atari-megaste": MACHINE_MEGA_STE, + "atari-mega-ste": MACHINE_MEGA_STE, + "megaste": MACHINE_MEGA_STE, + "mega-ste": MACHINE_MEGA_STE, + "ataritt": MACHINE_TT, + "atari-tt": MACHINE_TT, + "/tt/": MACHINE_TT, + "falcon": MACHINE_FALCON, + "atari-falcon": MACHINE_FALCON, + } + defaultMachine: int = MACHINE_UNKNOWN + romLower: str = rom.lower() + for key in machines: + if key in romLower: + defaultMachine: int = machines[key] + break + if not defaultMachine == MACHINE_UNKNOWN: + config.setInt("System", "nMachineType", defaultMachine) + print("MACHINE: {}".format(defaultMachine)) + else: + defaultMachine = coreSettings.getInt("hatarib_machine", MACHINE_ST) + + # Select bios + subdir: str = "atarist" + biosFile = os.path.join(recalboxFiles.BIOS, subdir, "st.img") + # ST/MEGAST use both st.img + if defaultMachine == MACHINE_FALCON : biosFile = os.path.join(recalboxFiles.BIOS, subdir, "falcon.img") + elif defaultMachine == MACHINE_MEGA_STE: biosFile = os.path.join(recalboxFiles.BIOS, subdir, "megaste.img") + elif defaultMachine == MACHINE_TT : biosFile = os.path.join(recalboxFiles.BIOS, subdir, "tt.img") + elif defaultMachine == MACHINE_STE : biosFile = os.path.join(recalboxFiles.BIOS, subdir, "ste.img") + if not os.path.exists(biosFile): + biosFile = os.path.join(recalboxFiles.BIOS, subdir, "tos.img") + if not os.path.exists(biosFile): + biosFile = os.path.join(recalboxFiles.BIOS, "tos.img") + if not os.path.exists(biosFile): + biosFile = os.path.join(recalboxFiles.BIOS, subdir, "emutos.img") + if "emutos" in rom: + biosFile = os.path.join(recalboxFiles.BIOS, subdir, "emutos.img") + config.setString("ROM", "szTosImageFileName", str(biosFile))''', +} + +# gsplusGenerator.py:1-11, 93-148 +GSPLUS_BLOCKS = { + 1: '''from typing import List + +from configgen.Command import Command +import configgen.recalboxFiles as recalboxFiles +from configgen.Emulator import Emulator +from configgen.generators.Generator import Generator +from configgen.settings.keyValueSettings import keyValueSettings +from configgen.controllers.controller import Controller, InputItem, ControllerPerPlayer + + +class GSplusGenerator(Generator):''', + 93: ''' def generate(self, system: Emulator, playersControllers: ControllerPerPlayer, recalboxOptions: keyValueSettings, args): + """ + Load, override keys and save back emulator's configuration file + This way, any modification is kept accross emulator launches + """ + + slot: int = -1 + if ".gsplus" in args.rom: + settings = keyValueSettings(args.rom, True) + settings.loadFile(True) + settings.changeSettingsFile(recalboxFiles.gsplusConfig) + + # Adjust BIOS path + import os + if settings.getString("g_cfg_rom_path", "") == "%1": + settings.setString("g_cfg_rom_path", os.path.join(recalboxFiles.BIOS, "apple2gs1.rom")) + elif settings.getString("g_cfg_rom_path", "") == "%3": + settings.setString("g_cfg_rom_path", os.path.join(recalboxFiles.BIOS, "apple2gs3.rom")) + + # Adjust rom path + folder = os.path.dirname(args.rom) + for i in range(1, 33): + if i < 3: + if settings.hasOption("s5d{}".format(i)) and settings.getString("s5d{}".format(i), "") != "": + settings.setString("s5d{}".format(i), os.path.join(folder, settings.getString("s5d{}".format(i), ""))) + if settings.hasOption("s6d{}".format(i)) and settings.getString("s6d{}".format(i), "") != "": + settings.setString("s6d{}".format(i), os.path.join(folder, settings.getString("s6d{}".format(i), ""))) + if settings.hasOption("s7d{}".format(i)) and settings.getString("s7d{}".format(i), "") != "": + settings.setString("s7d{}".format(i), os.path.join(folder, settings.getString("s7d{}".format(i), ""))) + + else: + # Load config file + settings = keyValueSettings(recalboxFiles.gsplusConfig, True) + settings.loadFile(True) + + # Reset drive slots + for i in range(1, 33): + if i < 3: + if settings.hasOption("s5d{}".format(i)): + settings.setString("s5d{}".format(i), "") + if settings.hasOption("s6d{}".format(i)): + settings.setString("s6d{}".format(i), "") + if settings.hasOption("s7d{}".format(i)): + settings.setString("s7d{}".format(i), "") + + # Reset bios + import os + bios = os.path.join(recalboxFiles.BIOS, "apple2gs1.rom") + if os.path.exists(bios): + settings.setString("g_cfg_rom_path", bios) + else: + bios = os.path.join(recalboxFiles.BIOS, "apple2gs3.rom") + if os.path.exists(bios): + settings.setString("g_cfg_rom_path", bios) + else: + settings.removeOption("g_cfg_rom_path")''', +} + +# amiberryKickstarts.py:1-151 +AMIBERRY_KICKSTARTS_BLOCKS = { + 1: r'''#!/usr/bin/env python +import os + +import configgen.recalboxFiles as recalboxFiles +from configgen.generators.amiberry.amiberryRomType import RomType +from configgen.generators.amiberry.amiberrySubSystems import SubSystems + +# Kickstarts live in the amiga subfolder (see es_bios.xml and amiberry rom_path) +def AmigaBios(name: str) -> str: + return os.path.join(recalboxFiles.BIOS, "amiga", "bios", name) + + +class KickstartManager: + + BIOS_LIST = \ + { + # Kickstart v1.2 rev 33.180 (1986)(Commodore)(A500-A1000-A2000).rom + "kick33180.A500": (256, "KS ROM v1.2 (A500,A1000,A2000) rev 33.180 (256k) [315093-01]"), + # Kickstart v1.3 rev 34.5 (1987)(Commodore)(A500-A1000-A2000-CDTV).rom + "kick34005.A500": (256, "KS ROM v1.3 (A500,A1000,A2000) rev 34.5 (256k) [315093-02]"), + # Kickstart v2.04 rev 37.175 (1991)(Commodore)(A500+).rom + "kick37175.A500": (256, "KS ROM v2.04 (A500+) rev 37.175 (512k) [390979-01]"), + # Kickstart v3.0 rev 39.106 (1992)(Commodore)(A1200)[!].rom + "kick39106.A1200": (256, "KS ROM v3.0 (A1200) rev 39.106 (512k) [391523-01/391524-01]"), + # Kickstart v3.1 rev 40.63 (1993)(Commodore)(A500-A600-A2000)[!].rom + "kick40063.A600": (256, "KS ROM v3.1 (A600) rev 40.63 (512k)"), + # Kickstart v3.1 rev 40.68 (1993)(Commodore)(A1200).rom + "kick40068.A1200": (256, "KS ROM v3.1 (A1200) rev 40.68 (512k) [391773-01/391774-01]"), + # Kickstart v3.1 rev 40.68 (1993)(Commodore)(A4000).rom + "kick40068.A4000": (256, "KS ROM v3.1 (A4000) rev 40.68 (512k)"), + + # Kickstart v1.3 rev 34.5 (1987)(Commodore)(A500-A2000-CDTV)[!].rom + "kick34005.CDTV": (256, "CDTV Extended ROM rev 34.5 (256k)"), + + # Kickstart v3.1 rev 40.60 (1993)(Commodore)(CD32).rom + "kick40060.CD32": (512, "CD32 KS ROM v3.1 rev 40.60 (512k)"), + # CD32 Extended-ROM rev 40.60 (1993)(Commodore)(CD32).rom 87746be2 + "kick40060.CD32.ext": (1024, "CD32 extended ROM rev 40.60 (512k)"), + + # AROS Kickstart 2019 + "kick02019.AROS": (256, "KS AROS 2019 Part#1 (512k)"), + # AROS Extended Kickstart 2019 + "kick02019.AROS.ext": (256, "KS AROS 2019 Part#2 (512k)"), + } + + BIOS_PER_SYSTEM = \ + { + SubSystems.A600: + [ + ( # Use the highest kickstart with advanced hardware + [RomType.WHDL, RomType.HDF, RomType.PACKAGE, RomType.CDROM, RomType.HDDFS], + ["kick37175.A500", "kick34005.A500", "kick33180.A500"] + ), + ( # Use kickstarts 1.3/1.2 to maximize compatibility when using disks + [RomType.DISK], + ["kick34005.A500", "kick33180.A500"] + ), + ], + SubSystems.A1200: + [ + ( # Use kickstart 3.1 first, then 3.0. Fallback on A4000 kickstart + [RomType.WHDL, RomType.HDF, RomType.PACKAGE, RomType.CDROM, RomType.HDDFS, RomType.DISK], + ["kick40068.A1200", "kick39106.A1200", "kick40068.A4000"] + ), + ], + SubSystems.CD32: + [ + ( # No choice for the CD32 + [RomType.CDROM], + ["kick40060.CD32|kick40060.CD32.ext"] + ), + ], + SubSystems.CDTV: + [ + ( # No choice for the CD32 + [RomType.CDROM], + ["kick34005.A500|kick34005.CDTV"] + ), + ], + } + + def __init__(self): + self.rom: str = "" + self.ext: str = "" + + @staticmethod + def GenerateWHDSymLinks(destinationfolder: str): + for bios in KickstartManager.BIOS_LIST: + biosPath = AmigaBios(bios) + biosLink = os.path.join(destinationfolder, bios) + if not os.path.exists(biosLink) and os.path.exists(biosPath): + os.symlink(biosPath, biosLink) + + def GetKickstartsFor(self, subsystem: SubSystems, romtype: RomType): + # Select best available kickstart + biosFile = None + extFile = None + needExt = False + tuppleArray = KickstartManager.BIOS_PER_SYSTEM[subsystem] + for tupple in tuppleArray: + romTypes, biosList = tupple + if romtype in romTypes: + for biosCouple in biosList: + bios = biosCouple + ext = None + if '|' in biosCouple: + bios, ext = biosCouple.split('|', 2) + needExt = ext is not None + biosPath = AmigaBios(bios) + if os.path.exists(biosPath): + biosFile = biosPath + if needExt: + extPath = AmigaBios(ext) + if os.path.exists(extPath): + extFile = extPath + if biosFile is not None: + break + + # Fallbacks + if subsystem in SubSystems.COMPUTERS: + # Fallback to AROS 2019? + if biosFile is None: + arosBios = AmigaBios("kick02019.AROS") + arosExt = AmigaBios("kick02019.AROS.ext") + if os.path.exists(arosBios) and os.path.exists(arosExt): + biosFile = arosBios + extFile = arosExt + + # Fallback to internal Aros bios + if biosFile is None: + self.rom = ":AROS" + return + + # Last check + if biosFile is None: + raise Exception("No BIOS found") + # If an extened bios is required, it's not worth going further w/o it + if extFile is None and needExt: + raise Exception("Extended BIOS not found") + + self.rom = biosFile if biosFile is not None else "" + self.ext = extFile if extFile is not None else "" + + @property + def GetBIOS(self) -> str: return self.rom + + @property + def NeedExtendedBIOS(self) -> bool: return len(self.ext) != 0 + + @property + def GetExtendedBIOS(self) -> str: return self.ext''', +} + +# amiberrySubSystems.py:1-13 +AMIBERRY_SUBSYSTEMS_BLOCKS = { + 1: '''#!/usr/bin/env python + + +class SubSystems: + UNKNOWN = "unknown" + A600 = "amiga600" + A1200 = "amiga1200" + CDTV = "amigacdtv" + CD32 = "amigacd32" + + COMPUTERS = ( A600, A1200 ) + CONSOLES = ( CDTV, CD32 ) + HAVEMOUSE = ( A600, A1200, CDTV, CD32 )''', +} + +# amiberryConfig.py:1-9, 337-345 +AMIBERRY_CONFIG_BLOCKS = { + 1: '''#!/usr/bin/env python +from typing import List + +from configgen.generators.amiberry.amiberrySubSystems import SubSystems +from configgen.controllers.controller import ControllerPerPlayer +from configgen.generators.amiberry.amiberryRomType import RomType + + +class ConfigGenerator:''', + 337: ''' def SetKickstarts(self, subsystem: SubSystems, romtype: RomType): + from configgen.generators.amiberry.amiberryKickstarts import KickstartManager + manager = KickstartManager() + manager.GetKickstartsFor(subsystem, romtype) + kickstart = manager.GetBIOS + self.settings.setString("kickstart_rom_file", kickstart) + if manager.NeedExtendedBIOS: + extended = manager.GetExtendedBIOS + self.settings.setString("kickstart_ext_rom_file", extended)''', +} + +# pcsx2Generator.py:1-166 +PCSX2_BLOCKS = { + 1: r'''#!/usr/bin/env python +from typing import Dict + +from configgen.Command import Command +from configgen.Emulator import Emulator +from configgen.controllers.controller import ControllerPerPlayer +from configgen.emulatorlauncher import recalboxFiles +from configgen.generators.Generator import Generator +from configgen.settings.iniSettings import IniSettings +from configgen.settings.keyValueSettings import keyValueSettings + +class Pcsx2Generator(Generator): + + pcsx2RootFolder = recalboxFiles.HOME + '/.config/PCSX2' + pcsx2ConfigFileReg = pcsx2RootFolder + '/PCSX2-reg.ini' + pcsx2ConfigFileGSdx = pcsx2RootFolder + '/GSdx.ini' + pcsx2ConfigFilePAD = pcsx2RootFolder + '/PAD.ini' + pcsx2ConfigFilePCSX2_ui = pcsx2RootFolder + '/PCSX2_ui.ini' + pcsx2ConfigFilePCSX2_vm = pcsx2RootFolder + '/PCSX2_vm.ini' + pcsx2ConfigFileSPU2 = pcsx2RootFolder + '/SPU2.ini' + + SECTION_DEFAULT = "default" + # Core + SECTION_VM_EMUCORE_GS = "EmuCore/GS" + SECTION_VM_EMUCORE_SPEEDHACKS = "EmuCore/Speedhacks" + # UI + SECTION_UI_NONE = "" + SECTION_UI_GSWINDOWS = "GSWindow" + SECTION_UI_POPUPDISABLERS = "PopupDisablers" + SECTION_UI_FILENAMES = "Filenames" + # Sound (SPU2.ini) + SECTION_SPU2_MIXING = "MIXING" + SECTION_SPU2_OUTPUT = "OUTPUT" + SECTION_SPU2_SDL = "SDL" + + # Bios names + BIOS_USA = "ps2-0230a-20080220.bin" + BIOS_EU = "ps2-0230e-20080220.bin" + BIOS_JP = "ps2-0230j-20080220.bin" + BIOS_HK = "ps2-0230h-20080220.bin" + + @staticmethod + def __ConfigurePad(playersControllers: ControllerPerPlayer, system: Emulator): + with open(Pcsx2Generator.pcsx2ConfigFilePAD, "w") as f: + if system.Rumble: + f.write("options = 1\n") + f.write("mouse_sensitivity = 100\n") + f.write("ff_intensity = 32767\n") + + if 1 in playersControllers: f.write("uid[0] = {}\n".format(playersControllers[1].SdlIndex)) + if 2 in playersControllers: f.write("uid[1] = {}\n".format(playersControllers[2].SdlIndex)) + if 1 in playersControllers: f.write("SDL2 = {}\n".format(playersControllers[1].generateSDLGameDBLine(False))) + if 2 in playersControllers: f.write("SDL2 = {}\n".format(playersControllers[2].generateSDLGameDBLine(False))) + + @staticmethod + def __CheckPs2Bios(rom: str) -> str: + import os.path + + def Exists(romPath: str) -> bool: + return os.path.exists(os.path.join(recalboxFiles.BIOS, "ps2", romPath)) + + # Try to get Redump region + lrom = rom.lower() + # EU region + if "europe" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "france" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "ireland" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "germany" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "belgium" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "italy" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "greece" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "croatia" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "denmark" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "spain" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "portugal" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "poland" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "finland" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "russia" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "austria" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "netherlands" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "switzerland" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "norway" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "sweden" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "scandinavia" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "australia" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "india" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "south africa" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + # USA region + elif "canada" in lrom and Exists(Pcsx2Generator.BIOS_USA): return Pcsx2Generator.BIOS_USA + elif "brazil" in lrom and Exists(Pcsx2Generator.BIOS_USA): return Pcsx2Generator.BIOS_USA + elif "latin america" in lrom and Exists(Pcsx2Generator.BIOS_USA): return Pcsx2Generator.BIOS_USA + # JP region + elif "japan" in lrom and Exists(Pcsx2Generator.BIOS_JP): return Pcsx2Generator.BIOS_JP + elif "korea" in lrom and Exists(Pcsx2Generator.BIOS_JP): return Pcsx2Generator.BIOS_JP + elif "asia" in lrom and Exists(Pcsx2Generator.BIOS_JP): return Pcsx2Generator.BIOS_JP + # CH + elif "china" in lrom and Exists(Pcsx2Generator.BIOS_JP): return Pcsx2Generator.BIOS_HK + elif "taiwan" in lrom and Exists(Pcsx2Generator.BIOS_JP): return Pcsx2Generator.BIOS_HK + # Shortest name at the end + elif "uk" in lrom and Exists(Pcsx2Generator.BIOS_EU): return Pcsx2Generator.BIOS_EU + elif "usa" in lrom and Exists(Pcsx2Generator.BIOS_USA): return Pcsx2Generator.BIOS_USA + + # Check if PS2 bios bin in folders + for bios in (Pcsx2Generator.BIOS_USA, Pcsx2Generator.BIOS_EU, Pcsx2Generator.BIOS_JP, Pcsx2Generator.BIOS_HK): + if Exists(bios): + return bios + return "" + + @staticmethod + def __ConfigureReg(): + reg = keyValueSettings(Pcsx2Generator.pcsx2ConfigFileReg) + reg.loadFile(True) + + # Override Reg Configuration + reg.setString("DocumentsFolderMode", "User") + reg.setString("CustomDocumentsFolder", "/usr/bin/PCSX2") + reg.setString("UseDefaultSettingsFolder", "enabled") + reg.setString("SettingsFolder", "/recalbox/share/system/.config/PCSX2") + reg.setString("Install_Dir", "/usr/bin/PCSX2") + reg.setString("RunWizard", "0") + + # Save configuration + reg.saveFile() + + @staticmethod + def __ConfigureCore(): + import multiprocessing + NbCpu = multiprocessing.cpu_count() + + # Override VM Configuration + vm = IniSettings(Pcsx2Generator.pcsx2ConfigFilePCSX2_vm, True) + vm.loadFile(True) + + vm.setString(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "FrameLimitEnable", "enabled") + vm.setString(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "FramerateNTSC", "59.94") + vm.setInt(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "FrameratePAL", 50) + vm.setString(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "FrameSkipEnable", "disabled") + vm.setInt(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "FramesToDraw", 2) + vm.setInt(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "FramesToSkip", 2) + vm.setString(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "LimitScalar", "1.00") + vm.setString(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "SynchronousMTGS", "disabled") + vm.setInt(Pcsx2Generator.SECTION_VM_EMUCORE_GS, "VsyncQueueSize", 2) + vm.setString(Pcsx2Generator.SECTION_VM_EMUCORE_SPEEDHACKS, "vuThread", "enabled" if NbCpu > 3 else "disabled") + + # Save configuration + vm.saveFile() + + @staticmethod + def __ConfigureUI(system: Emulator, rom: str): + # Game Ratio + GAME_RATIO: Dict[str, str] = \ + { + "16/9": "16:9", + "4/3": "4:3", + } + + gameRatio = GAME_RATIO[system.Ratio] if system.Ratio in GAME_RATIO else "4:3" + + # override UI Configuration + ui = IniSettings(Pcsx2Generator.pcsx2ConfigFilePCSX2_ui, True) + ui.defineBool("1", "0").loadFile(True) + + ui.setString(Pcsx2Generator.SECTION_UI_NONE, "AskOnBoot", "disabled") + ui.setString(Pcsx2Generator.SECTION_UI_GSWINDOWS, "AspectRatio", gameRatio) + ui.setString(Pcsx2Generator.SECTION_UI_POPUPDISABLERS, "Dialog.ComponentsConfig.Overrides", "disabled,ok") + ui.setString(Pcsx2Generator.SECTION_UI_FILENAMES, "BIOS", Pcsx2Generator.__CheckPs2Bios(rom))''', +} + +# dolphinGenerator.py:1-13, 113, 152-163, 228-273 +DOLPHIN_BLOCKS = { + 1: '''#!/usr/bin/env python3 +from configgen.Command import Command +import configgen.recalboxFiles as recalboxFiles +from configgen.Emulator import Emulator +from configgen.controllers.controller import ControllerPerPlayer +from configgen.generators.Generator import Generator +from configgen.settings.keyValueSettings import keyValueSettings +from configgen.utils.Vulkan import Vulkan +from configgen.utils.architecture import Architecture +from configgen.utils.videoMode import createCrtDesktopRes, getCrtResolution, deleteCrtDesktopRes + + +class DolphinGenerator(Generator):''', + 113: ''' SECTION_CORE = "Core"''', + 152: ''' # Check if Gamecube bios IPL.bin in folders + @staticmethod + def CheckGamecubeIpl() -> str: + ipl0 = "/recalbox/share/bios/gamecube/EUR/IPL.bin" + ipl1 = "/recalbox/share/bios/gamecube/JP/IPL.bin" + ipl2 = "/recalbox/share/bios/gamecube/USA/IPL.bin" + # if os.path? return "True" set "False" for disable "SkipIpl=Video boot intro" + import os.path + if os.path.exists(ipl0) or os.path.exists(ipl1) or os.path.exists(ipl2): + return "False" + else: + return "True"''', + 228: r''' def mainConfiguration(self, system: Emulator, args): + # Get Languages + language = self.GetLanguage() + systemLanguage = self.GetFrontendLanguage() + gamecubeLanguage = self.GAMECUBE_LANGUAGES[language] if language in self.GAMECUBE_LANGUAGES else 0 + # Get Netplay configs + nickname = self.GetNetplay() + lobbyServer = self.NETPLAY_SERVERS[systemLanguage] if systemLanguage in self.NETPLAY_SERVERS else None + ## Get if we have all IPL.bin in folders Gc bios + biosGamecube = self.CheckGamecubeIpl() + ## set Gamecube Wii U adapter + padsGamecube = self.SetGamecubeWiiUAdapter() + # Get current rom path + import os + romName = os.path.basename(args.rom) + homeDir = args.rom.replace("/" + romName, "") + + # Load Configuration + from configgen.settings.iniSettings import IniSettings + dolphinSettings = IniSettings(DolphinGenerator.iniFile, True) + dolphinSettings.loadFile(True) \ + .defineBool('True', 'False') + + # Resolution + from configgen.utils.resolutions import ResolutionParser + resolution = ResolutionParser(system.VideoMode) + if resolution.isSet and resolution.selfProcess: + dolphinSettings.setString(self.SECTION_DISPLAY, "FullscreenResolution", resolution.string) + dolphinSettings.setBool(self.SECTION_DISPLAY, "Fullscreen", True) + if Architecture().isX64: + dolphinSettings.setBool(self.SECTION_DISPLAY, "RenderToMain", True) + dolphinSettings.setBool(self.SECTION_DISPLAY, "KeepWindowOnTop", True) + # Logging + dolphinSettings.setBool(self.SECTION_DSP, "CaptureLog", args.verbose) + # Force audio backend on Pulse Audio + dolphinSettings.setString(self.SECTION_DSP, "Backend", "Pulse") + # Analytics + dolphinSettings.setBool(self.SECTION_ANALYTICS, "Enabled", True) + dolphinSettings.setBool(self.SECTION_ANALYTICS, "PermissionAsked", True) + # Bluetooth Pass-trough + dolphinSettings.setBool(self.SECTION_BTPASSTHROUGH, "Enabled", False) + # Core + dolphinSettings.setInt(self.SECTION_CORE, "SelectedLanguage", gamecubeLanguage) + dolphinSettings.setBool(self.SECTION_CORE, "WiimoteContinuousScanning", True) + dolphinSettings.setBool(self.SECTION_CORE, "WiiKeyboard", False) + dolphinSettings.setString(self.SECTION_CORE, "SkipIpl", biosGamecube)''', +} + +# xemuGenerator.py:1-27, 30-60 +XEMU_BLOCKS = { + 1: """#!/usr/bin/env python3 +import shutil +import os.path +from configgen.Command import Command +from configgen.Emulator import Emulator +from configgen.generators.Generator import Generator, ControllerPerPlayer +import configgen.recalboxFiles as recalboxFiles +from configgen.settings.keyValueSettings import keyValueSettings +from configgen.utils.Vulkan import Vulkan +from configgen.generators.xemu.xemuControllers import XemuControllers + + +class XemuGenerator(Generator): + SECTION_GENERAL = "general" + SECTION_SYS = "sys" + SECTION_SYS_FILES = "sys.files" + SECTION_AUDIO = "audio" + SECTION_DISPLAY = "display" + SECTION_DISPLAY_QUALITY = "display.quality" + SECTION_DISPLAY_UI = "display.ui" + SECTION_DISPLAY_WINDOW = "display.window" + SECTION_INPUT = "input" + SECTION_NET = "net" + + savePath = recalboxFiles.SAVES + "/xbox" + biosPath = recalboxFiles.BIOS + "/xbox" + optionsConfigFile = recalboxFiles.CONF + '/xemu/xemu_config.ini'""", + 30: ''' def mainConfiguration(self, system: Emulator, args): + # Load xemu config file defined in frontend options and get values + from configgen.settings.iniSettings import IniSettings + configSettings = IniSettings(self.optionsConfigFile, True) + configSettings.loadFile(True).defineBool('true', 'false') + + skip_boot_anim = configSettings.getBool(self.SECTION_GENERAL, "skip_boot_anim", False) + show_menubar = configSettings.getBool(self.SECTION_DISPLAY_UI, "show_menubar", False) + show_notifications = configSettings.getBool(self.SECTION_DISPLAY_UI, "show_notifications", False) + fit = configSettings.getString(self.SECTION_DISPLAY_UI, "fit", "'scale'") + aspect_ratio = configSettings.getString(self.SECTION_DISPLAY_UI, "aspect_ratio", "'native'") + surface_scale = configSettings.getInt(self.SECTION_DISPLAY_QUALITY, "surface_scale", 1) + + print(f"Xemu configuration: skip_boot_anim:{skip_boot_anim}") + + # Load configuration + xemuSettings = IniSettings(self.xemuConfigFile, True) + xemuSettings.loadFile(True).defineBool('true', 'false') + + # General + xemuSettings.setBool(self.SECTION_GENERAL, "skip_boot_anim", skip_boot_anim) + xemuSettings.setBool(self.SECTION_GENERAL, "show_welcome", False) + xemuSettings.setString(self.SECTION_GENERAL, "screenshot_dir", f"'{recalboxFiles.SCREENSHOTS}'") + + # Sys + xemuSettings.setString(self.SECTION_SYS, "mem_limit", "'128'") + + # Sys.files + xemuSettings.setString(self.SECTION_SYS_FILES, "flashrom_path", f"'{self.biosPath}/Complex_4627.bin'") + xemuSettings.setString(self.SECTION_SYS_FILES, "bootrom_path", f"'{self.biosPath}/mcpx_1.0.bin'") + xemuSettings.setString(self.SECTION_SYS_FILES, "hdd_path", f"'{self.savePath}/xbox_hdd.qcow2'")''', +} + +# mupenConfig.py:1-5, 117-123 +MUPEN_CONFIG_BLOCKS = { + 1: '''#!/usr/bin/env python +import configgen.recalboxFiles as recalboxFiles +from configgen.Emulator import Emulator +from configgen.settings.iniSettings import IniSettings +from configgen.controllers.controller import InputItem, ControllerPerPlayer, Controller''', + 117: '''def __setIpl64DD(system: Emulator, mupenSettings: IniSettings): + # Set ipl only for n64 + section = '64DD' + if system.Name == "64dd": + mupenSettings.setString(section, "IPL-ROM", "/recalbox/share/bios/64DD_IPL.bin") + elif system.Name == "n64": + mupenSettings.setString(section, "IPL-ROM", "")''', +} + +BLOCKS = { + FILES_MODULE: FILES_MODULE_BLOCKS, + LAUNCHER: LAUNCHER_BLOCKS, + RETROARCH: RETROARCH_BLOCKS, + CORES: CORES_BLOCKS, + LIBRETRO_GENERATOR: LIBRETRO_GENERATOR_BLOCKS, + HATARI: HATARI_BLOCKS, + GSPLUS: GSPLUS_BLOCKS, + AMIBERRY_KICKSTARTS: AMIBERRY_KICKSTARTS_BLOCKS, + AMIBERRY_SUBSYSTEMS: AMIBERRY_SUBSYSTEMS_BLOCKS, + AMIBERRY_CONFIG: AMIBERRY_CONFIG_BLOCKS, + PCSX2: PCSX2_BLOCKS, + DOLPHIN: DOLPHIN_BLOCKS, + XEMU: XEMU_BLOCKS, + MUPEN_CONFIG: MUPEN_CONFIG_BLOCKS, +} + + +def files(path: str | None = None, old: str = "", new: str = "") -> dict[str, str]: + """The configgen tree, with `old` replaced once by `new` in `path`.""" + tree = {name: placed(blocks) for name, blocks in BLOCKS.items()} + if path is not None: + if old not in tree[path]: + raise AssertionError(f"{old!r} is not in {path}") + tree[path] = tree[path].replace(old, new, 1) + return tree + + +def configgen(path: str | None = None, old: str = "", new: str = "") -> Configgen: + return Configgen(files(path, old, new).__getitem__) + + +def ref(path: str, lines: str) -> str: + return f"{path}:{lines}" + + +class SystemDirectoryPerCore(unittest.TestCase): + """libretroRetroarch.py:431-529, the directory RetroArch is given.""" + + @classmethod + def setUpClass(cls) -> None: + cls.records = system_directories(configgen()) + + def by(self, emulator: str) -> list[Launcher]: + return [r for r in self.records if r.emulator == emulator] + + def test_a_core_with_one_directory(self): + gpsp, = self.by("gpsp") + self.assertEqual(gpsp.kind, "passes_path") + self.assertEqual(gpsp.build, "libretro") + self.assertEqual(gpsp.names, []) + self.assertEqual(gpsp.search_dir, "/recalbox/share/bios/gba") + self.assertEqual(gpsp.passes_to, "retroarch system_directory") + self.assertIsNone(gpsp.depends_on_option) + self.assertEqual(gpsp.source_ref, ref(RETROARCH, "472-473")) + geargrafx, = self.by("geargrafx") + self.assertEqual(geargrafx.search_dir, "/recalbox/share/bios/pcenginecd") + + def test_a_core_whose_directory_depends_on_the_system(self): + self.assertEqual( + [(r.search_dir, r.depends_on_option, r.source_ref) for r in self.by("bsnes")], + [ + ("/recalbox/share/bios/sgb", "system gb, gbc", ref(RETROARCH, "436-438")), + ("/recalbox/share/bios/satellaview", "system satellaview", + ref(RETROARCH, "436, 439-440")), + ("/recalbox/share/bios", "system other than gb, gbc, satellaview", + ref(RETROARCH, "436, 441-442")), + ], + ) + self.assertEqual( + [(r.search_dir, r.depends_on_option) for r in self.by("mesen_s")], + [("/recalbox/share/bios/sgb", "system flag SuperGameBoy"), + ("/recalbox/share/bios/", "without system flag SuperGameBoy")], + ) + + def test_a_directory_named_after_the_system(self): + picodrive, = self.by("picodrive") + self.assertEqual(picodrive.search_dir, "/recalbox/share/bios/") + + def test_every_other_core_gets_the_bios_root(self): + default = [r for r in self.by(ALL_CORES) + if r.depends_on_option == "core with no case of its own"] + self.assertEqual([(r.search_dir, r.source_ref) for r in default], + [("/recalbox/share/bios", ref(RETROARCH, "513-514"))]) + + def test_the_system_overrides_come_after_the_core(self): + overrides = [r for r in self.by(ALL_CORES) if r.depends_on_option.startswith("system")] + self.assertEqual( + [(r.search_dir, r.depends_on_option.removesuffix( + ", replacing the directory chosen by core")) for r in overrides], + [ + ("/recalbox/share/bios/amiga/bios", + "system amiga600, amiga1200, amigacdtv, amigacd32"), + ("/recalbox/share/bios/fds", "system fds"), + ("/recalbox/share/bios/lynx", "system lynx"), + ("/recalbox/share/bios/nds", "system nds"), + ("/recalbox/share/bios/saturn", "system saturn"), + ("/recalbox/share/bios/stv", "system stv"), + ], + ) + self.assertEqual(overrides[0].source_ref, ref(RETROARCH, "516-519")) + + def test_the_cases_cover_the_match(self): + cores = {name for r in self.records for name in r.emulator.split(", ")} + self.assertEqual(len(cores - {ALL_CORES}), 29) + + def test_dolphin_assets_directory(self): + assets, = core_assets(configgen()) + self.assertEqual(assets.emulator, "dolphin") + self.assertEqual(assets.search_dir, "/recalbox/share/bios/dolphin-emu") + self.assertEqual(assets.passes_to, "retroarch core_assets_directory") + self.assertEqual(assets.source_ref, ref(RETROARCH, "423-424")) + + def test_a_guarded_case_raises(self): + with self.assertRaises(LauncherShapeError): + system_directories(configgen(RETROARCH, "case 'gpsp':", "case 'gpsp' if True:")) + + def test_a_branch_test_it_cannot_read_raises(self): + tree = configgen(RETROARCH, "elif self.system.Name == 'sufami':", + "elif self.system.Name.startswith('sufami'):") + with self.assertRaises(LauncherShapeError): + system_directories(tree) + + def test_a_directory_it_cannot_spell_raises(self): + tree = configgen(RETROARCH, 'recalboxFiles.BIOS + "/gba"', "gbaDirectory()") + with self.assertRaises(LauncherShapeError): + system_directories(tree) + + +class HatariTosCascade(unittest.TestCase): + """hatariGenerator.py:103-167 and libretroCores.py:165-298.""" + + def test_the_standalone_tries_the_machine_then_three_fallbacks(self): + records = hatari(configgen()) + self.assertEqual( + [(r.slot, r.names[0]) for r in records[:-1]], + [("st", "atarist/st.img"), ("megast", "atarist/st.img"), + ("ste", "atarist/ste.img"), ("megaste", "atarist/megaste.img"), + ("tt", "atarist/tt.img"), ("falcon", "atarist/falcon.img")], + ) + for record in records[:-1]: + self.assertEqual(record.kind, "names_searched") + self.assertEqual(record.names[1:], ["atarist/tos.img", "tos.img", "atarist/emutos.img"]) + self.assertEqual(record.search_dir, "/recalbox/share/bios") + self.assertEqual(record.passes_to, "[ROM] szTosImageFileName") + self.assertIs(record.fails_when_missing, False) + self.assertEqual(record.source_ref, ref(HATARI, "108-138, 151-167")) + # coreSettings is never bound in SetMachineAndBios: no option fallback. + self.assertEqual(record.depends_on_option, + "machine read from the rom path through machines") + + def test_emutos_in_the_rom_path_forces_one_name(self): + emutos = hatari(configgen())[-1] + self.assertEqual((emutos.kind, emutos.slot, emutos.names), + ("fixed_name", "emutos", ["atarist/emutos.img"])) + self.assertEqual(emutos.depends_on_option, '"emutos" in the rom path') + + def test_the_two_libretro_cores(self): + records = hatari_libretro(configgen()) + old = [r for r in records if r.emulator == "hatari"] + new = [r for r in records if r.emulator == "hatarib"] + self.assertEqual([r.slot for r in old], + ["st", "ste", "tt", "falcon", "megaste", "emutos"]) + self.assertEqual(old[0].passes_to, "[ROM] szTosImageFileName") + self.assertEqual(old[0].source_ref, ref(CORES, "235-239, 249-267, 280-294")) + self.assertEqual([r.slot for r in new], + ["st", "megast", "ste", "megaste", "tt", "falcon"]) + self.assertEqual(new[0].names, ["atarist/st.img", "atarist/tos.img", "tos.img"]) + self.assertEqual(new[0].passes_to, + "core option hatarib_tos ( when none is found)") + self.assertEqual(new[0].depends_on_option, + "machine read from the rom path through machines, " + "else option hatarib_machine") + self.assertEqual(new[0].source_ref, ref(CORES, "168-198, 211-212, 214-229")) + + def test_another_existence_test_raises(self): + tree = configgen(HATARI, "if not os.path.exists(biosFile):", + "if not os.path.isfile(biosFile):") + with self.assertRaises(LauncherShapeError): + hatari(tree) + + +class GsplusRom(unittest.TestCase): + """gsplusGenerator.py:93-148.""" + + def test_the_search_and_the_game_file_placeholders(self): + searched, rom1, rom3 = gsplus(configgen()) + self.assertEqual(searched.names, ["apple2gs1.rom", "apple2gs3.rom"]) + self.assertEqual(searched.kind, "names_searched") + self.assertEqual(searched.passes_to, "g_cfg_rom_path") + self.assertEqual(searched.depends_on_option, 'rom path without ".gsplus"') + self.assertEqual(searched.source_ref, ref(GSPLUS, "140-148")) + self.assertEqual([(r.slot, r.names) for r in (rom1, rom3)], + [("%1", ["apple2gs1.rom"]), ("%3", ["apple2gs3.rom"])]) + self.assertEqual(rom1.source_ref, ref(GSPLUS, "107-108")) + + def test_a_last_miss_that_keeps_the_key_raises(self): + tree = configgen(GSPLUS, 'settings.removeOption("g_cfg_rom_path")', "pass") + with self.assertRaises(LauncherShapeError): + gsplus(tree) + + +class AmiberryKickstarts(unittest.TestCase): + """amiberryKickstarts.py:47-143 and amiberryConfig.py:337-345.""" + + def test_each_system_and_rom_type(self): + records = amiberry(configgen()) + self.assertEqual( + [(r.slot, r.kind, r.names, r.fails_when_missing) for r in records], + [ + ("amiga600", "names_searched", + ["kick37175.A500", "kick34005.A500", "kick33180.A500"], False), + ("amiga600", "names_searched", ["kick34005.A500", "kick33180.A500"], False), + ("amiga1200", "names_searched", + ["kick40068.A1200", "kick39106.A1200", "kick40068.A4000"], False), + ("amigacd32", "fixed_name", ["kick40060.CD32", "kick40060.CD32.ext"], + "No BIOS found, Extended BIOS not found"), + ("amigacdtv", "fixed_name", ["kick34005.A500", "kick34005.CDTV"], + "No BIOS found, Extended BIOS not found"), + ("amiga600, amiga1200", "fixed_name", ["kick02019.AROS", "kick02019.AROS.ext"], + False), + ], + ) + self.assertEqual(records[1].depends_on_option, "rom type DISK") + self.assertEqual(records[0].search_dir, "/recalbox/share/bios/amiga/bios") + self.assertEqual(records[0].passes_to, "kickstart_rom_file") + self.assertEqual(records[-1].passes_to, + "kickstart_rom_file, kickstart_ext_rom_file " + "(:AROS built in when either is missing)") + self.assertEqual(records[0].source_ref, + f"{ref(AMIBERRY_KICKSTARTS, '9-10, 50-53, 94-142')}, " + f"{ref(AMIBERRY_CONFIG, '337-345')}") + + def test_several_pairs_for_one_rom_type_raise(self): + tree = configgen(AMIBERRY_KICKSTARTS, '["kick40060.CD32|kick40060.CD32.ext"]', + '["kick40060.CD32|kick40060.CD32.ext", "a|b"]') + with self.assertRaises(LauncherShapeError): + amiberry(tree) + + +class Pcsx2Region(unittest.TestCase): + """pcsx2Generator.py:36-40, 55-107, 166.""" + + def test_the_region_token_comes_first_then_a_fixed_order(self): + records = pcsx2(configgen()) + eu = records[0] + self.assertEqual(eu.slot, "EU") + self.assertEqual(eu.names, ["ps2-0230e-20080220.bin", "ps2-0230a-20080220.bin", + "ps2-0230j-20080220.bin", "ps2-0230h-20080220.bin"]) + self.assertTrue(eu.depends_on_option.startswith("rom name contains europe, france")) + self.assertTrue(eu.depends_on_option.endswith(", uk")) + self.assertEqual(eu.search_dir, "/recalbox/share/bios/ps2") + self.assertEqual(eu.passes_to, "[Filenames] BIOS") + self.assertEqual(eu.source_ref, ref(PCSX2, "37-40, 56-107, 166")) + self.assertEqual([r.slot for r in records], ["EU", "USA", "JP", "HK", "default"]) + + def test_the_chinese_name_is_written_when_the_japanese_file_exists(self): + hk = pcsx2(configgen())[3] + self.assertEqual((hk.kind, hk.names), ("fixed_name", ["ps2-0230h-20080220.bin"])) + self.assertEqual(hk.depends_on_option, + "rom name contains china, taiwan and ps2-0230j-20080220.bin is present") + + def test_a_region_branch_of_another_shape_raises(self): + tree = configgen(PCSX2, 'elif "uk" in lrom and Exists', 'elif "uk" in lrom or Exists') + with self.assertRaises(LauncherShapeError): + pcsx2(tree) + + +class FixedPaths(unittest.TestCase): + def test_xemu_writes_two_paths(self): + record, = xemu(configgen()) + self.assertEqual(record.names, ["xbox/Complex_4627.bin", "xbox/mcpx_1.0.bin"]) + self.assertEqual(record.passes_to, "[sys.files] flashrom_path, [sys.files] bootrom_path") + self.assertEqual(record.source_ref, ref(XEMU, "16, 26, 58-59")) + + def test_mupen64plus_writes_the_64dd_ipl_for_one_system(self): + record, = mupen64plus(configgen()) + self.assertEqual(record.names, ["64DD_IPL.bin"]) + self.assertEqual(record.passes_to, "[64DD] IPL-ROM") + self.assertEqual(record.depends_on_option, "system 64dd") + self.assertEqual(record.source_ref, ref(MUPEN_CONFIG, "118-121")) + + def test_dolphin_only_tests_the_ipl_for_presence(self): + standalone, libretro = dolphin(configgen()) + names = ["gamecube/EUR/IPL.bin", "gamecube/JP/IPL.bin", "gamecube/USA/IPL.bin"] + self.assertEqual((standalone.emulator, standalone.names), ("dolphin, dolphin-gui", names)) + self.assertEqual(standalone.passes_to, "[Core] SkipIpl (False when one is present, else True)") + self.assertEqual(standalone.source_ref, ref(DOLPHIN, "154-163, 236-237, 273")) + self.assertEqual((libretro.emulator, libretro.names), ("dolphin", names)) + self.assertEqual(libretro.passes_to, + "core option dolphin_skip_gc_bios (disabled when one is present, " + "else enabled)") + self.assertEqual(libretro.source_ref, ref(CORES, "496-504")) + + def test_minivmac_boot_disk_goes_through_a_command_file(self): + record, = content_files(configgen()) + self.assertEqual((record.emulator, record.names), + ("minivmac", ["macintosh/MinivMacBootv2.dsk"])) + self.assertEqual(record.passes_to, + "written into /tmp/minivmac.cmd, which is loaded as the content") + self.assertEqual(record.source_ref, + f"{ref(LIBRETRO_GENERATOR, '877-882')}, {ref(FILES_MODULE, '174')}") + + def test_o2em_bios_option_is_read_from_the_core_directory(self): + cg = configgen() + records = core_file_options(cg, system_directories(cg)) + self.assertEqual( + [(r.names, r.search_dir, r.depends_on_option) for r in records], + [(["o2rom.bin"], "/recalbox/share/bios/o2em", "system odyssey2"), + (["g7400.bin"], "/recalbox/share/bios/o2em", "system videopacplus")], + ) + self.assertEqual(records[0].passes_to, "core option o2em_bios") + self.assertEqual(records[0].source_ref, + f"{ref(CORES, '350-351, 695')}, {ref(RETROARCH, '491-492')}") + + +class ExtractionNeverEmpty(unittest.TestCase): + def test_every_file_read_is_one_the_scraper_fetches(self): + tree = files() + read: list[str] = [] + + def reader(path: str) -> str: + read.append(path) + return tree[path] + + records = extract_launchers(reader) + self.assertEqual(sorted(set(read)), sorted(SOURCE_FILES)) + self.assertEqual(len(records), 85) + + def test_records_fit_the_schema(self): + schema = json.loads((REPO_ROOT / "schemas" / "platform.schema.json").read_text()) + launcher = schema["$defs"]["launcher"] + kinds = set(launcher["properties"]["kind"]["enum"]) + for record in extract_launchers(files().__getitem__): + self.assertEqual(sorted(set(record) - set(launcher["properties"])), []) + self.assertEqual(sorted(set(launcher["required"]) - set(record)), []) + self.assertIn(record["kind"], kinds) + + def test_a_files_module_without_bios_raises(self): + with self.assertRaises(LauncherShapeError): + configgen(FILES_MODULE, "BIOS = '/recalbox/share/bios'", "BIOS_DIR = ''") + + def test_the_scraper_refuses_a_file_it_did_not_fetch(self): + tree = files() + scraper = recalbox_scraper.Scraper.__new__(recalbox_scraper.Scraper) + scraper.tag = "10.1.1" + fetched = {path: tree[path] for path in SOURCE_FILES if path != PCSX2} + with mock.patch.object(scraper, "_blobs", return_value=fetched), \ + self.assertRaises(LauncherShapeError): + scraper._fetch_launchers() + + def test_the_scraper_hands_over_what_it_fetched(self): + tree = files() + scraper = recalbox_scraper.Scraper.__new__(recalbox_scraper.Scraper) + scraper.tag = "10.1.1" + reader: Callable[[str], str] = tree.__getitem__ + with mock.patch.object(scraper, "_blobs", return_value=tree): + self.assertEqual(scraper._fetch_launchers(), extract_launchers(reader)) + + +class PlatformRecords(unittest.TestCase): + """The launchers platforms/recalbox.yml carries, as the scraper wrote them.""" + + @classmethod + def setUpClass(cls) -> None: + cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "recalbox.yml").read_text()) + + def test_every_emulator_is_one_recalbox_names(self): + cores = set(self.config["cores"]) | {ALL_CORES} + named = { + name for record in self.config["launchers"] + for name in record["emulator"].split(", ") + } + self.assertEqual(sorted(named - cores), []) + + def test_every_reference_is_in_configgen(self): + for record in self.config["launchers"]: + for part in record["source_ref"].split(", "): + if not part[0].isdigit(): + self.assertTrue(part.startswith(f"{CONFIGGEN}/"), record) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index 6be79399..13ee2c3a 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -258,6 +258,13 @@ seven BIOS paths on every device but AMD64, where the keys are left empty: An extractor that no longer recognises the shape of the code raises: an empty list would read as a launcher that looks for nothing. +A libretro frontend that picks the system directory per core records each +choice as a `passes_path` entry with no `names`. Recalbox gives gpsp +`bios/gba` and geargrafx `bios/pcenginecd`, so a core that opens `gba_bios.bin` +reads it from there. `emulator: libretro` stands for every core Recalbox runs, +for the default directory and for the per-system overrides applied after the +per-core choice, and `` in `search_dir` is the system's own name. + ### Inheritance If the new platform inherits from an existing one (e.g. Lakka inherits RetroArch), diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 2dc831c4..bb7e5843 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -45,7 +45,7 @@ Every module in `tests/`, by the area it guards. | Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` | | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_selection` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_retrobat`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` | | Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_exports`, `test_site_platform_hashes`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture