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