Files
libretro/scripts/scraper/recalbox_launchers.py
T
Abdessamad Derraz fe9a081cfb feat: read Recalbox launchers from configgen
The scraper now records, from configgen at the pinned tag, the names each Recalbox launcher tries and where it hands them: the Hatari TOS cascades (standalone, hatari and hatarib cores), GSplus, Amiberry kickstarts, the PCSX2 region choice, the Dolphin IPL presence test, xemu, the mupen64plus 64DD IPL, the Mini vMac boot disk and the o2em_bios option. The system_directory libretroRetroarch gives each core, and the per-system overrides after it, are passes_path records.
2026-10-20 20:59:51 +02:00

1192 lines
53 KiB
Python

"""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>"
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 <etos1024k>.
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(<path>)")
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, <path under BIOS>) 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]