mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-21 11:19:11 -05:00
Recalbox starts its emulators from a Python configgen too. The parsed source, the record and the ast helpers move to configgen_launchers so both readers use them. Batocera's extractors are unchanged.
712 lines
29 KiB
Python
712 lines
29 KiB
Python
"""Batocera's launcher layer, read from the configgen generators.
|
|
|
|
Between the file a user drops in /userdata/bios and the emulator, configgen
|
|
decides which name is looked for and where it is handed over: the DuckStation
|
|
generator walks a list of 22 names per region, Hatari's tries each TOS
|
|
version in each language, xemu writes one fixed path into its config. That is
|
|
the platform's behaviour, not the emulator's, so it is recorded here with the
|
|
lines of the pinned revision that say it.
|
|
|
|
Every extractor reads a known shape with ast. A generator that no longer has
|
|
that shape raises LauncherShapeError: an empty list would read as "this
|
|
launcher looks for nothing".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from .configgen_launchers import (
|
|
Launcher,
|
|
LauncherShapeError,
|
|
Source,
|
|
_assigned_name,
|
|
_calls,
|
|
_enclosing_test,
|
|
_innermost_function,
|
|
_locate,
|
|
_mentions,
|
|
_outermost,
|
|
_run,
|
|
)
|
|
|
|
CONFIGGEN = "package/batocera/core/batocera-configgen/configgen/configgen"
|
|
GENERATORS = f"{CONFIGGEN}/generators"
|
|
PATHS_MODULE = f"{CONFIGGEN}/batoceraPaths.py"
|
|
IMPORTER = f"{GENERATORS}/importer.py"
|
|
LIBRETRO_OPTIONS = f"{GENERATORS}/libretro/libretroOptions.py"
|
|
|
|
# The placeholder a Hatari TOS name carries for its language suffix.
|
|
LANGUAGE_PLACEHOLDER = "L"
|
|
|
|
|
|
def _config_option(node: ast.AST) -> tuple[str, Any] | None:
|
|
"""(option, default) when `node` is system.config.get*(option, default)."""
|
|
if not (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr.startswith("get")
|
|
and ast.unparse(node.func.value) == "system.config"
|
|
and node.args
|
|
and isinstance(node.args[0], ast.Constant)
|
|
):
|
|
return None
|
|
second = node.args[1] if len(node.args) > 1 else None
|
|
default = second.value if isinstance(second, ast.Constant) else None
|
|
return node.args[0].value, default
|
|
|
|
|
|
def path_constants(source: Source) -> dict[str, str]:
|
|
"""batoceraPaths' module constants that are plain paths."""
|
|
constants: dict[str, str] = {}
|
|
for node in source.tree.body:
|
|
name = _assigned_name(node)
|
|
if name is None:
|
|
continue
|
|
value = _path_value(node.value, constants) # type: ignore[union-attr]
|
|
if value is not None:
|
|
constants[name] = value
|
|
if "BIOS" not in constants:
|
|
source.fail("no BIOS path constant")
|
|
return constants
|
|
|
|
|
|
def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None:
|
|
if isinstance(node, ast.Name):
|
|
return constants.get(node.id)
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Name)
|
|
and node.func.id == "Path"
|
|
and len(node.args) == 1
|
|
and isinstance(node.args[0], ast.Constant)
|
|
):
|
|
return str(node.args[0].value)
|
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div):
|
|
left = _path_value(node.left, constants)
|
|
right = node.right.value if isinstance(node.right, ast.Constant) else None
|
|
if left is not None and isinstance(right, str):
|
|
return f"{left.rstrip('/')}/{right}"
|
|
return None
|
|
|
|
|
|
def generator_names(source: Source) -> dict[str, list[str]]:
|
|
"""Generator module -> the emulator or core names configgen loads it for."""
|
|
names: dict[str, list[str]] = {}
|
|
plain = source.literal(source.assignment("_GENERATOR_MAP").value, "_GENERATOR_MAP")
|
|
for emulator, (module, _cls) in plain.items():
|
|
names.setdefault(module, []).append(emulator)
|
|
legacy = source.literal(
|
|
source.assignment("_LEGACY_GENERATOR_MAP").value, "_LEGACY_GENERATOR_MAP"
|
|
)
|
|
for cores in legacy.values():
|
|
for core, (module, _cls) in cores.items():
|
|
names.setdefault(module, []).append(core)
|
|
return names
|
|
|
|
|
|
class Configgen:
|
|
"""The configgen tree at one revision, read through `read(path)`."""
|
|
|
|
def __init__(self, read: Callable[[str], str]) -> None:
|
|
self._read = read
|
|
self._sources: dict[str, Source] = {}
|
|
self.paths = path_constants(self.source(PATHS_MODULE))
|
|
self.bios = self.paths["BIOS"]
|
|
self._generators = generator_names(self.source(IMPORTER))
|
|
|
|
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 generator(self, module: str) -> Source:
|
|
return self.source(f"{GENERATORS}/{module.replace('.', '/')}.py")
|
|
|
|
def helper(self, module: str, helper: str) -> Source:
|
|
"""A module the generator imports with `from . import helper`."""
|
|
generator = self.generator(module)
|
|
imported = any(
|
|
isinstance(node, ast.ImportFrom)
|
|
and node.level == 1
|
|
and node.module is None
|
|
and any(alias.name == helper for alias in node.names)
|
|
for node in ast.walk(generator.tree)
|
|
)
|
|
if not imported:
|
|
generator.fail(f"does not import {helper}")
|
|
return self.source(f"{GENERATORS}/{module.split('.')[0]}/{helper}.py")
|
|
|
|
def emulator(self, module: str) -> str:
|
|
"""The name configgen loads `module` for (importer.get_generator)."""
|
|
if module in self._generators:
|
|
return ", ".join(self._generators[module])
|
|
package, _, leaf = module.partition(".")
|
|
if leaf == f"{package}Generator":
|
|
return package
|
|
raise LauncherShapeError(f"{IMPORTER}: no emulator loads {module}")
|
|
|
|
def bios_name(self, node: ast.AST) -> str | None:
|
|
"""The name under /userdata/bios that an expression spells, if any."""
|
|
if (
|
|
isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Name)
|
|
and node.func.id == "str"
|
|
and node.args
|
|
):
|
|
return self.bios_name(node.args[0])
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
value = node.value.strip('"')
|
|
prefix = self.bios.rstrip("/") + "/"
|
|
return value[len(prefix):] if value.startswith(prefix) else None
|
|
value = _path_value(node, self.paths)
|
|
prefix = self.bios.rstrip("/") + "/"
|
|
if value is not None and value.startswith(prefix):
|
|
return value[len(prefix):]
|
|
return None
|
|
|
|
def option_name(self, node: ast.AST) -> tuple[str, str] | None:
|
|
"""(default name, option) for f"{BIOS}/{system.config.get(option, default)}"."""
|
|
if not isinstance(node, ast.JoinedStr) or len(node.values) != 3:
|
|
return None
|
|
head, sep, tail = node.values
|
|
if not (
|
|
isinstance(head, ast.FormattedValue)
|
|
and _path_value(head.value, self.paths) == self.bios
|
|
and isinstance(sep, ast.Constant)
|
|
and sep.value == "/"
|
|
and isinstance(tail, ast.FormattedValue)
|
|
):
|
|
return None
|
|
option = _config_option(tail.value)
|
|
if option is None or not isinstance(option[1], str):
|
|
return None
|
|
return option[1], option[0]
|
|
|
|
|
|
def _raise_message(source: Source, scope: ast.AST, guard: str) -> str:
|
|
"""The message of `if not <guard>: raise X("...")` in scope."""
|
|
for node in ast.walk(scope):
|
|
if (
|
|
isinstance(node, ast.If)
|
|
and isinstance(node.test, ast.UnaryOp)
|
|
and isinstance(node.test.op, ast.Not)
|
|
and isinstance(node.test.operand, ast.Name)
|
|
and node.test.operand.id == guard
|
|
and isinstance(node.body[0], ast.Raise)
|
|
):
|
|
exc = node.body[0].exc
|
|
if isinstance(exc, ast.Call) and exc.args and isinstance(exc.args[0], ast.Constant):
|
|
return str(exc.args[0].value)
|
|
source.fail(f"no `if not {guard}: raise` with a message")
|
|
|
|
|
|
def _string_lists(source: Source, node: ast.AST, what: str) -> dict[str, list[str]]:
|
|
value = source.literal(node, what)
|
|
if not (
|
|
isinstance(value, dict)
|
|
and value
|
|
and all(
|
|
isinstance(v, list) and v and all(isinstance(n, str) for n in v)
|
|
for v in value.values()
|
|
)
|
|
):
|
|
source.fail(f"{what} is not a mapping of name lists")
|
|
return value
|
|
|
|
|
|
def duckstation(cg: Configgen) -> list[Launcher]:
|
|
"""bios_lists searched case-insensitively, first hit per region."""
|
|
module = "duckstation.duckstationGenerator"
|
|
src = cg.generator(module)
|
|
lists_node = src.assignment("bios_lists")
|
|
lists = _string_lists(src, lists_node.value, "bios_lists")
|
|
finder = src.function("find_bios")
|
|
listed = [
|
|
call for call in _calls(finder, attr="iterdir")
|
|
if isinstance(call.func.value, ast.Name) # type: ignore[attr-defined]
|
|
]
|
|
lowered = _calls(finder, attr="lower")
|
|
if len(listed) != 1 or not lowered:
|
|
src.fail("find_bios no longer lists the BIOS directory and compares lowercased names")
|
|
search_dir = cg.paths.get(listed[0].func.value.id) # type: ignore[attr-defined]
|
|
if search_dir is None:
|
|
src.fail("find_bios lists a directory that is not a batoceraPaths constant")
|
|
|
|
generate = src.function("generate")
|
|
located = _locate(generate, lambda stmt: (
|
|
isinstance(stmt, ast.Assign)
|
|
and isinstance(stmt.value, ast.Call)
|
|
and isinstance(stmt.value.func, ast.Name)
|
|
and stmt.value.func.id == "find_bios"
|
|
))
|
|
if located is None:
|
|
src.fail("generate no longer calls find_bios")
|
|
body, index = located
|
|
found_var = _assigned_name(body[index])
|
|
if found_var is None:
|
|
src.fail("find_bios result is not bound to a name")
|
|
message = _raise_message(src, generate, found_var)
|
|
|
|
region_mapping = src.literal(
|
|
src.assignment("region_mapping", generate).value, "region_mapping"
|
|
)
|
|
branches = [
|
|
node for node in ast.walk(generate)
|
|
if isinstance(node, ast.If)
|
|
and isinstance(node.test, ast.Compare)
|
|
and isinstance(node.test.left, ast.Constant)
|
|
and isinstance(node.test.ops[0], ast.In)
|
|
and ast.unparse(node.test.comparators[0]) == found_var
|
|
]
|
|
if len(branches) != 1:
|
|
src.fail(f"generate no longer branches once on a slot found in {found_var}")
|
|
branch = branches[0]
|
|
writes = _calls(branch, attr="set")
|
|
sections = {call.args[0].value for call in writes if isinstance(call.args[0], ast.Constant)}
|
|
if len(sections) != 1 or not any(
|
|
isinstance(call.args[1], ast.Subscript)
|
|
and ast.unparse(call.args[1].value) == "region_mapping"
|
|
for call in writes
|
|
):
|
|
src.fail("the region paths are not written to one config section through region_mapping")
|
|
section = sections.pop()
|
|
shared = {
|
|
branch.test.left.value: [
|
|
call.args[1].value for stmt in branch.body for call in _calls(stmt, attr="set")
|
|
if isinstance(call.args[1], ast.Constant)
|
|
]
|
|
}
|
|
|
|
first, last = _run(body, index, lambda stmt: _mentions(stmt, {section}, {found_var}))
|
|
ref = src.ref([
|
|
(src.span(first)[0], last.end_lineno),
|
|
(finder.lineno, finder.end_lineno),
|
|
src.span(lists_node),
|
|
])
|
|
launchers = []
|
|
for slot, names in lists.items():
|
|
keys = shared.get(slot) or ([region_mapping[slot]] if slot in region_mapping else None)
|
|
if not keys:
|
|
src.fail(f"bios_lists slot {slot} is written nowhere")
|
|
launchers.append(Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="names_searched",
|
|
slot=slot, names=names, case_insensitive=True, search_dir=search_dir,
|
|
passes_to=f"[{section}] {', '.join(keys)}",
|
|
fails_when_missing=message, fails_scope="emulator", source_ref=ref,
|
|
))
|
|
return launchers
|
|
|
|
|
|
def duckstation_legacy(cg: Configgen) -> list[Launcher]:
|
|
"""One name list per region, tried with Path.exists, first hit kept."""
|
|
module = "duckstation_legacy.duckstationLegacyGenerator"
|
|
src = cg.generator(module)
|
|
generate = src.function("generate")
|
|
loops = []
|
|
for node in ast.walk(generate):
|
|
if not (
|
|
isinstance(node, ast.For)
|
|
and isinstance(node.iter, ast.Name)
|
|
and isinstance(node.target, ast.Name)
|
|
):
|
|
continue
|
|
test = node.body[0].test if isinstance(node.body[0], ast.If) else None
|
|
if not (
|
|
isinstance(test, ast.Call)
|
|
and isinstance(test.func, ast.Attribute)
|
|
and test.func.attr == "exists"
|
|
and isinstance(test.func.value, ast.BinOp)
|
|
and isinstance(test.func.value.left, ast.Name)
|
|
and ast.unparse(test.func.value.right) == node.target.id
|
|
):
|
|
continue
|
|
bound = {
|
|
_assigned_name(stmt): stmt for stmt in node.body[0].body
|
|
if isinstance(stmt, ast.Assign) and ast.unparse(stmt.value) == node.target.id
|
|
}
|
|
flags = {
|
|
_assigned_name(stmt) for stmt in node.body[0].body
|
|
if isinstance(stmt, ast.Assign)
|
|
and isinstance(stmt.value, ast.Constant)
|
|
and stmt.value.value is True
|
|
}
|
|
if len(bound) != 1 or len(flags) != 1:
|
|
src.fail(f"the loop over {node.iter.id} no longer keeps one file and one flag")
|
|
loops.append((node.iter.id, next(iter(bound)), flags.pop(), test.func.value.left.id))
|
|
if not loops:
|
|
src.fail("generate no longer tries BIOS names with Path.exists")
|
|
flags = {flag for _l, _v, flag, _d in loops}
|
|
dirs = {directory for _l, _v, _f, directory in loops}
|
|
if len(flags) != 1 or len(dirs) != 1 or next(iter(dirs)) not in cg.paths:
|
|
src.fail("the region loops no longer share one flag and one BIOS directory")
|
|
flag = flags.pop()
|
|
search_dir = cg.paths[dirs.pop()]
|
|
message = _raise_message(src, generate, flag)
|
|
|
|
writes: dict[str, tuple[str, str]] = {}
|
|
for call in _calls(generate, attr="set"):
|
|
if (
|
|
len(call.args) == 3
|
|
and all(isinstance(a, ast.Constant) for a in call.args[:2])
|
|
and isinstance(call.args[2], ast.Name)
|
|
):
|
|
writes[call.args[2].id] = (call.args[0].value, call.args[1].value)
|
|
|
|
list_nodes = {name: src.assignment(name, generate) for name, _v, _f, _d in loops}
|
|
tracked = {*list_nodes, *(v for _l, v, _f, _d in loops), flag}
|
|
first_list = min(list_nodes.values(), key=lambda node: node.lineno)
|
|
body, index = _locate(generate, lambda stmt: stmt is first_list) # type: ignore[misc]
|
|
sections = {section for section, _key in writes.values()}
|
|
first, last = _run(body, index, lambda stmt: _mentions(stmt, sections, tracked))
|
|
ref = src.ref([(src.span(first)[0], last.end_lineno)])
|
|
|
|
launchers = []
|
|
for list_name, var, _flag, _dir in loops:
|
|
if var not in writes:
|
|
src.fail(f"{var} is written to no config key")
|
|
section, key = writes[var]
|
|
names = src.literal(list_nodes[list_name].value, list_name)
|
|
if not (isinstance(names, list) and names and all(isinstance(n, str) for n in names)):
|
|
src.fail(f"{list_name} is not a list of names")
|
|
launchers.append(Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="names_searched",
|
|
slot=key.removeprefix("Path"), names=names, case_insensitive=False,
|
|
search_dir=search_dir, passes_to=f"[{section}] {key}",
|
|
fails_when_missing=message, fails_scope="emulator", source_ref=ref,
|
|
))
|
|
return launchers
|
|
|
|
|
|
def _loaded(stmt: ast.stmt) -> set[str]:
|
|
"""Names an assignment reads on its right-hand side."""
|
|
value = getattr(stmt, "value", None)
|
|
return {n.id for n in ast.walk(value) if isinstance(n, ast.Name)} if value else set()
|
|
|
|
|
|
def _trace(body: list[ast.stmt], index: int) -> list[ast.stmt]:
|
|
"""body[index], the assignments before it that bind what it reads, and
|
|
the command-line additions that read any of those names."""
|
|
kept = [body[index]]
|
|
needed = _loaded(body[index])
|
|
for stmt in reversed(body[:index]):
|
|
if _assigned_name(stmt) in needed:
|
|
kept.append(stmt)
|
|
needed |= _loaded(stmt)
|
|
produced = {name for stmt in kept if (name := _assigned_name(stmt)) is not None}
|
|
kept += [
|
|
stmt for stmt in body
|
|
if isinstance(stmt, ast.AugAssign) and _mentions(stmt.value, set(), produced)
|
|
]
|
|
return sorted(kept, key=lambda stmt: stmt.lineno)
|
|
|
|
|
|
def hatari(cg: Configgen) -> list[Launcher]:
|
|
"""findBestTos: per machine, each TOS version in each language."""
|
|
module = "hatari.hatariGenerator"
|
|
src = cg.generator(module)
|
|
finder = src.function("findBestTos")
|
|
languages = src.literal(src.assignment("all_languages", finder).value, "all_languages")
|
|
machines = _string_lists(
|
|
src, src.assignment("all_machines_bios", finder).value, "all_machines_bios"
|
|
)
|
|
if not (
|
|
isinstance(languages, list) and languages and all(isinstance(x, str) for x in languages)
|
|
):
|
|
src.fail("all_languages is not a list of strings")
|
|
|
|
marker = prefix = suffix = None
|
|
for node in ast.walk(finder):
|
|
if (
|
|
isinstance(node, ast.If)
|
|
and isinstance(node.test, ast.Compare)
|
|
and isinstance(node.test.left, ast.Constant)
|
|
and isinstance(node.test.ops[0], ast.In)
|
|
and len(node.orelse) == 1
|
|
and isinstance(node.orelse[0], ast.Assign)
|
|
and isinstance(node.orelse[0].value, ast.JoinedStr)
|
|
):
|
|
parts = node.orelse[0].value.values
|
|
if (
|
|
len(parts) == 2
|
|
and isinstance(parts[0], ast.Constant)
|
|
and isinstance(parts[1], ast.FormattedValue)
|
|
):
|
|
marker, prefix = node.test.left.value, parts[0].value
|
|
tos_path = src.assignment("tos_path", finder).value
|
|
if isinstance(tos_path, ast.BinOp) and isinstance(tos_path.right, ast.JoinedStr):
|
|
parts = tos_path.right.values
|
|
if (
|
|
len(parts) == 3
|
|
and all(isinstance(p, ast.FormattedValue) for p in parts[:2])
|
|
and isinstance(parts[2], ast.Constant)
|
|
):
|
|
suffix = parts[2].value
|
|
if marker is None or prefix is None or suffix is None:
|
|
src.fail("findBestTos no longer builds <tos{version} or etos*><language>.img")
|
|
raised = [node for node in ast.walk(finder) if isinstance(node, ast.Raise)]
|
|
exc = raised[0].exc if len(raised) == 1 else None
|
|
if not (isinstance(exc, ast.Call) and exc.args and isinstance(exc.args[0], ast.JoinedStr)):
|
|
src.fail("findBestTos no longer raises one formatted message")
|
|
template = exc.args[0]
|
|
|
|
generate = src.function("generate")
|
|
located = _locate(generate, lambda stmt: (
|
|
isinstance(stmt, ast.Assign)
|
|
and isinstance(stmt.value, ast.Call)
|
|
and ast.unparse(stmt.value.func).endswith(".findBestTos")
|
|
))
|
|
if located is None:
|
|
src.fail("generate no longer calls findBestTos")
|
|
body, index = located
|
|
call = body[index].value # type: ignore[attr-defined]
|
|
if not call.args or not isinstance(call.args[0], ast.Name) or call.args[0].id not in cg.paths:
|
|
src.fail("findBestTos is not given a batoceraPaths directory")
|
|
search_dir = cg.paths[call.args[0].id]
|
|
kept = _trace(body, index)
|
|
options = [
|
|
option[0] for stmt in kept for node in ast.walk(stmt)
|
|
if (option := _config_option(node)) is not None
|
|
]
|
|
spans = [src.span(stmt) for stmt in kept] + [(finder.lineno, finder.end_lineno)]
|
|
args: list[str] = []
|
|
second = call.args[1] if len(call.args) > 1 else None
|
|
machine_var = second.id if isinstance(second, ast.Name) else None
|
|
tos_var = _assigned_name(body[index])
|
|
for stmt in kept:
|
|
if isinstance(stmt, ast.AugAssign) and isinstance(stmt.value, ast.List):
|
|
for element in stmt.value.elts:
|
|
if isinstance(element, ast.Constant):
|
|
args.append(str(element.value))
|
|
elif isinstance(element, ast.Name) and element.id == machine_var:
|
|
args.append("{machine}")
|
|
elif isinstance(element, ast.Name) and element.id == tos_var:
|
|
args.append("<file>")
|
|
if "<file>" not in args:
|
|
src.fail("the TOS findBestTos returns is not put on the command line")
|
|
|
|
ref = src.ref(spans)
|
|
launchers = []
|
|
for machine, versions in machines.items():
|
|
message = "".join(
|
|
str(part.value) if isinstance(part, ast.Constant) else machine
|
|
for part in template.values
|
|
)
|
|
names = [
|
|
f"{v if marker in v else prefix + v}{{{LANGUAGE_PLACEHOLDER}}}{suffix}"
|
|
for v in versions
|
|
]
|
|
launchers.append(Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="names_searched",
|
|
slot=machine, names=names, expands={LANGUAGE_PLACEHOLDER: languages},
|
|
case_insensitive=False, search_dir=search_dir,
|
|
passes_to=" ".join(args).replace("{machine}", machine),
|
|
fails_when_missing=message, depends_on_option=", ".join(options),
|
|
source_ref=ref,
|
|
))
|
|
return launchers
|
|
|
|
|
|
def _config_writes(cg: Configgen, src: Source, emulator: str) -> Launcher:
|
|
"""parser.set(section, key, <path under BIOS>) calls of one module."""
|
|
writes = []
|
|
for call in _calls(src.tree, attr="set"):
|
|
if len(call.args) != 3 or not all(isinstance(a, ast.Constant) for a in call.args[:2]):
|
|
continue
|
|
name = cg.bios_name(call.args[2])
|
|
if name is not None:
|
|
writes.append((call, call.args[0].value, call.args[1].value, name))
|
|
if not writes:
|
|
src.fail("writes no /userdata/bios path into its config")
|
|
spans = []
|
|
conditions = []
|
|
for call, _section, _key, _name in writes:
|
|
scope = _innermost_function(src, call)
|
|
spans.append(src.span(_outermost(scope, call)))
|
|
test = _enclosing_test(scope, call)
|
|
if test is not None and test not in conditions:
|
|
conditions.append(test)
|
|
keys = []
|
|
for _call, section, key, _name in writes:
|
|
label = f"[{section}] {key}"
|
|
if label not in keys:
|
|
keys.append(label)
|
|
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(keys),
|
|
depends_on_option=", ".join(conditions) or None, source_ref=src.ref(spans),
|
|
)
|
|
|
|
|
|
def xemu(cg: Configgen) -> list[Launcher]:
|
|
module = "xemu.xemuGenerator"
|
|
return [_config_writes(cg, cg.helper(module, "xemuConfig"), cg.emulator(module))]
|
|
|
|
|
|
def mupen64plus(cg: Configgen) -> list[Launcher]:
|
|
module = "mupen.mupenGenerator"
|
|
return [_config_writes(cg, cg.helper(module, "mupenConfig"), cg.emulator(module))]
|
|
|
|
|
|
def melonds(cg: Configgen) -> list[Launcher]:
|
|
"""One record per base_config section whose values are BIOS paths."""
|
|
module = "melonds.melondsGenerator"
|
|
src = cg.generator(module)
|
|
generate = src.function("generate")
|
|
base = src.assignment("base_config", generate)
|
|
if not isinstance(base.value, ast.Dict):
|
|
src.fail("base_config is not a dict display")
|
|
console = [
|
|
node for node in ast.walk(generate)
|
|
if isinstance(node, ast.Assign)
|
|
and ast.unparse(node.targets[0]).startswith("base_config['Emu']['ConsoleType']")
|
|
and _config_option(node.value) is not None
|
|
]
|
|
enabled = [
|
|
(key, value) for key, value in zip(base.value.keys, base.value.values)
|
|
if isinstance(value, ast.Dict)
|
|
and any(
|
|
isinstance(k, ast.Constant) and k.value == "ExternalBIOSEnable" for k in value.keys
|
|
)
|
|
]
|
|
if len(console) != 1 or len(enabled) != 1:
|
|
src.fail("generate no longer sets Emu.ConsoleType from an option with external BIOS on")
|
|
option = _config_option(console[0].value)[0] # type: ignore[index]
|
|
launchers = []
|
|
for key, value in zip(base.value.keys, base.value.values):
|
|
if not isinstance(value, ast.Dict) or not isinstance(key, ast.Constant):
|
|
continue
|
|
entries = [
|
|
(k.value, cg.bios_name(v)) for k, v in zip(value.keys, value.values)
|
|
if isinstance(k, ast.Constant)
|
|
]
|
|
entries = [(k, name) for k, name in entries if name is not None]
|
|
if not entries:
|
|
continue
|
|
launchers.append(Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="fixed_name",
|
|
names=[name for _k, name in entries], case_insensitive=False,
|
|
search_dir=cg.bios,
|
|
passes_to=", ".join(f"{key.value}.{k}" for k, _n in entries),
|
|
depends_on_option=option,
|
|
source_ref=src.ref([
|
|
(key.lineno, value.end_lineno),
|
|
(enabled[0][0].lineno, enabled[0][1].end_lineno),
|
|
src.span(console[0]),
|
|
]),
|
|
))
|
|
if not launchers:
|
|
src.fail("base_config names no BIOS path")
|
|
return launchers
|
|
|
|
|
|
def x16emu(cg: Configgen) -> list[Launcher]:
|
|
"""A command-line flag followed by a path under BIOS."""
|
|
module = "x16emu.x16emuGenerator"
|
|
src = cg.generator(module)
|
|
found = []
|
|
for node in ast.walk(src.function("generate")):
|
|
if isinstance(node, ast.List):
|
|
for flag, value in zip(node.elts, node.elts[1:]):
|
|
name = cg.bios_name(value)
|
|
is_flag = isinstance(flag, ast.Constant) and str(flag.value).startswith("-")
|
|
if is_flag and name is not None:
|
|
found.append((flag, value, name))
|
|
if not found:
|
|
src.fail("the command line passes no /userdata/bios path")
|
|
return [Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="fixed_name",
|
|
names=[name for _f, _v, name in found], case_insensitive=False,
|
|
search_dir=cg.bios,
|
|
passes_to=", ".join(f"command line {f.value}" for f, _v, _n in found),
|
|
source_ref=src.ref([(f.lineno, v.end_lineno) for f, v, _n in found]),
|
|
)]
|
|
|
|
|
|
def gsplus(cg: Configgen) -> list[Launcher]:
|
|
"""config.save(key, f"{BIOS}/{system.config.get(option, default)}")."""
|
|
module = "gsplus.gsplusGenerator"
|
|
src = cg.generator(module)
|
|
found = []
|
|
for call in _calls(src.tree, attr="save"):
|
|
if len(call.args) == 2 and isinstance(call.args[0], ast.Constant):
|
|
named = cg.option_name(call.args[1])
|
|
if named is not None:
|
|
found.append((call, call.args[0].value, *named))
|
|
if len(found) != 1:
|
|
src.fail(f"expected one ROM path built from an option, found {len(found)}")
|
|
call, key, name, option = found[0]
|
|
return [Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="fixed_name",
|
|
names=[name], case_insensitive=False, search_dir=cg.bios,
|
|
passes_to=key, depends_on_option=option,
|
|
source_ref=src.ref([src.span(_outermost(src.function("generate"), call))]),
|
|
)]
|
|
|
|
|
|
# libretro core options whose value names the BIOS file the core opens.
|
|
FILE_OPTIONS = (("_o2em_options", "o2em_bios"), ("_neocd_options", "neocd_bios"))
|
|
|
|
|
|
def core_file_options(cg: Configgen) -> list[Launcher]:
|
|
"""_set_from_system(settings, option, system, [name], default=...)."""
|
|
src = cg.source(LIBRETRO_OPTIONS)
|
|
table = src.assignment("_option_functions").value
|
|
if not isinstance(table, ast.Dict):
|
|
src.fail("_option_functions is not a dict display")
|
|
cores: dict[str, list[str]] = {}
|
|
for key, value in zip(table.keys, table.values):
|
|
if isinstance(key, ast.Constant) and isinstance(value, ast.Name):
|
|
cores.setdefault(value.id, []).append(key.value)
|
|
launchers = []
|
|
for function_name, setting in FILE_OPTIONS:
|
|
function = src.function(function_name)
|
|
if function_name not in cores:
|
|
src.fail(f"no core is configured by {function_name}")
|
|
calls = [
|
|
call for call in _calls(function, name="_set_from_system")
|
|
if len(call.args) >= 2
|
|
and isinstance(call.args[1], ast.Constant)
|
|
and call.args[1].value == setting
|
|
]
|
|
if len(calls) != 1:
|
|
src.fail(f"{function_name} no longer sets {setting} once")
|
|
call = calls[0]
|
|
named = call.args[3] if len(call.args) > 3 else None
|
|
option = named.value if isinstance(named, ast.Constant) else setting
|
|
default = next((k.value for k in call.keywords if k.arg == "default"), None)
|
|
if isinstance(default, ast.Constant):
|
|
names = [default.value]
|
|
condition = None
|
|
elif (
|
|
isinstance(default, ast.IfExp)
|
|
and isinstance(default.body, ast.Constant)
|
|
and isinstance(default.orelse, ast.Constant)
|
|
):
|
|
names = [default.body.value, default.orelse.value]
|
|
condition = ast.unparse(default.test)
|
|
else:
|
|
src.fail(f"{setting} has no literal default")
|
|
depends = option if condition is None else f"{option} (default by {condition})"
|
|
launchers.append(Launcher(
|
|
emulator=", ".join(cores[function_name]), build="libretro", kind="core_option",
|
|
names=names, passes_to=f"core option {setting}", depends_on_option=depends,
|
|
source_ref=src.ref([src.span(_outermost(function, call))]),
|
|
))
|
|
return launchers
|
|
|
|
|
|
EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = (
|
|
duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus,
|
|
x16emu, gsplus, core_file_options,
|
|
)
|
|
|
|
|
|
def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]:
|
|
"""Every launcher record, read through `read(repo-relative path)`."""
|
|
cg = Configgen(read)
|
|
return [launcher.entry() for extractor in EXTRACTORS for launcher in extractor(cg)]
|