mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
RetroBat's es_features.cfg and KNULLI's es_features.yml list the values a menu offers. They move out of source_ref into choices_from, beside the generator that writes the choice. RetroBat's xemu flashrom record holds outside a Chihiro launch (Xemu.Generator.cs:48, 284), and KNULLI's libretro boot disk records name the one core es_features offers them for. Rescraped at the pinned revisions.
1113 lines
47 KiB
Python
1113 lines
47 KiB
Python
"""KNULLI's launcher layer, read from its configgen and its board scripts.
|
|
|
|
KNULLI is built on Batocera and keeps configgen under its own package
|
|
(package/system/knulli-configgen). Where a generator is still Batocera's
|
|
code (DuckStation, xemu, mupen64plus, x16emu, the flycast environment, the
|
|
hatari and melonDS forms batocera_launchers accepts), Batocera's extractor
|
|
reads it. The others are older or KNULLI's own: the lexaloffle generator logs
|
|
and returns instead of raising, libretroOptions is one function of
|
|
`if (system.config['core'] == ...)` blocks, and the PICO-8 binary is
|
|
installed by board/fsoverlay/usr/bin/knulli-install-pico8.
|
|
|
|
The remaining generators name a /userdata/bios path in a command line, a
|
|
config key, an environment variable, a copy or a presence test. bios_sites()
|
|
reads each of those uses with ast and the context that consumes it, and
|
|
GENERIC_SITES says, per generator, whether the path is a file or a directory.
|
|
A generator in that table that no longer names a BIOS path raises
|
|
LauncherShapeError, as every other extractor does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import posixpath
|
|
import re
|
|
from collections.abc import Callable, Iterator
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import yaml
|
|
|
|
try:
|
|
from .batocera_launchers import (
|
|
PATH_PATCHES,
|
|
Configgen,
|
|
config_directories,
|
|
_path_value,
|
|
_render,
|
|
_system_branches,
|
|
duckstation,
|
|
duckstation_legacy,
|
|
environment_directories,
|
|
hatari,
|
|
melonds,
|
|
mupen64plus,
|
|
patched_paths,
|
|
x16emu,
|
|
working_directories,
|
|
xemu,
|
|
)
|
|
from .configgen_launchers import (
|
|
Launcher,
|
|
LauncherShapeError,
|
|
Source,
|
|
_assigned_name,
|
|
)
|
|
from .shell_launchers import LauncherPatternMissing, Script, source_ref
|
|
except ImportError:
|
|
from batocera_launchers import ( # type: ignore[no-redef]
|
|
PATH_PATCHES,
|
|
Configgen,
|
|
config_directories,
|
|
_path_value,
|
|
_render,
|
|
_system_branches,
|
|
duckstation,
|
|
duckstation_legacy,
|
|
environment_directories,
|
|
hatari,
|
|
melonds,
|
|
mupen64plus,
|
|
patched_paths,
|
|
x16emu,
|
|
working_directories,
|
|
xemu,
|
|
)
|
|
from configgen_launchers import ( # type: ignore[no-redef]
|
|
Launcher,
|
|
LauncherShapeError,
|
|
Source,
|
|
_assigned_name,
|
|
)
|
|
from shell_launchers import ( # type: ignore[no-redef]
|
|
LauncherPatternMissing,
|
|
Script,
|
|
source_ref,
|
|
)
|
|
|
|
CONFIGGEN = "package/system/knulli-configgen/configgen/configgen"
|
|
GENERATORS = f"{CONFIGGEN}/generators"
|
|
LIBRETRO_OPTIONS = f"{GENERATORS}/libretro/libretroOptions.py"
|
|
PICO8_INSTALLER = "board/fsoverlay/usr/bin/knulli-install-pico8"
|
|
ES_FEATURES = "package/emulationstation/knulli-es-system/es_features.yml"
|
|
|
|
# The generators of Batocera's config_directories that KNULLI keeps in the
|
|
# same shape: openMSX's filepool lines and amiberry's rom_path.
|
|
CONFIG_DIRECTORY_GENERATORS = ("openmsx.openmsxGenerator", "amiberry.amiberryGenerator")
|
|
|
|
|
|
def knulli_config_directories(cg: Configgen) -> list[Launcher]:
|
|
return config_directories(cg, CONFIG_DIRECTORY_GENERATORS)
|
|
|
|
|
|
# Batocera's extractors that read KNULLI's generators unchanged.
|
|
SHARED = (
|
|
duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus, x16emu,
|
|
environment_directories, working_directories, knulli_config_directories,
|
|
)
|
|
|
|
|
|
def _lines(node: ast.AST) -> tuple[int, int]:
|
|
"""First and last line of a node."""
|
|
first: int = getattr(node, "lineno", 0)
|
|
return first, getattr(node, "end_lineno", None) or first
|
|
|
|
|
|
def _bound(node: ast.AST) -> ast.expr | None:
|
|
"""The value of an assignment statement."""
|
|
value = getattr(node, "value", None)
|
|
return value if isinstance(value, ast.expr) else None
|
|
|
|
|
|
def configgen(read: Callable[[str], str]) -> Configgen:
|
|
"""KNULLI's configgen: Batocera's reader at KNULLI's package, no legacy map."""
|
|
return Configgen(read, CONFIGGEN, legacy_map=False)
|
|
|
|
|
|
def _error_guard(
|
|
src: Source, scope: ast.FunctionDef, test: Callable[[ast.expr], bool], what: str
|
|
) -> tuple[ast.If, ast.JoinedStr]:
|
|
"""`if <test>: eslog.error(f"..."); return -1` and its message."""
|
|
found = []
|
|
for node in ast.walk(scope):
|
|
if not (isinstance(node, ast.If) and test(node.test) and len(node.body) == 2):
|
|
continue
|
|
logged, returned = node.body
|
|
if (
|
|
isinstance(logged, ast.Expr)
|
|
and isinstance(logged.value, ast.Call)
|
|
and ast.unparse(logged.value.func) == "eslog.error"
|
|
and logged.value.args
|
|
and isinstance(logged.value.args[0], ast.JoinedStr)
|
|
and isinstance(returned, ast.Return)
|
|
):
|
|
found.append((node, logged.value.args[0]))
|
|
if len(found) != 1:
|
|
src.fail(f"expected one `if {what}: eslog.error(...); return`, found {len(found)}")
|
|
return found[0]
|
|
|
|
|
|
def lexaloffle(cg: Configgen) -> list[Launcher]:
|
|
"""Per system, BIN_PATH under /userdata/bios run as the emulator binary."""
|
|
module = "lexaloffle.lexaloffleGenerator"
|
|
src = cg.generator(module)
|
|
generate = src.function("generate")
|
|
defined = {
|
|
name: node for node in src.tree.body if (name := _assigned_name(node)) is not None
|
|
}
|
|
constants = dict(cg.paths)
|
|
for name, node in defined.items():
|
|
value = _path_value(_bound(node), constants)
|
|
if value is not None:
|
|
constants[name] = value
|
|
missing, missing_message = _error_guard(
|
|
src, generate,
|
|
lambda t: isinstance(t, ast.UnaryOp) and isinstance(t.op, ast.Not)
|
|
and isinstance(t.operand, ast.Call) and ast.unparse(t.operand.func).endswith(".exists"),
|
|
"not <binary>.exists()",
|
|
)
|
|
binary = ast.unparse(missing.test.operand.func).removesuffix(".exists") # type: ignore[attr-defined]
|
|
access, access_message = _error_guard(
|
|
src, generate,
|
|
lambda t: isinstance(t, ast.UnaryOp) and isinstance(t.op, ast.Not)
|
|
and ast.unparse(t.operand).startswith(f"os.access({binary}"),
|
|
f"not os.access({binary}, ...)",
|
|
)
|
|
commands = [
|
|
node for node in ast.walk(generate)
|
|
if isinstance(node, ast.Assign) and isinstance(node.value, ast.List)
|
|
and node.value.elts and ast.unparse(node.value.elts[0]) == binary
|
|
]
|
|
if len(commands) != 1:
|
|
src.fail(f"expected one command line starting with {binary}, found {len(commands)}")
|
|
prefix = cg.bios.rstrip("/") + "/"
|
|
launchers = []
|
|
for system, branch in _system_branches(generate):
|
|
bound = [
|
|
stmt for stmt in branch.body
|
|
if _assigned_name(stmt) == binary and isinstance(_bound(stmt), ast.Name)
|
|
]
|
|
if len(bound) != 1:
|
|
src.fail(f"the {system} branch does not set {binary} once")
|
|
constant = ast.unparse(bound[0].value) # type: ignore[attr-defined]
|
|
path = constants.get(constant)
|
|
if path is None or not path.startswith(prefix):
|
|
src.fail(f"{constant} is not a path under {cg.bios}")
|
|
messages = [_render(m, binary, path) for m in (missing_message, access_message)]
|
|
launchers.append(Launcher(
|
|
emulator=cg.emulator(module), build="standalone", kind="fixed_name",
|
|
slot=system, names=[path[len(prefix):]], case_insensitive=False,
|
|
search_dir=cg.bios, passes_to="exec",
|
|
fails_when_missing=", ".join(messages),
|
|
source_ref=src.ref([
|
|
src.span(defined[constant]), src.span(branch.test), src.span(bound[0]),
|
|
_lines(missing), _lines(access),
|
|
src.span(commands[0]),
|
|
]),
|
|
))
|
|
if not launchers:
|
|
src.fail("generate no longer branches on system.name")
|
|
return launchers
|
|
|
|
|
|
def pico8_installer(read: Callable[[str], str], bios: str) -> list[Launcher]:
|
|
"""knulli-install-pico8: unzips the Raspberry Pi build and moves it into the BIOS folder."""
|
|
script = Script(PICO8_INSTALLER, read(PICO8_INSTALLER))
|
|
lines: set[int] = set()
|
|
archive_line, archive = script.find(r"\$\(ls -d (\S+\.zip)", "the PICO-8 zip lookup")
|
|
lines.add(archive_line)
|
|
guard_line, _ = script.find(r'^if \[ -z \$PICO_8_ZIPFILE \]', "the missing zip test")
|
|
message_line, message = script.find(r'^\s*echo "([^"]+)"', "the missing zip message", guard_line)
|
|
lines |= {guard_line, message_line}
|
|
names: list[str] = []
|
|
prefix = bios.rstrip("/") + "/"
|
|
for number, line in script.code():
|
|
moved = re.match(r"\s*mv\s+(\S+)\s+(\S+)\s*$", line)
|
|
if moved is None:
|
|
continue
|
|
used: set[int] = set()
|
|
source = script.expand(moved.group(1), number, used)
|
|
target = script.expand(moved.group(2).strip('"'), number, used)
|
|
if not target.startswith(prefix):
|
|
continue
|
|
if target.endswith("/"):
|
|
target += source.rsplit("/", 1)[-1]
|
|
names.append(target[len(prefix):])
|
|
lines |= used | {number}
|
|
if not names:
|
|
raise script.missing(f"mv into {bios}")
|
|
folder = names[0].rsplit("/", 1)[0]
|
|
return [Launcher(
|
|
emulator="lexaloffle", build="standalone", kind="moves", names=names,
|
|
case_insensitive=False, search_dir=bios,
|
|
passes_to=f"moved from {archive.group(1)} into {prefix}{folder}",
|
|
fails_when_missing=message.group(1),
|
|
source_ref=source_ref([(PICO8_INSTALLER, lines)]),
|
|
)]
|
|
|
|
|
|
def _quoted(node: ast.AST) -> str | None:
|
|
"""The value of a '"name"' core option literal."""
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
value = node.value
|
|
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
return value[1:-1]
|
|
return None
|
|
|
|
|
|
def _cores_of(test: ast.expr) -> list[str]:
|
|
"""Core names `system.config['core'] == 'x'`, `in [...]` or an `or` of them tests."""
|
|
if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.Or):
|
|
return [core for value in test.values for core in _cores_of(value)]
|
|
if (
|
|
isinstance(test, ast.Compare)
|
|
and ast.unparse(test.left) == "system.config['core']"
|
|
and len(test.ops) == 1
|
|
):
|
|
right = test.comparators[0]
|
|
if isinstance(test.ops[0], ast.Eq) and isinstance(right, ast.Constant):
|
|
return [str(right.value)]
|
|
if isinstance(test.ops[0], ast.In) and isinstance(right, (ast.List, ast.Tuple)):
|
|
return [str(e.value) for e in right.elts if isinstance(e, ast.Constant)]
|
|
return []
|
|
|
|
|
|
# libretro core options whose value names the BIOS file the core opens,
|
|
# the same two batocera_launchers.FILE_OPTIONS reads.
|
|
FILE_OPTIONS = ("o2em_bios", "neocd_bios")
|
|
|
|
|
|
def core_file_options(cg: Configgen) -> list[Launcher]:
|
|
"""if isOptSet(o): save(o, option) elif ...: save(o, '"a"') else: save(o, '"b"')."""
|
|
src = cg.source(LIBRETRO_OPTIONS)
|
|
function = src.function("generateCoreSettings")
|
|
launchers = []
|
|
for setting in FILE_OPTIONS:
|
|
chains = [
|
|
node for node in ast.walk(function)
|
|
if isinstance(node, ast.If)
|
|
and ast.unparse(node.test) == f"system.isOptSet({setting!r})"
|
|
]
|
|
if len(chains) != 1:
|
|
src.fail(f"expected one `if system.isOptSet({setting!r})`, found {len(chains)}")
|
|
chain = chains[0]
|
|
names: list[str] = []
|
|
conditions: list[str] = []
|
|
branch: ast.If | None = chain
|
|
tail: list[ast.stmt] = chain.orelse
|
|
while branch is not None:
|
|
tail = branch.orelse
|
|
if branch is not chain:
|
|
saved = _saved(branch.body, setting)
|
|
if saved is None:
|
|
src.fail(f"a branch of {setting} saves no literal name")
|
|
names.append(saved)
|
|
conditions.append(ast.unparse(branch.test))
|
|
branch = tail[0] if len(tail) == 1 and isinstance(tail[0], ast.If) else None
|
|
default = _saved(tail, setting)
|
|
if default is None:
|
|
src.fail(f"{setting} has no literal default")
|
|
names.append(default)
|
|
holder = next(
|
|
(
|
|
node for node in ast.walk(function)
|
|
if isinstance(node, ast.If) and _cores_of(node.test)
|
|
and any(child is chain for child in ast.walk(node))
|
|
),
|
|
None,
|
|
)
|
|
if holder is None:
|
|
src.fail(f"{setting} is not set under a core test")
|
|
depends = setting if not conditions else f"{setting} (default by {', '.join(conditions)})"
|
|
launchers.append(Launcher(
|
|
emulator=", ".join(_cores_of(holder.test)), build="libretro", kind="core_option",
|
|
names=names, passes_to=f"core option {setting}", depends_on_option=depends,
|
|
source_ref=src.ref([src.span(holder.test), _lines(chain)]),
|
|
))
|
|
return launchers
|
|
|
|
|
|
def _saved(body: list[ast.stmt], setting: str) -> str | None:
|
|
"""The literal a one-statement body saves under setting."""
|
|
if len(body) != 1 or not isinstance(body[0], ast.Expr):
|
|
return None
|
|
call = body[0].value
|
|
if (
|
|
isinstance(call, ast.Call)
|
|
and isinstance(call.func, ast.Attribute)
|
|
and call.func.attr == "save"
|
|
and len(call.args) == 2
|
|
and isinstance(call.args[0], ast.Constant)
|
|
and call.args[0].value == setting
|
|
):
|
|
return _quoted(call.args[1])
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Generic reading of the /userdata/bios paths a generator hands over.
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GenericSites:
|
|
"""A generator module whose BIOS path uses bios_sites() reads.
|
|
|
|
kind is fixed_name when the path names a file, passes_path when it names
|
|
a directory. A copy is recorded as `copies` whatever the kind, its names
|
|
read from the folder the code copies from and passes_to the target. caller
|
|
names the module and function whose core test selects the cores this
|
|
helper module runs for, emulator the name when no core test does.
|
|
"""
|
|
|
|
path: str
|
|
kind: str
|
|
build: str = "standalone"
|
|
emulator: str | None = None
|
|
caller: tuple[str, str] | None = None
|
|
|
|
|
|
# The names configgen gives the command it builds.
|
|
COMMAND_NAMES = {"commandArray", "commandLine"}
|
|
|
|
GENERIC_SITES = (
|
|
GenericSites("dolphin/dolphinGenerator.py", "fixed_name"),
|
|
GenericSites("yabasanshiro/yabasanshiroGenerator.py", "fixed_name"),
|
|
GenericSites("rpcs3/rpcs3Generator.py", "fixed_name"),
|
|
GenericSites("ryujinx/ryujinxGenerator.py", "fixed_name"),
|
|
GenericSites("pcsx2/pcsx2Generator.py", "fixed_name"),
|
|
GenericSites("aethersx2/aethersx2Generator.py", "fixed_name"),
|
|
GenericSites("gsplus/gsplusGenerator.py", "passes_path"),
|
|
GenericSites("tsugaru/tsugaruGenerator.py", "passes_path"),
|
|
GenericSites("scummvm/scummvmGenerator.py", "passes_path"),
|
|
GenericSites("fsuae/fsuaeGenerator.py", "passes_path"),
|
|
GenericSites("mame/mameGenerator.py", "passes_path"),
|
|
GenericSites("fpinball/fpinballGenerator.py", "passes_path"),
|
|
GenericSites(
|
|
"libretro/libretroRetroarchCustom.py", "passes_path", "libretro", emulator="retroarch"
|
|
),
|
|
GenericSites("libretro/libretroConfig.py", "passes_path", "libretro"),
|
|
GenericSites(
|
|
"libretro/libretroMAMEConfig.py", "passes_path", "libretro",
|
|
caller=("libretro/libretroConfig.py", "generateMAMEConfigs"),
|
|
),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _Site:
|
|
"""One use of a BIOS path, and the record fields it contributes."""
|
|
|
|
path: str
|
|
passes_to: str
|
|
kind: str | None
|
|
lines: list[tuple[int, int]]
|
|
conditions: list[str]
|
|
cores: list[str]
|
|
expands: dict[str, list[str]] = field(default_factory=dict)
|
|
# A copy reads its file from `source` and hands it to `passes_to`.
|
|
source: str | None = None
|
|
|
|
|
|
class _Reader:
|
|
"""The BIOS path uses of one generator module."""
|
|
|
|
def __init__(self, cg: Configgen, src: Source) -> None:
|
|
self.cg = cg
|
|
self.src = src
|
|
self.bios = cg.bios.rstrip("/")
|
|
self.parents: dict[int, ast.AST] = {}
|
|
self.copy_sources: dict[int, str] = {}
|
|
for parent in ast.walk(src.tree):
|
|
for child in ast.iter_child_nodes(parent):
|
|
self.parents[id(child)] = parent
|
|
self.constants = dict(cg.paths)
|
|
folder = src.path.rsplit("/", 1)[0]
|
|
for node in src.tree.body:
|
|
if (
|
|
isinstance(node, ast.ImportFrom)
|
|
and node.level == 1
|
|
and node.module
|
|
and node.module.endswith("Paths")
|
|
):
|
|
paths = cg.source(f"{folder}/{node.module}.py")
|
|
self._define(paths.tree.body)
|
|
self._define(src.tree.body)
|
|
|
|
def _define(self, body: list[ast.stmt]) -> None:
|
|
for node in body:
|
|
name = _assigned_name(node)
|
|
value = self.resolve(getattr(node, "value", None))
|
|
if name is not None and value is not None:
|
|
self.constants[name] = value
|
|
|
|
def under_bios(self, value: str | None) -> bool:
|
|
return value is not None and (
|
|
value.rstrip("/") == self.bios or f"{self.bios}/" in value
|
|
)
|
|
|
|
def resolve(self, node: ast.AST | None) -> str | None:
|
|
"""The path an expression spells, unknown parts of an f-string as <expr>."""
|
|
if node is None:
|
|
return None
|
|
value = _path_value(node, self.constants)
|
|
if value is not None:
|
|
return value
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
text = node.value.strip('"')
|
|
return text if text.startswith("/") else None
|
|
if isinstance(node, ast.Call) and ast.unparse(node.func) == "str" and len(node.args) == 1:
|
|
return self.resolve(node.args[0])
|
|
if isinstance(node, ast.JoinedStr):
|
|
parts = []
|
|
for part in node.values:
|
|
if isinstance(part, ast.Constant):
|
|
parts.append(str(part.value))
|
|
else:
|
|
inner = self.resolve(part.value) # type: ignore[attr-defined]
|
|
parts.append(inner if inner is not None else f"<{ast.unparse(part.value)}>") # type: ignore[attr-defined]
|
|
return "".join(parts).strip('"')
|
|
if (
|
|
isinstance(node, ast.BinOp)
|
|
and isinstance(node.op, ast.Div)
|
|
and isinstance(node.right, (ast.Constant, ast.Name))
|
|
):
|
|
left = self.resolve(node.left)
|
|
right = node.right.value if isinstance(node.right, ast.Constant) else None
|
|
if isinstance(node.right, ast.Name):
|
|
right = self._placeholder(node.right)
|
|
if left is not None and isinstance(right, str):
|
|
return f"{left.rstrip('/')}/{right}"
|
|
return None
|
|
|
|
def _placeholder(self, name: ast.Name) -> str | None:
|
|
"""{var} for a comprehension variable over a literal list."""
|
|
comprehension = self._comprehension(name)
|
|
if comprehension is None:
|
|
return None
|
|
return "{" + name.id + "}"
|
|
|
|
def _comprehension(self, name: ast.Name) -> tuple[str, list[str]] | None:
|
|
node: ast.AST | None = name
|
|
while node is not None:
|
|
node = self.parents.get(id(node))
|
|
if isinstance(node, (ast.GeneratorExp, ast.ListComp)):
|
|
for generator in node.generators:
|
|
if isinstance(generator.target, ast.Name) and generator.target.id == name.id:
|
|
values = self._literal_list(generator.iter, node)
|
|
return (name.id, values) if values else None
|
|
return None
|
|
|
|
def _literal_list(self, node: ast.AST, scope_of: ast.AST) -> list[str] | None:
|
|
if isinstance(node, ast.List):
|
|
return [str(e.value) for e in node.elts if isinstance(e, ast.Constant)]
|
|
if isinstance(node, ast.Name):
|
|
scope = self.function_of(scope_of)
|
|
bound = [
|
|
n for n in ast.walk(scope or self.src.tree)
|
|
if _assigned_name(n) == node.id and isinstance(_bound(n), ast.List)
|
|
]
|
|
if len(bound) == 1:
|
|
return self._literal_list(_bound(bound[0]), scope_of) # type: ignore[arg-type]
|
|
return None
|
|
|
|
def function_of(self, node: ast.AST) -> ast.FunctionDef | None:
|
|
while node is not None:
|
|
node = self.parents.get(id(node)) # type: ignore[assignment]
|
|
if isinstance(node, ast.FunctionDef):
|
|
return node
|
|
return None
|
|
|
|
def statement_of(self, node: ast.AST) -> ast.stmt:
|
|
while not isinstance(node, ast.stmt):
|
|
node = self.parents[id(node)]
|
|
return node
|
|
|
|
def maximal(self) -> Iterator[tuple[ast.AST, str]]:
|
|
"""Each expression spelling a BIOS path that no larger one contains."""
|
|
for node in ast.walk(self.src.tree):
|
|
if not isinstance(node, ast.expr):
|
|
continue
|
|
value = self.resolve(node)
|
|
if value is None and isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
# A line written to a config file: openmsx's settings.tcl.
|
|
written = re.search(rf"{re.escape(self.bios)}(?:/[^\s\"';]*)?", node.value)
|
|
parent = self.parents.get(id(node))
|
|
if written and isinstance(parent, ast.Call) and ast.unparse(parent.func).endswith(".write"):
|
|
yield node, written.group(0)
|
|
continue
|
|
if not self.under_bios(value):
|
|
continue
|
|
parent = self.parents.get(id(node))
|
|
if isinstance(parent, (ast.FormattedValue, ast.JoinedStr)):
|
|
continue
|
|
if isinstance(parent, ast.expr) and self.under_bios(self.resolve(parent)):
|
|
continue
|
|
if isinstance(parent, ast.Call) and ast.unparse(parent.func) == "str":
|
|
continue
|
|
yield node, value # type: ignore[misc]
|
|
|
|
def conditions(
|
|
self, node: ast.AST, guards: bool = False
|
|
) -> tuple[list[str], list[tuple[int, int]]]:
|
|
"""Tests of the ifs a node sits in, outermost first, and their lines.
|
|
|
|
A presence test of a BIOS path is a guard, kept for its lines only,
|
|
unless guards is set: a copy made only when its source or its target
|
|
is there carries the test as a condition.
|
|
"""
|
|
tests: list[str] = []
|
|
spans: list[tuple[int, int]] = []
|
|
child, parent = node, self.parents.get(id(node))
|
|
while parent is not None and not isinstance(parent, ast.FunctionDef):
|
|
if isinstance(parent, ast.If) and child is not parent.test:
|
|
spans.append(self.src.span(parent.test))
|
|
text = ast.unparse(parent.test)
|
|
if guards or not self._is_guard(parent.test):
|
|
in_else = any(child is stmt for stmt in parent.orelse)
|
|
tests.append(f"not ({text})" if in_else else text)
|
|
child, parent = parent, self.parents.get(id(parent))
|
|
return list(reversed(tests)), spans
|
|
|
|
def _is_guard(self, test: ast.expr) -> bool:
|
|
for n in ast.walk(test):
|
|
if not (
|
|
isinstance(n, ast.Call)
|
|
and isinstance(n.func, ast.Attribute)
|
|
and n.func.attr == "exists"
|
|
):
|
|
continue
|
|
receiver = n.func.value
|
|
if self.under_bios(self.resolve(receiver)):
|
|
return True
|
|
if isinstance(receiver, ast.Name) and self.local_value(receiver) is not None:
|
|
return True
|
|
return False
|
|
|
|
def cores(self, node: ast.AST) -> list[str]:
|
|
child, parent = node, self.parents.get(id(node))
|
|
while parent is not None:
|
|
if isinstance(parent, ast.If) and child is not parent.test and _cores_of(parent.test):
|
|
return _cores_of(parent.test)
|
|
child, parent = parent, self.parents.get(id(parent))
|
|
return []
|
|
|
|
def uses(self, node: ast.AST, value: str) -> Iterator[tuple[ast.AST, str | None, str | None]]:
|
|
"""(node read, passes_to, kind) for each consumer of a BIOS path expression.
|
|
|
|
A path bound to a local name is followed to each read of that name.
|
|
A use with no consumer (a presence test, a mkdir) yields passes_to None.
|
|
"""
|
|
parent = self.parents.get(id(node))
|
|
if isinstance(parent, ast.Assign) and len(parent.targets) == 1:
|
|
target = parent.targets[0]
|
|
if isinstance(target, ast.Name):
|
|
scope = self.function_of(parent)
|
|
if scope is None:
|
|
return
|
|
for read in ast.walk(scope):
|
|
if (
|
|
isinstance(read, ast.Name)
|
|
and read.id == target.id
|
|
and isinstance(read.ctx, ast.Load)
|
|
and read.lineno > parent.lineno
|
|
):
|
|
yield from self.uses(read, value)
|
|
return
|
|
if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant):
|
|
yield node, str(target.slice.value), None
|
|
return
|
|
yield node, *self.consumer(node, value)
|
|
|
|
def _command_line(self, display: ast.List | ast.Tuple) -> bool:
|
|
"""Whether a list display is (part of) the command configgen runs.
|
|
|
|
It is assigned or added to commandArray (or commandLine for the
|
|
libretro MAME cmd file), or it starts with an absolute binary path.
|
|
"""
|
|
first = display.elts[0] if display.elts else None
|
|
if isinstance(first, ast.Constant) and str(first.value).startswith("/"):
|
|
return True
|
|
holder = self.parents.get(id(display))
|
|
targets: list[ast.expr] = []
|
|
if isinstance(holder, ast.Assign):
|
|
targets = holder.targets
|
|
elif isinstance(holder, ast.AugAssign):
|
|
targets = [holder.target]
|
|
elif (
|
|
isinstance(holder, ast.Call)
|
|
and isinstance(holder.func, ast.Attribute)
|
|
and holder.func.attr in ("extend", "append")
|
|
):
|
|
targets = [holder.func.value]
|
|
names = {
|
|
ast.unparse(t.value if isinstance(t, ast.Subscript) else t) for t in targets
|
|
}
|
|
return bool(names & COMMAND_NAMES)
|
|
|
|
def local_value(self, name: ast.Name) -> str | None:
|
|
"""The BIOS path a local name is bound to once in its function."""
|
|
scope = self.function_of(name)
|
|
if scope is None:
|
|
return None
|
|
bound = [
|
|
self.resolve(_bound(n))
|
|
for n in ast.walk(scope) if _assigned_name(n) == name.id
|
|
]
|
|
return bound[0] if len(bound) == 1 and self.under_bios(bound[0]) else None
|
|
|
|
def consumer(self, node: ast.AST, value: str) -> tuple[str | None, str | None]:
|
|
parent = self.parents.get(id(node))
|
|
if isinstance(parent, ast.Attribute):
|
|
return None, None
|
|
if isinstance(parent, (ast.List, ast.Tuple)):
|
|
holder = self.parents.get(id(parent))
|
|
if isinstance(holder, (ast.For, ast.comprehension)):
|
|
return None, None
|
|
if not self._command_line(parent):
|
|
return None, None
|
|
index = next(i for i, e in enumerate(parent.elts) if e is node)
|
|
if isinstance(node, ast.JoinedStr) and isinstance(node.values[0], ast.Constant):
|
|
flag = str(node.values[0].value)
|
|
if flag.startswith("-") and flag.endswith("=") and value.startswith(flag):
|
|
return f"command line {flag[:-1]}", None
|
|
previous = parent.elts[index - 1] if index else None
|
|
if isinstance(previous, ast.Constant) and str(previous.value).startswith("-"):
|
|
return f"command line {previous.value!s}", None
|
|
if index:
|
|
return f"command line argument {index}", None
|
|
return None, None
|
|
if isinstance(parent, ast.Call):
|
|
func = parent.func
|
|
attr = func.attr if isinstance(func, ast.Attribute) else None
|
|
consts = [a.value for a in parent.args if isinstance(a, ast.Constant)]
|
|
if attr == "save" and len(parent.args) == 2 and parent.args[1] is node and consts:
|
|
return str(consts[0]), None
|
|
if attr == "set" and len(parent.args) == 3 and parent.args[2] is node and len(consts) >= 2:
|
|
return f"[{consts[0]!s}] {consts[1]!s}", None
|
|
receiver = func.value if isinstance(func, ast.Attribute) else None
|
|
if attr in ("copy", "copyfile", "copy2") and receiver is not None and ast.unparse(receiver) == "shutil":
|
|
source, target = parent.args[0], parent.args[1]
|
|
self.copy_sources[id(node)] = value if source is node else (
|
|
self.resolve(source) or ast.unparse(source)
|
|
)
|
|
return (value if target is node else self.resolve(target) or ast.unparse(target)), "copies"
|
|
if (
|
|
attr == "append"
|
|
and isinstance(receiver, ast.Name)
|
|
and receiver.id in COMMAND_NAMES
|
|
and isinstance(node, ast.JoinedStr)
|
|
and isinstance(node.values[0], ast.Constant)
|
|
):
|
|
flag = str(node.values[0].value)
|
|
if flag.startswith("-") and flag.endswith("=") and value.startswith(flag):
|
|
return f"command line {flag[:-1]}", None
|
|
if attr == "write" and isinstance(node, ast.Constant):
|
|
return f"written: {str(node.value).strip()}", None
|
|
return None, None
|
|
if isinstance(parent, ast.Dict):
|
|
key = next(
|
|
(k for k, v in zip(parent.keys, parent.values) if v is node), None
|
|
)
|
|
if isinstance(key, ast.Constant):
|
|
holder = self.parents.get(id(parent))
|
|
is_env = (
|
|
isinstance(holder, ast.keyword) and holder.arg == "env"
|
|
) or _assigned_name(holder) == "env" # type: ignore[arg-type]
|
|
return (f"environment {key.value!s}" if is_env else str(key.value)), None
|
|
return None, None
|
|
|
|
def sites(self) -> list[_Site]:
|
|
found: list[_Site] = []
|
|
guards: list[tuple[ast.AST, str]] = []
|
|
for node, value in self.maximal():
|
|
if isinstance(self.statement_of(node), (ast.Import, ast.ImportFrom)):
|
|
continue
|
|
if self.function_of(node) is None and isinstance(self.parents.get(id(node)), ast.Assign):
|
|
continue
|
|
for read, passes_to, kind in self.uses(node, value):
|
|
parent = self.parents.get(id(read))
|
|
if (
|
|
passes_to is None
|
|
and isinstance(parent, ast.Attribute)
|
|
and parent.attr == "exists"
|
|
):
|
|
guards.append((read, value))
|
|
if passes_to is None:
|
|
continue
|
|
found.append(self._site(read, value, passes_to, kind, node))
|
|
for read, value in guards:
|
|
if any(site.path == value for site in found):
|
|
site = next(s for s in found if s.path == value)
|
|
site.lines.append(self.src.span(self.statement_of(read)))
|
|
continue
|
|
found.append(self._guard_site(read, value))
|
|
return sorted(found, key=lambda site: site.lines[0])
|
|
|
|
def _site(
|
|
self, read: ast.AST, value: str, passes_to: str, kind: str | None, defined: ast.AST
|
|
) -> _Site:
|
|
tests, spans = self.conditions(read, guards=kind == "copies")
|
|
lines = [self.src.span(self.statement_of(read)), *spans]
|
|
if defined is not read:
|
|
# A path bound in one branch and read after the if: the branch
|
|
# that bound it is a condition of the read.
|
|
bound_tests, bound_spans = self.conditions(defined)
|
|
tests = list(dict.fromkeys([*bound_tests, *tests]))
|
|
lines += [self.src.span(self.statement_of(defined)), *bound_spans]
|
|
return _Site(
|
|
value, passes_to, kind, lines, tests, self.cores(read), self._expands(read),
|
|
self.copy_sources.get(id(read)),
|
|
)
|
|
|
|
def _guard_site(self, read: ast.AST, value: str) -> _Site:
|
|
"""A presence test whose if body is the only consumer of the path."""
|
|
test = self.statement_of(read)
|
|
if not isinstance(test, ast.If):
|
|
self.src.fail(f"line {test.lineno} tests a BIOS path outside an if")
|
|
first = test.body[0]
|
|
described = ast.unparse(first)
|
|
call = first.value if isinstance(first, ast.Expr) else None
|
|
if isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute) and call.func.attr == "set":
|
|
literal = [a.value for a in call.args if isinstance(a, ast.Constant)]
|
|
if len(literal) == 3:
|
|
described = f"[{literal[0]!s}] {literal[1]!s} = {literal[2]!s}"
|
|
tests, spans = self.conditions(test)
|
|
return _Site(
|
|
value, f"presence sets {described}", None,
|
|
[(test.lineno, first.end_lineno), *spans], # type: ignore[list-item]
|
|
tests, self.cores(test), self._expands(read),
|
|
)
|
|
|
|
def _expands(self, node: ast.AST) -> dict[str, list[str]]:
|
|
found: dict[str, list[str]] = {}
|
|
for name in ast.walk(node):
|
|
if isinstance(name, ast.Name):
|
|
bound = self._comprehension(name)
|
|
if bound is not None:
|
|
found[bound[0]] = bound[1]
|
|
return found
|
|
|
|
|
|
def _caller_cores(cg: Configgen, caller: tuple[str, str]) -> list[str]:
|
|
path, function = caller
|
|
src = cg.source(f"{cg.generators}/{path}")
|
|
for node in ast.walk(src.tree):
|
|
if not isinstance(node, ast.If) or not _cores_of(node.test):
|
|
continue
|
|
if any(
|
|
isinstance(n, ast.Call) and ast.unparse(n.func).endswith(f".{function}")
|
|
for stmt in node.body for n in ast.walk(stmt)
|
|
):
|
|
return _cores_of(node.test)
|
|
src.fail(f"no core test calls {function}")
|
|
|
|
|
|
def bios_sites(cg: Configgen, spec: GenericSites) -> list[Launcher]:
|
|
"""One record per BIOS path and consumer of a generator module."""
|
|
src = cg.source(f"{cg.generators}/{spec.path}")
|
|
reader = _Reader(cg, src)
|
|
sites = reader.sites()
|
|
if not sites:
|
|
src.fail("hands over no /userdata/bios path")
|
|
module = spec.path.removesuffix(".py").replace("/", ".")
|
|
caller_cores = _caller_cores(cg, spec.caller) if spec.caller else []
|
|
grouped: dict[tuple[str, str, str, tuple[str, ...]], list[_Site]] = {}
|
|
for site in sites:
|
|
flag = site.passes_to.removeprefix("command line ")
|
|
if site.path.startswith(f"{flag}="):
|
|
site.path = site.path[len(flag) + 1:]
|
|
emulator = (
|
|
spec.emulator
|
|
or ", ".join(site.cores or caller_cores)
|
|
or (cg.emulator(module) if spec.build == "standalone" else "")
|
|
)
|
|
if not emulator:
|
|
src.fail(f"no core runs the {site.passes_to} use at line {site.lines[0][0]}")
|
|
key = (emulator, site.path, site.passes_to, tuple(site.conditions))
|
|
grouped.setdefault(key, []).append(site)
|
|
prefix = cg.bios.rstrip("/") + "/"
|
|
launchers = []
|
|
for (emulator, path, passes_to, _conditions), group in grouped.items():
|
|
kind = group[0].kind or spec.kind
|
|
conditions = list(dict.fromkeys(c for site in group for c in site.conditions))
|
|
expands = {k: v for site in group for k, v in site.expands.items()}
|
|
names: list[str] = []
|
|
search_dir = path
|
|
source = group[0].source
|
|
if kind == "copies" and source is not None and not source.startswith(prefix):
|
|
# Copied into BIOS: the file is read from the folder it comes from.
|
|
search_dir, _, name = source.rpartition("/")
|
|
names = [name]
|
|
elif kind in ("fixed_name", "copies"):
|
|
if not path.startswith(prefix):
|
|
src.fail(f"{path} names no file under {cg.bios}")
|
|
names, search_dir = [path[len(prefix):]], cg.bios
|
|
launchers.append(Launcher(
|
|
emulator=emulator, build=spec.build, kind=kind, names=names,
|
|
expands=expands or None,
|
|
case_insensitive=False if names else None,
|
|
search_dir=search_dir, passes_to=passes_to, fails_when_missing=False,
|
|
depends_on_option=" and ".join(conditions) or None,
|
|
source_ref=src.ref([span for site in group for span in site.lines]),
|
|
))
|
|
return launchers
|
|
|
|
|
|
# Generators that write a [Folders] path relative to the config folder they
|
|
# are given. PCSX2 and AetherSX2 resolve those paths against their data root,
|
|
# the folder holding inis/, which configureINI receives as config_directory.
|
|
RELATIVE_FOLDERS = (
|
|
("pcsx2/pcsx2Generator.py", "configureINI"),
|
|
("aethersx2/aethersx2Generator.py", "configureINI"),
|
|
)
|
|
|
|
|
|
def relative_folders(
|
|
cg: Configgen, specs: tuple[tuple[str, str], ...] = RELATIVE_FOLDERS
|
|
) -> list[Launcher]:
|
|
"""parser.set(section, key, "../<path>") resolved against config_directory."""
|
|
launchers = []
|
|
for path, function_name in specs:
|
|
src = cg.source(f"{cg.generators}/{path}")
|
|
reader = _Reader(cg, src)
|
|
function = src.function(function_name)
|
|
if not function.args.args:
|
|
src.fail(f"{function_name} takes no config folder")
|
|
folder = function.args.args[0].arg
|
|
ini = [
|
|
node for node in ast.walk(function)
|
|
if isinstance(node, ast.Assign) and isinstance(node.value, ast.BinOp)
|
|
and ast.unparse(node.value).startswith(f"{folder} /")
|
|
and ast.unparse(node.value).endswith(".ini'")
|
|
]
|
|
if len(ini) != 1:
|
|
src.fail(f"{function_name} builds no one ini path under {folder}")
|
|
parts = sorted(
|
|
(n for n in ast.walk(ini[0].value)
|
|
if isinstance(n, ast.Constant) and isinstance(n.value, str)),
|
|
key=lambda n: (n.lineno, n.col_offset),
|
|
)
|
|
ini_name = posixpath.join(*(str(n.value) for n in parts))
|
|
calls = [
|
|
node for node in ast.walk(src.tree)
|
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
and node.func.id == function_name and node.args
|
|
]
|
|
if len(calls) != 1:
|
|
src.fail(f"expected one call of {function_name}, found {len(calls)}")
|
|
base = reader.resolve(calls[0].args[0])
|
|
if base is None or not base.startswith("/"):
|
|
src.fail(f"{function_name} is given a folder that is not a path")
|
|
found = 0
|
|
for call in ast.walk(function):
|
|
if not (
|
|
isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)
|
|
and call.func.attr == "set" and len(call.args) == 3
|
|
and all(isinstance(a, ast.Constant) for a in call.args)
|
|
):
|
|
continue
|
|
section, key, value = (str(a.value) for a in call.args) # type: ignore[attr-defined]
|
|
if not value.startswith("../"):
|
|
continue
|
|
resolved = posixpath.normpath(posixpath.join(base, value))
|
|
if not reader.under_bios(resolved):
|
|
continue
|
|
found += 1
|
|
launchers.append(Launcher(
|
|
emulator=cg.emulator(path.removesuffix(".py").replace("/", ".")),
|
|
build="standalone", kind="passes_path", names=[], search_dir=resolved,
|
|
passes_to=f"{ini_name} [{section}] {key} = {value}, relative to {base}",
|
|
fails_when_missing=False,
|
|
source_ref=src.ref([
|
|
src.span(reader.statement_of(calls[0])), src.span(ini[0]), _lines(call),
|
|
]),
|
|
))
|
|
if not found:
|
|
src.fail(f"{function_name} writes no relative folder under {cg.bios}")
|
|
return launchers
|
|
|
|
|
|
def _yaml_node(root: yaml.Node, keys: tuple[str, ...], path: str) -> yaml.Node:
|
|
node = root
|
|
for key in keys:
|
|
if not isinstance(node, yaml.MappingNode):
|
|
raise LauncherShapeError(f"{path}: {key} is not under a mapping")
|
|
found = [v for k, v in node.value if isinstance(k, yaml.ScalarNode) and k.value == key]
|
|
if len(found) != 1:
|
|
raise LauncherShapeError(f"{path}: no {'/'.join(keys)}")
|
|
node = found[0]
|
|
return node
|
|
|
|
|
|
def option_choices(read: Callable[[str], str], keys: tuple[str, ...]) -> tuple[list[str], tuple[int, int]]:
|
|
"""The values es_features.yml offers for an option, and their lines."""
|
|
node = _yaml_node(yaml.compose(read(ES_FEATURES)), (*keys, "choices"), ES_FEATURES)
|
|
if not isinstance(node, yaml.MappingNode) or not node.value:
|
|
raise LauncherShapeError(f"{ES_FEATURES}: {'/'.join(keys)} has no choices")
|
|
values = [str(v.value) for _k, v in node.value if isinstance(v, yaml.ScalarNode)]
|
|
return values, (node.start_mark.line + 1, node.end_mark.line)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BootDisk:
|
|
"""A generator that names a BIOS file from an option value, and the ES feature offering it."""
|
|
|
|
path: str
|
|
feature: tuple[str, ...]
|
|
build: str = "standalone"
|
|
caller: tuple[str, str] | None = None
|
|
|
|
|
|
BOOT_DISKS = (
|
|
BootDisk("mame/mameGenerator.py", ("mame", "systems", "macintosh", "cfeatures")),
|
|
BootDisk(
|
|
"libretro/libretroMAMEConfig.py",
|
|
("libretro", "cores", "mame", "systems", "macintosh", "cfeatures"), "libretro",
|
|
("libretro/libretroConfig.py", "generateMAMEConfigs"),
|
|
),
|
|
)
|
|
|
|
|
|
def _option_template(reader: _Reader, node: ast.AST) -> tuple[str, str] | None:
|
|
"""(option, name) for '<bios>/' + system.config[option] + '<suffix>'."""
|
|
if not (isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add)):
|
|
return None
|
|
left, suffix = node.left, node.right
|
|
if not (
|
|
isinstance(left, ast.BinOp) and isinstance(left.op, ast.Add)
|
|
and isinstance(left.left, ast.Constant) and isinstance(suffix, ast.Constant)
|
|
and isinstance(left.right, ast.Subscript)
|
|
and ast.unparse(left.right.value) == "system.config"
|
|
and isinstance(left.right.slice, ast.Constant)
|
|
):
|
|
return None
|
|
prefix = str(left.left.value).strip('"')
|
|
if not prefix.startswith(reader.bios + "/"):
|
|
return None
|
|
option = str(left.right.slice.value)
|
|
name = prefix[len(reader.bios) + 1:] + "{" + option + "}" + str(suffix.value).strip('"')
|
|
return option, name
|
|
|
|
|
|
def boot_disks(
|
|
cg: Configgen, read: Callable[[str], str], specs: tuple[BootDisk, ...] = BOOT_DISKS
|
|
) -> list[Launcher]:
|
|
"""`if system.config[o] in [...]: X = bios + o + ext else: X = bios + o + ext2`."""
|
|
launchers = []
|
|
for spec in specs:
|
|
src = cg.source(f"{cg.generators}/{spec.path}")
|
|
reader = _Reader(cg, src)
|
|
module = spec.path.removesuffix(".py").replace("/", ".")
|
|
emulator = (
|
|
", ".join(_caller_cores(cg, spec.caller)) if spec.caller else cg.emulator(module)
|
|
)
|
|
if spec.caller and spec.feature[:2] == ("libretro", "cores"):
|
|
# The option is offered for one core of the system: es_features
|
|
# lists it under that core, the other cores the generator serves
|
|
# never see it (KNULLI's macintosh offers mame alone).
|
|
core = spec.feature[2]
|
|
if core not in emulator.split(", "):
|
|
src.fail(f"es_features offers the option for {core}, which the generator does not serve")
|
|
emulator = core
|
|
found = 0
|
|
for chain in ast.walk(src.tree):
|
|
if not (
|
|
isinstance(chain, ast.If) and isinstance(chain.test, ast.Compare)
|
|
and isinstance(chain.test.ops[0], ast.In)
|
|
and isinstance(chain.test.comparators[0], ast.List)
|
|
and ast.unparse(chain.test.left).startswith("system.config[")
|
|
):
|
|
continue
|
|
branches = [(chain.body, True), (chain.orelse, False)]
|
|
templates = []
|
|
for body, listed in branches:
|
|
flag = next(
|
|
(str(s.value.value) for s in body if isinstance(s, ast.Assign)
|
|
and isinstance(s.value, ast.Constant) and str(s.value.value).startswith("-")),
|
|
None,
|
|
)
|
|
named = [
|
|
(s, t) for s in body if isinstance(s, ast.Assign)
|
|
and (t := _option_template(reader, s.value)) is not None
|
|
]
|
|
if flag is None or len(named) != 1:
|
|
break
|
|
templates.append((listed, flag, *named[0]))
|
|
if len(templates) != 2:
|
|
continue
|
|
option = templates[0][3][0]
|
|
if option != templates[1][3][0] or ast.unparse(chain.test.left) != f"system.config[{option!r}]":
|
|
src.fail(f"line {chain.lineno} names a file from two options")
|
|
listed_values = [
|
|
str(e.value) for e in chain.test.comparators[0].elts if isinstance(e, ast.Constant)
|
|
]
|
|
offered, lines = option_choices(read, (*spec.feature, option))
|
|
holder = reader.parents.get(id(chain))
|
|
body = getattr(holder, "body", [])
|
|
index = next(i for i, stmt in enumerate(body) if stmt is chain)
|
|
added = body[index + 1] if index + 1 < len(body) else None
|
|
if not (
|
|
isinstance(added, ast.AugAssign) and isinstance(added.value, ast.List)
|
|
and ast.unparse(added.target) in COMMAND_NAMES
|
|
):
|
|
src.fail(f"the file chosen at line {chain.lineno} is not put on the command line")
|
|
for listed, flag, statement, (_, name) in templates:
|
|
values = listed_values if listed else [v for v in offered if v not in listed_values]
|
|
if not values:
|
|
src.fail(f"no {option} value reaches the branch at line {statement.lineno}")
|
|
tests, spans = reader.conditions(statement)
|
|
found += 1
|
|
launchers.append(Launcher(
|
|
emulator=emulator, build=spec.build, kind="fixed_name", names=[name],
|
|
expands={option: values}, case_insensitive=False, search_dir=cg.bios,
|
|
passes_to=f"command line {flag}", fails_when_missing=False,
|
|
depends_on_option=f"{option} ({' and '.join(tests)})",
|
|
# The values the menu offers come from es_features.yml;
|
|
# the generator writing the choice is the evidence.
|
|
choices_from=None if listed else f"{ES_FEATURES}:{lines[0]}-{lines[1]}",
|
|
source_ref=src.ref([*spans, src.span(statement), _lines(added)]),
|
|
))
|
|
if not found:
|
|
src.fail("names no BIOS file from an option value")
|
|
return launchers
|
|
|
|
|
|
EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = (
|
|
*SHARED, lexaloffle, core_file_options, relative_folders,
|
|
)
|
|
|
|
|
|
# KNULLI builds Batocera's emulator packages from this submodule, at the
|
|
# batocera.linux commit it pins: Citron's path patch is there.
|
|
BATOCERA_SUBMODULE = "batocera"
|
|
|
|
|
|
def extract_launchers(
|
|
read: Callable[[str], str], batocera: Callable[[str], str] | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""Every launcher record, read through `read(repo-relative path)`.
|
|
|
|
`batocera` reads the batocera submodule at its pinned commit. The
|
|
records it gives name the file by its path in KNULLI's repository.
|
|
"""
|
|
cg = configgen(read)
|
|
launchers = [launcher for extractor in EXTRACTORS for launcher in extractor(cg)]
|
|
launchers += pico8_installer(read, cg.bios)
|
|
launchers += boot_disks(cg, read)
|
|
for spec in GENERIC_SITES:
|
|
launchers += bios_sites(cg, spec)
|
|
if batocera is not None:
|
|
prefix = f"{BATOCERA_SUBMODULE}/"
|
|
launchers += patched_paths(
|
|
lambda path: batocera(path.removeprefix(prefix)),
|
|
tuple(prefix + patch for patch in PATH_PATCHES),
|
|
)
|
|
return [launcher.entry() for launcher in launchers]
|
|
|
|
|
|
__all__ = [
|
|
"CONFIGGEN", "GENERIC_SITES", "LauncherPatternMissing", "LauncherShapeError",
|
|
"bios_sites", "configgen", "core_file_options", "extract_launchers", "lexaloffle",
|
|
"pico8_installer",
|
|
]
|