mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
ROCKNIX, EmuELEC, AmberELEC, EmuDeck and RetroDECK records citing a config now carry the code installing it, whether that keeps a file already there, and the launch code rewriting its keys. Rescraped at the pinned revisions. Records the shipped code never reads are no longer written: EmuELEC's mame.ini folders (MAME.opt turns paths off), AmberELEC's atari800.cfg (read only under an option that is off), the six RetroDECK XRoar ROM lists no machine looks up, and the ROCKNIX melonDS DSi BIOS keys (DSiExternalBIOSEnable is set by no shipped ini). Conditions added: ROCKNIX and AmberELEC amiberry kickstarts hold only outside a WHDLoad --autoload boot, ROCKNIX NanoBoyAdvance exits without its BIOS, the EmuELEC scummvm.ini copy runs from an action no launch passes, and the EmuDeck melonDS.toml keys need ExternalBIOSEnable (DS) or ConsoleType 1 (DSi).
884 lines
34 KiB
Python
884 lines
34 KiB
Python
"""ROCKNIX's launcher layer, read from its packages at the pinned tag.
|
|
|
|
ROCKNIX starts each standalone emulator from a shell script and installs a
|
|
config with the package. Between a file in /storage/roms/bios and the
|
|
emulator, the config names the path (melonDS, xemu, mednafen), the script
|
|
searches or links (yabasanshiro, drastic, dolphin), or the emulator is
|
|
handed a directory and left to choose (DuckStation, AetherSX2). Several
|
|
packages ship one config per device: a record carries `device` only where
|
|
the value read differs between them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import posixpath
|
|
import re
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
|
|
try:
|
|
from .configgen_launchers import Launcher
|
|
from .shell_launchers import config_placement, evidence_fields
|
|
from .shell_launchers import (
|
|
LauncherPatternMissing,
|
|
Link,
|
|
RepoTree,
|
|
Script,
|
|
Setting,
|
|
describe_keys,
|
|
settings,
|
|
source_ref,
|
|
)
|
|
except ImportError:
|
|
from configgen_launchers import Launcher
|
|
from shell_launchers import config_placement, evidence_fields # type: ignore[no-redef]
|
|
from shell_launchers import (
|
|
LauncherPatternMissing,
|
|
Link,
|
|
RepoTree,
|
|
Script,
|
|
Setting,
|
|
describe_keys,
|
|
settings,
|
|
source_ref,
|
|
)
|
|
|
|
ROCKNIX = "projects/ROCKNIX"
|
|
DEVICES = f"{ROCKNIX}/devices"
|
|
STANDALONE = f"{ROCKNIX}/packages/emulators/standalone"
|
|
RETROARCH = f"{ROCKNIX}/packages/emulators/libretro/retroarch"
|
|
BIOS_ROOT = "/storage/roms/bios"
|
|
CONFIG_ROOT = "/storage/.config"
|
|
# The first-boot step copying every package's /usr/config to /storage/.config.
|
|
USERCONFIG = f"{ROCKNIX}/packages/sysutils/systemd/scripts/userconfig-setup"
|
|
|
|
Lines = list[tuple[str, set[int]]]
|
|
|
|
|
|
@dataclass
|
|
class DeviceConfig:
|
|
"""The config file one or more devices install, and the lines that pick it."""
|
|
|
|
path: str
|
|
devices: list[str]
|
|
mk_lines: set[int]
|
|
|
|
|
|
def device_configs(
|
|
tree: RepoTree, package: str, file: str, folder: str = "config"
|
|
) -> list[DeviceConfig]:
|
|
"""Each distinct file package.mk installs as `file` for some device.
|
|
|
|
The directory comes from `${PKG_DIR}/<folder>/${DEVICE}/` or from a
|
|
`case ${DEVICE}` naming it. A device whose directory is absent does not
|
|
get the package. A directory that is a symlink resolves to its target.
|
|
"""
|
|
mk_path = f"{package}/package.mk"
|
|
mk = Script(mk_path, tree.read(mk_path))
|
|
arms: list[tuple[list[str], str]] = []
|
|
mk_lines: set[int] = set()
|
|
for block in re.finditer(r'case\s+"?\$\{?DEVICE\}?"?\s+in(.*?)\besac\b', mk.text, re.S):
|
|
body = block.group(1)
|
|
if not re.search(rf"CONFIG=|\$\{{PKG_DIR\}}/{folder}/", body):
|
|
continue
|
|
offset = block.start(1)
|
|
for arm in re.finditer(r"^\s*([\w*|]+)\)(.*?);;", body, re.M | re.S):
|
|
chosen = re.search(
|
|
rf'CONFIG="([^"]+)"|\$\{{PKG_DIR\}}/{folder}/([^/\s]+)', arm.group(2)
|
|
)
|
|
if chosen:
|
|
arms.append((arm.group(1).split("|"), chosen.group(1) or chosen.group(2)))
|
|
mk_lines.add(mk.text.count("\n", 0, offset + arm.start(2)) + 1)
|
|
break
|
|
if not arms:
|
|
number, _ = mk.find(
|
|
rf"\$\{{PKG_DIR\}}/{folder}/\$\{{DEVICE\}}/", f"{folder}/${{DEVICE}} copy"
|
|
)
|
|
arms, mk_lines = [(["*"], "${DEVICE}")], {number}
|
|
root = f"{package}/{folder}"
|
|
entries = tree.listdir(root)
|
|
by_path: dict[str, DeviceConfig] = {}
|
|
for device, entry in tree.listdir(DEVICES).items():
|
|
if entry.kind != "dir":
|
|
continue
|
|
arm = next((d for patterns, d in arms if device in patterns or "*" in patterns), None)
|
|
if arm is None:
|
|
continue
|
|
name = device if arm in ("${DEVICE}", "${CONFIG}") else arm
|
|
found = entries.get(name)
|
|
if found is None:
|
|
continue
|
|
if found.kind == "link":
|
|
name = found.target.rstrip("/")
|
|
path = f"{root}/{name}/{file}"
|
|
by_path.setdefault(path, DeviceConfig(path, [], mk_lines)).devices.append(device)
|
|
if not by_path:
|
|
raise LauncherPatternMissing(root, f"{file} for any device")
|
|
return sorted(by_path.values(), key=lambda c: c.path)
|
|
|
|
|
|
def per_device(
|
|
tree: RepoTree,
|
|
configs: list[DeviceConfig],
|
|
mk_path: str,
|
|
read: Callable[[str, str], tuple[object, set[int]]],
|
|
) -> list[tuple[str | None, object, Lines]]:
|
|
"""Configs grouped by what read() finds in them.
|
|
|
|
read(path, text) returns a comparable value and the lines it comes from.
|
|
One group gives one result without a device. Otherwise each group names
|
|
its devices, in the order of the devices directory.
|
|
"""
|
|
groups: dict[object, tuple[list[str], Lines]] = {}
|
|
for config in configs:
|
|
value, lines = read(config.path, tree.read(config.path))
|
|
devices, refs = groups.setdefault(value, ([], []))
|
|
devices.extend(config.devices)
|
|
refs.append((config.path, lines))
|
|
mk_lines = set().union(*(c.mk_lines for c in configs))
|
|
if len(groups) == 1:
|
|
((value, (_, refs)),) = groups.items()
|
|
return [(None, value, [*refs, (mk_path, mk_lines)])]
|
|
order = list(tree.listdir(DEVICES))
|
|
return [
|
|
(", ".join(sorted(devices, key=order.index)), value, [*refs, (mk_path, mk_lines)])
|
|
for value, (devices, refs) in sorted(
|
|
groups.items(), key=lambda g: min(order.index(d) for d in g[1][0])
|
|
)
|
|
]
|
|
|
|
|
|
def _file_under(value: str, root: str) -> bool:
|
|
return value.startswith(root.rstrip("/") + "/")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FixedPaths:
|
|
"""Keys of a config that name a file under one directory."""
|
|
|
|
keys: tuple[tuple[str, str], ...]
|
|
names: tuple[str, ...]
|
|
search_dir: str
|
|
|
|
|
|
def fixed_paths(
|
|
found: list[Setting], keys: list[tuple[str, str]], path: str
|
|
) -> tuple[FixedPaths, set[int]]:
|
|
"""The values of keys, as names relative to the directory they share."""
|
|
by_key = {(s.section, s.key): s for s in found}
|
|
missing = [k for k in keys if k not in by_key]
|
|
if missing:
|
|
raise LauncherPatternMissing(path, f"key {missing[0][1]}")
|
|
values = [by_key[k].value for k in keys if by_key[k].value]
|
|
lines = {by_key[k].line for k in keys}
|
|
if not values:
|
|
return FixedPaths(tuple(keys), (), ""), lines
|
|
folder = posixpath.commonpath([posixpath.dirname(v) for v in values])
|
|
names = tuple(posixpath.relpath(v, folder) for v in values)
|
|
return FixedPaths(tuple(keys), names, folder), lines
|
|
|
|
|
|
def bios_keys(tree: RepoTree, paths: list[str], root: str = BIOS_ROOT) -> list[tuple[str, str]]:
|
|
"""Keys whose value is a file under root in at least one of the configs."""
|
|
keys: list[tuple[str, str]] = []
|
|
for path in paths:
|
|
for setting in settings(tree.read(path)):
|
|
key = (setting.section, setting.key)
|
|
if _file_under(setting.value, root) and key not in keys:
|
|
keys.append(key)
|
|
if not keys:
|
|
raise LauncherPatternMissing(paths[0], f"key naming a file under {root}")
|
|
return keys
|
|
|
|
|
|
def with_evidence(
|
|
tree: RepoTree,
|
|
record: Launcher,
|
|
target: str,
|
|
keys: list[str],
|
|
start: Script | None = None,
|
|
writers: tuple[str, ...] = (),
|
|
source_name: str = "",
|
|
) -> Launcher:
|
|
"""A config record with the steps that put its file at ``target``.
|
|
|
|
The first boot copies /usr/config to /storage/.config, leaving a file
|
|
already there. A start script can copy or generate it again, and its
|
|
sed lines rewriting one of the record's keys are the overrides.
|
|
"""
|
|
setup = Script(USERCONFIG, tree.read(USERCONFIG))
|
|
placements = [config_placement(setup, target, required=False, sources=("/usr/config/",))]
|
|
if start is not None:
|
|
placements.append(config_placement(
|
|
start, target, keys, writers, source_name, required=False
|
|
))
|
|
fields = evidence_fields(placements)
|
|
record.installs = fields["installs"]
|
|
record.kept_if_present = fields["kept_if_present"]
|
|
record.overrides = fields["overrides"]
|
|
return record
|
|
|
|
|
|
def _keys(value: FixedPaths) -> list[str]:
|
|
return [f"[{section}] {key}" if section else key for section, key in value.keys]
|
|
|
|
|
|
def config_records(
|
|
tree: RepoTree,
|
|
emulator: str,
|
|
package: str,
|
|
file: str,
|
|
passes_to_suffix: str = "",
|
|
depends_on_option: str | None = None,
|
|
target: str = "",
|
|
start: Script | None = None,
|
|
unread: Callable[[tuple[str, str]], bool] = lambda key: False,
|
|
) -> list[Launcher]:
|
|
"""fixed_name records for the bios paths of a config installed per device.
|
|
|
|
``unread`` drops the keys the shipped configs leave the emulator
|
|
reading no file from.
|
|
"""
|
|
configs = device_configs(tree, package, file)
|
|
keys = [k for k in bios_keys(tree, [c.path for c in configs]) if not unread(k)]
|
|
name = posixpath.basename(file)
|
|
|
|
def read(path: str, text: str) -> tuple[object, set[int]]:
|
|
return fixed_paths(settings(text), keys, path)
|
|
|
|
records = []
|
|
for device, value, refs in per_device(tree, configs, f"{package}/package.mk", read):
|
|
assert isinstance(value, FixedPaths)
|
|
described = describe_keys(name, value.keys)
|
|
records.append(with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator=emulator,
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=list(value.names),
|
|
source_ref=source_ref(refs),
|
|
search_dir=value.search_dir or None,
|
|
passes_to=(
|
|
(described + passes_to_suffix) if value.names else f"{described} left empty"
|
|
),
|
|
device=device,
|
|
depends_on_option=depends_on_option,
|
|
),
|
|
target or f"{CONFIG_ROOT}/{posixpath.basename(package)}/{name}",
|
|
_keys(value),
|
|
start,
|
|
))
|
|
return records
|
|
|
|
|
|
def retroarch(tree: RepoTree) -> list[Launcher]:
|
|
"""system_directory of the retroarch.cfg each device installs."""
|
|
configs = device_configs(tree, RETROARCH, "retroarch.cfg", folder="sources")
|
|
wanted = ("system_directory", "systemfiles_in_content_dir")
|
|
|
|
def read(path: str, text: str) -> tuple[object, set[int]]:
|
|
found = {s.key: s for s in settings(text) if s.key in wanted}
|
|
if "system_directory" not in found:
|
|
raise LauncherPatternMissing(path, "system_directory")
|
|
return tuple((k, found[k].value) for k in wanted if k in found), {
|
|
s.line for s in found.values()
|
|
}
|
|
|
|
records = []
|
|
for device, value, refs in per_device(tree, configs, f"{RETROARCH}/package.mk", read):
|
|
assert isinstance(value, tuple)
|
|
values = dict(value)
|
|
records.append(with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator="retroarch",
|
|
build="libretro",
|
|
kind="passes_path",
|
|
names=[],
|
|
source_ref=source_ref(refs),
|
|
search_dir=values["system_directory"],
|
|
passes_to="retroarch.cfg " + ", ".join(f'{k} = "{v}"' for k, v in value),
|
|
device=device,
|
|
),
|
|
f"{CONFIG_ROOT}/retroarch/retroarch.cfg",
|
|
[k for k, _ in value],
|
|
))
|
|
return records
|
|
|
|
|
|
def amiberry(tree: RepoTree) -> list[Launcher]:
|
|
"""The A500 or A1200 config, chosen by the game name, then the kickstarts link."""
|
|
package = f"{STANDALONE}/amiberry"
|
|
script = Script(
|
|
f"{package}/scripts/start_amiberry.sh", tree.read(f"{package}/scripts/start_amiberry.sh")
|
|
)
|
|
test_line, test = script.find(r"grep -E '([^']+)'", "machine choice by game name")
|
|
then_line, then = script.find(
|
|
r"cp \S+/(\w+-default\.uae)", "config copied for a match", test_line
|
|
)
|
|
else_line, _ = script.find(r"^\s*else\b", "else of the machine choice", then_line)
|
|
other_line, other = script.find(
|
|
r"cp \S+/(\w+-default\.uae)", "config copied otherwise", else_line
|
|
)
|
|
sed_line, sed = script.find(
|
|
r'sed -i "s#(@\w+@)#\$\{(\w+)\}#g"', "placeholder substitution", other_line
|
|
)
|
|
used: set[int] = {test_line, then_line, else_line, other_line, sed_line}
|
|
root = script.expand("${" + sed.group(2) + "}", sed_line, used)
|
|
written = re.search(r'"?(\$\{?\w+\}?)"?\s*$', script.lines[then_line - 1])
|
|
if written is None:
|
|
raise script.missing("destination of the config copy")
|
|
target = script.expand(written.group(1), then_line, set())
|
|
# A WHDLoad archive is booted by --autoload, which builds its own setup
|
|
# (kickstart included) over the config: the record holds for floppies.
|
|
autoload = [n for n, line in script.code() if re.search(r"amiberry .*--autoload", line)]
|
|
if not autoload:
|
|
raise script.missing("WHDLoad boot with --autoload")
|
|
content = (
|
|
"content not booted with --autoload, a WHDLoad .lha or .zip "
|
|
f"({posixpath.basename(script.path)}:{', '.join(map(str, autoload))})"
|
|
)
|
|
records = []
|
|
for config, condition in (
|
|
(then.group(1), f"game name matches {test.group(1)}"),
|
|
(other.group(1), f"game name does not match {test.group(1)}"),
|
|
):
|
|
path = f"{package}/config/conf/{config}"
|
|
found = [
|
|
Setting(s.section, s.key, s.value.replace(sed.group(1), root), s.line)
|
|
for s in settings(tree.read(path))
|
|
]
|
|
keys = [(s.section, s.key) for s in found if _file_under(s.value, root)]
|
|
if not keys:
|
|
raise LauncherPatternMissing(path, f"key naming a file under {sed.group(1)}")
|
|
value, lines = fixed_paths(found, keys, path)
|
|
slot = re.fullmatch(r"Amiga(\w+)-default\.uae", config)
|
|
records.append(with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator="amiberry",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=list(value.names),
|
|
source_ref=source_ref([(script.path, used), (path, lines)]),
|
|
slot=slot.group(1) if slot else config,
|
|
search_dir=value.search_dir,
|
|
passes_to=f"{describe_keys(config, value.keys)} (amiberry -f)",
|
|
depends_on_option=f"{condition}, {content}",
|
|
),
|
|
target,
|
|
_keys(value),
|
|
script,
|
|
source_name=config,
|
|
))
|
|
mk = Script(f"{package}/package.mk", tree.read(f"{package}/package.mk"))
|
|
links = mk.links(BIOS_ROOT, fixed={"INSTALL": ""})
|
|
if not links:
|
|
raise mk.missing(f"ln -s of {BIOS_ROOT}")
|
|
records.extend(link_record("amiberry", mk, link) for link in links)
|
|
# The script copies the installed folder, links included, to the one it
|
|
# enters before it runs amiberry: the kickstarts link is read there.
|
|
copy_line, copy = script.find(r"cp -rf? (\S+) \$\{?(\w+)\}?\s*$", "copy of the installed folder")
|
|
cd_line, cd = script.find(r"^\s*cd \$\{?(\w+)\}?\s*$", "working directory")
|
|
if cd.group(1) != copy.group(2):
|
|
raise LauncherPatternMissing(script.path, "cd into the copied folder")
|
|
entered: set[int] = {cd_line}
|
|
folder = script.expand("${" + cd.group(1) + "}", cd_line, entered)
|
|
records.append(Launcher(
|
|
emulator="amiberry", build="standalone", kind="passes_path", names=[],
|
|
source_ref=source_ref([(script.path, entered)]),
|
|
search_dir=folder, passes_to=f"working directory of {script.path.rsplit('/', 1)[-1]}",
|
|
))
|
|
installed = copy.group(1).rstrip("/")
|
|
for link in links:
|
|
if not link.link.startswith(installed + "/"):
|
|
continue
|
|
copied = folder + link.link[len(installed):]
|
|
records.append(Launcher(
|
|
emulator="amiberry", build="standalone", kind="links", names=list(link.names),
|
|
source_ref=source_ref([(mk.path, link.lines), (script.path, {copy_line} | entered)]),
|
|
search_dir=link.bios_side,
|
|
passes_to=f"symlink {copied} -> {link.target}, copied with {installed}",
|
|
))
|
|
return records
|
|
|
|
|
|
def link_record(
|
|
emulator: str,
|
|
script: Script,
|
|
link: Link,
|
|
case_insensitive: bool | None = None,
|
|
fails_when_missing: bool | str = False,
|
|
) -> Launcher:
|
|
return Launcher(
|
|
emulator=emulator,
|
|
build="standalone",
|
|
kind="links",
|
|
names=list(link.names),
|
|
source_ref=source_ref([(script.path, link.lines)]),
|
|
expands=link.expands or None,
|
|
search_dir=link.bios_side,
|
|
passes_to=f"symlink {link.link} -> {link.target}",
|
|
case_insensitive=case_insensitive,
|
|
fails_when_missing=fails_when_missing,
|
|
)
|
|
|
|
|
|
def hatari(tree: RepoTree) -> list[Launcher]:
|
|
package = f"{STANDALONE}/hatarisa"
|
|
# The script is named after the package's binary: start_hatari.sh became
|
|
# start_hatarisa.sh (2e7041d273), so it is found, not spelled.
|
|
starts = sorted(
|
|
name for name in tree.listdir(f"{package}/scripts")
|
|
if name.startswith("start_") and name.endswith(".sh")
|
|
)
|
|
if len(starts) != 1:
|
|
raise LauncherPatternMissing(f"{package}/scripts", "one start script")
|
|
script = Script(f"{package}/scripts/{starts[0]}", tree.read(f"{package}/scripts/{starts[0]}"))
|
|
copy_line, copy = script.find(r"cp \S+/([\w-]+\.cfg) ", "default config copy")
|
|
run_line, _ = script.find(r"--configfile", "--configfile", copy_line)
|
|
path = f"{package}/config/conf/{copy.group(1)}"
|
|
found = settings(tree.read(path))
|
|
keys = [(s.section, s.key) for s in found if _file_under(s.value, BIOS_ROOT)]
|
|
if not keys:
|
|
raise LauncherPatternMissing(path, f"key naming a file under {BIOS_ROOT}")
|
|
value, lines = fixed_paths(found, keys, path)
|
|
copied = re.search(r'"?(\$\{?\w+\}?)"?\s*$', script.lines[copy_line - 1])
|
|
if copied is None:
|
|
raise script.missing("destination of the default config copy")
|
|
return [with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator="hatari",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=list(value.names),
|
|
source_ref=source_ref([(path, lines), (script.path, {copy_line, run_line})]),
|
|
search_dir=value.search_dir,
|
|
passes_to=f"{describe_keys(copy.group(1), value.keys)} (--configfile)",
|
|
),
|
|
script.expand(copied.group(1), copy_line, set()),
|
|
_keys(value),
|
|
script,
|
|
source_name=copy.group(1),
|
|
)]
|
|
|
|
|
|
def melonds(tree: RepoTree) -> list[Launcher]:
|
|
package = f"{STANDALONE}/melonds-sa"
|
|
script = Script(
|
|
f"{package}/scripts/start_melonds.sh", tree.read(f"{package}/scripts/start_melonds.sh")
|
|
)
|
|
enable_line, _ = script.find(r"ExternalBIOSEnable=1", "ExternalBIOSEnable=1")
|
|
tests = [
|
|
(number, found)
|
|
for number, line in script.code()
|
|
if number < enable_line and (found := re.search(r'if \[ "\$(\w+)" = "(\w+)" \]', line))
|
|
]
|
|
if not tests:
|
|
raise script.missing("test that enables the external BIOS")
|
|
test_line, test = tests[-1]
|
|
setting_line, setting = script.find(
|
|
rf"^\s*{test.group(1)}=\$\(get_setting (\w+) ", f"setting read into {test.group(1)}"
|
|
)
|
|
condition = f"{setting.group(1)} = {test.group(2)} sets ExternalBIOSEnable=1, otherwise 0"
|
|
configs = [c.path for c in device_configs(tree, package, "melonDS.ini")]
|
|
# melonDS opens the DSi BIOS and firmware keys only under
|
|
# DSiExternalBIOSEnable, off unless a config sets it.
|
|
dsi_external = any(
|
|
s.key == "DSiExternalBIOSEnable" and s.value not in ("", "0")
|
|
for path in configs for s in settings(tree.read(path))
|
|
)
|
|
|
|
def unread(key: tuple[str, str]) -> bool:
|
|
return not dsi_external and key[1] in ("DSiBIOS9Path", "DSiBIOS7Path", "DSiFirmwarePath")
|
|
|
|
conf_line, conf = script.find(r'^\s*CONF_DIR="([^"]+)"', "CONF_DIR")
|
|
records = config_records(
|
|
tree, "melonds", package, "melonDS.ini", depends_on_option=condition,
|
|
target=f"{conf.group(1)}/melonDS.ini", start=script, unread=unread,
|
|
)
|
|
for record in records:
|
|
record.source_ref += (
|
|
f", {source_ref([(script.path, {setting_line, test_line, enable_line})])}"
|
|
)
|
|
return records
|
|
|
|
|
|
def dsperate(tree: RepoTree) -> list[Launcher]:
|
|
package = f"{STANDALONE}/dsperate-sa"
|
|
script = Script(
|
|
f"{package}/scripts/start_dsperate.sh", tree.read(f"{package}/scripts/start_dsperate.sh")
|
|
)
|
|
run_line, _ = script.find(r"/usr/bin/dsperate --config ", "dsperate --config")
|
|
records = config_records(
|
|
tree, "dsperate", package, "dsperate.ini", " (--config)",
|
|
target=script.expand("${DSPERATE_INI}", run_line, set()), start=script,
|
|
)
|
|
for record in records:
|
|
record.source_ref += f", {source_ref([(script.path, {run_line})])}"
|
|
return records
|
|
|
|
|
|
def xemu(tree: RepoTree) -> list[Launcher]:
|
|
package = f"{STANDALONE}/xemu-sa"
|
|
script = Script(
|
|
f"{package}/scripts/start_xemu.sh", tree.read(f"{package}/scripts/start_xemu.sh")
|
|
)
|
|
copy_line, _ = script.find(
|
|
r'cp -r "\$\{SOURCE_DIR\}/\$\{XEMU_INI\}"', "xemu.toml copy at launch"
|
|
)
|
|
records = config_records(
|
|
tree, "xemu", package, "xemu.toml", " (copied at each launch)",
|
|
target=script.expand("${CONF_DIR}/${XEMU_INI}", copy_line, set()), start=script,
|
|
)
|
|
for record in records:
|
|
record.source_ref += f", {source_ref([(script.path, {copy_line})])}"
|
|
return records
|
|
|
|
|
|
def nanoboyadvance(tree: RepoTree) -> list[Launcher]:
|
|
package = f"{STANDALONE}/nanoboyadvance-sa"
|
|
path = f"{package}/config/config.toml"
|
|
found = settings(tree.read(path))
|
|
keys = [(s.section, s.key) for s in found if _file_under(s.value, BIOS_ROOT)]
|
|
if not keys:
|
|
raise LauncherPatternMissing(path, f"key naming a file under {BIOS_ROOT}")
|
|
value, lines = fixed_paths(found, keys, path)
|
|
mk_path = f"{package}/package.mk"
|
|
mk_line, _ = Script(mk_path, tree.read(mk_path)).find(
|
|
r"config/config\.toml", "config.toml install"
|
|
)
|
|
start_path = f"{package}/scripts/start_nanoboyadvance.sh"
|
|
return [with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator="nanoboyadvance",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=list(value.names),
|
|
source_ref=source_ref([(path, lines), (mk_path, {mk_line})]),
|
|
search_dir=value.search_dir,
|
|
passes_to=describe_keys("config.toml", value.keys),
|
|
# NanoBoyAdvance exits when its BIOS cannot be read
|
|
# (src/platform/sdl/main.cpp:180-185 at the build ROCKNIX ships).
|
|
fails_when_missing=True,
|
|
),
|
|
f"{CONFIG_ROOT}/nanoboyadvance/config.toml",
|
|
_keys(value),
|
|
Script(start_path, tree.read(start_path)),
|
|
)]
|
|
|
|
|
|
def mednafen(tree: RepoTree) -> list[Launcher]:
|
|
"""The firmware keys of mednafen.template, one record per module."""
|
|
package = f"{STANDALONE}/mednafen"
|
|
path = f"{package}/config/common/mednafen.template"
|
|
found = settings(tree.read(path), separator=" ")
|
|
folder = next((s for s in found if s.key == "filesys.path_firmware"), None)
|
|
if folder is None:
|
|
raise LauncherPatternMissing(path, "filesys.path_firmware")
|
|
pattern = re.compile(r"(\w+)\.(\w*bios\w*|ggrom|cart\.\w+_path)")
|
|
firmware = [s for s in found if pattern.fullmatch(s.key) and s.value and not s.value.isdigit()]
|
|
if not firmware:
|
|
raise LauncherPatternMissing(path, "firmware keys")
|
|
script_path = f"{package}/scripts/start_mednafen.sh"
|
|
script = Script(script_path, tree.read(script_path))
|
|
gen_line, _ = script.find(r"mednafen_gen_config\.sh", "config generation")
|
|
test_line, _ = script.find(
|
|
r'if \[ ! -f "\$MEDNAFEN_HOME/mednafen\.cfg" \]', "mednafen.cfg test"
|
|
)
|
|
modules: dict[str, list[Setting]] = {}
|
|
for setting in firmware:
|
|
modules.setdefault(setting.key.split(".", 1)[0], []).append(setting)
|
|
home = script.expand("${MEDNAFEN_HOME}", gen_line, set())
|
|
return [with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator="mednafen",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=list(dict.fromkeys(s.value for s in group)),
|
|
source_ref=source_ref(
|
|
[
|
|
(path, {folder.line, *(s.line for s in group)}),
|
|
(script_path, {test_line, gen_line}),
|
|
]
|
|
),
|
|
slot=module,
|
|
search_dir=folder.value,
|
|
passes_to="mednafen.cfg " + ", ".join(s.key for s in group),
|
|
),
|
|
f"{home}/mednafen.cfg",
|
|
[s.key for s in group],
|
|
script,
|
|
writers=("mednafen_gen_config.sh",),
|
|
) for module, group in modules.items()]
|
|
|
|
|
|
def yabasanshiro(tree: RepoTree) -> list[Launcher]:
|
|
package = f"{STANDALONE}/yabasanshiro-sa"
|
|
script = Script(
|
|
f"{package}/scripts/start_yabasanshiro.sh",
|
|
tree.read(f"{package}/scripts/start_yabasanshiro.sh"),
|
|
)
|
|
option_line, option = script.find(r"=\$\(get_setting (\w+) ", "BIOS option")
|
|
test_line, test = script.find(r'if \[ "\$\w+" = (\w+) \]', "BIOS option test", option_line)
|
|
loop = next((lp for lp in script.loops() if lp.start > test_line), None)
|
|
if loop is None:
|
|
raise script.missing("loop over BIOS names")
|
|
find_line, find = script.find(
|
|
rf"\$\(find (\S+) -name \$\{{{loop.var}\}}", "find of each name", loop.start, loop.end
|
|
)
|
|
flag_line, flag = script.find(
|
|
r'="(-\w) \$\{\w+\}"', "flag the found path is passed with", find_line, loop.end
|
|
)
|
|
break_line, _ = script.find(r"^\s*break\b", "break on the first found", flag_line, loop.end)
|
|
return [
|
|
Launcher(
|
|
emulator="yabasanshiro",
|
|
build="standalone",
|
|
kind="names_searched",
|
|
names=loop.items,
|
|
source_ref=source_ref(
|
|
[(script.path, {option_line, test_line, *range(loop.start, loop.end + 1)})]
|
|
),
|
|
case_insensitive=False,
|
|
search_dir=f"{find.group(1)} (recursive)",
|
|
passes_to=f"yabasanshiro {flag.group(1)} <first path found>",
|
|
fails_when_missing=False,
|
|
depends_on_option=f"{option.group(1)} = {test.group(1)} (game, then platform)",
|
|
)
|
|
]
|
|
|
|
|
|
def directory_records(
|
|
tree: RepoTree,
|
|
emulator: str,
|
|
package: str,
|
|
file: str,
|
|
folder_key: tuple[str, str],
|
|
file_keys: tuple[tuple[str, str], ...],
|
|
target: str = "",
|
|
start: str = "",
|
|
) -> list[Launcher]:
|
|
"""scans_dir records: the config hands a directory and leaves the file keys empty."""
|
|
configs = device_configs(tree, package, file)
|
|
name = posixpath.basename(file)
|
|
|
|
def read(path: str, text: str) -> tuple[object, set[int]]:
|
|
found = {(s.section, s.key): s for s in settings(text)}
|
|
if folder_key not in found:
|
|
raise LauncherPatternMissing(path, f"[{folder_key[0]}] {folder_key[1]}")
|
|
set_files = [k for k in file_keys if k in found and found[k].value]
|
|
if set_files:
|
|
raise LauncherPatternMissing(path, f"empty [{set_files[0][0]}] {set_files[0][1]}")
|
|
lines = {found[k].line for k in (folder_key, *file_keys) if k in found}
|
|
return found[folder_key].value, lines
|
|
|
|
unset = describe_keys("", file_keys).strip()
|
|
records = []
|
|
for device, folder, refs in per_device(tree, configs, f"{package}/package.mk", read):
|
|
passes = f"{name} [{folder_key[0]}] {folder_key[1]} = {folder}, {unset} unset"
|
|
records.append(with_evidence(
|
|
tree,
|
|
Launcher(
|
|
emulator=emulator,
|
|
build="standalone",
|
|
kind="scans_dir",
|
|
names=[],
|
|
source_ref=source_ref(refs),
|
|
search_dir=folder,
|
|
passes_to=passes,
|
|
device=device,
|
|
),
|
|
target,
|
|
[f"[{s}] {k}" for s, k in (folder_key, *file_keys)],
|
|
Script(start, tree.read(start)) if start else None,
|
|
))
|
|
return records
|
|
|
|
|
|
def duckstation(tree: RepoTree) -> list[Launcher]:
|
|
return directory_records(
|
|
tree,
|
|
"duckstation",
|
|
f"{STANDALONE}/duckstation-sa",
|
|
"settings.ini",
|
|
("BIOS", "SearchDirectory"),
|
|
(("BIOS", "PathNTSCU"), ("BIOS", "PathNTSCJ"), ("BIOS", "PathPAL")),
|
|
f"{CONFIG_ROOT}/duckstation/settings.ini",
|
|
f"{STANDALONE}/duckstation-sa/scripts/start_duckstation.sh",
|
|
)
|
|
|
|
|
|
def armsx2(tree: RepoTree) -> list[Launcher]:
|
|
return directory_records(
|
|
tree,
|
|
"armsx2",
|
|
f"{STANDALONE}/armsx2-sa",
|
|
"ARMSX2/inis/PCSX2.ini",
|
|
("Folders", "Bios"),
|
|
(("Filenames", "BIOS"),),
|
|
f"{CONFIG_ROOT}/ARMSX2/inis/PCSX2.ini",
|
|
f"{STANDALONE}/armsx2-sa/scripts/start_armsx2.sh",
|
|
)
|
|
|
|
|
|
# Start scripts whose ln -s commands put a BIOS path where the emulator reads.
|
|
def pico8(tree: RepoTree) -> list[Launcher]:
|
|
"""The PICO-8 binary start_pico8.sh executes, outside /storage/roms/bios.
|
|
|
|
The script picks the binary by HW_ARCH, pico8_64 on aarch64 and
|
|
pico8_dyn on any other, and runs it from GAME_DIR, or from
|
|
GAME_DIR/${HW_ARCH} when that folder exists.
|
|
"""
|
|
path = f"{STANDALONE}/pico-8/scripts/start_pico8.sh"
|
|
script = Script(path, tree.read(path))
|
|
game_line, game = script.find(r'^\s*GAME_DIR="([^"$]+)"', "GAME_DIR assignment")
|
|
case_line, _ = script.find(r"^\s*case \$\{HW_ARCH\} in", "case on HW_ARCH")
|
|
arch_line, arch = script.find(r"^\s*(\w+)\)", "HW_ARCH branch", case_line + 1)
|
|
first_line, first = script.find(r'STATIC_BIN="([^"$]+)"', "binary of the first branch", arch_line)
|
|
other_line, _ = script.find(r"^\s*\*\)", "default HW_ARCH branch", first_line)
|
|
rest_line, rest = script.find(r'STATIC_BIN="([^"$]+)"', "binary of the default branch", other_line)
|
|
sub_line, _ = script.find(
|
|
r'if \[ -d "\$\{GAME_DIR\}/\$\{HW_ARCH\}" \]', "per-architecture launch folder"
|
|
)
|
|
end_line, _ = script.find(r"^\s*fi\b", "end of the launch folder test", sub_line)
|
|
run_line, _ = script.find(r"^\s*\$\{LAUNCH_DIR\}/\$\{STATIC_BIN\} ", "binary run")
|
|
return [
|
|
Launcher(
|
|
emulator="pico-8",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=[first.group(1), rest.group(1)],
|
|
source_ref=source_ref([(path, {
|
|
game_line, case_line, arch_line, first_line, other_line, rest_line,
|
|
*range(sub_line, end_line + 1), run_line,
|
|
})]),
|
|
search_dir=game.group(1).rstrip("/"),
|
|
passes_to="exec",
|
|
fails_when_missing=False,
|
|
depends_on_option=(
|
|
f"HW_ARCH {arch.group(1)} runs {first.group(1)}, any other {rest.group(1)}, "
|
|
f"from {game.group(1).rstrip('/')}/${{HW_ARCH}} when that folder exists"
|
|
),
|
|
)
|
|
]
|
|
|
|
|
|
LINK_SCRIPTS = (
|
|
("drastic", f"{STANDALONE}/drastic-sa/scripts/start_drastic.sh"),
|
|
("dolphin", f"{STANDALONE}/dolphin-sa/scripts/start_dolphin_gc.sh"),
|
|
("flycast", f"{STANDALONE}/flycast-sa/scripts/start_flycast.sh"),
|
|
("cemu", f"{STANDALONE}/cemu-sa/scripts/start_cemu.sh"),
|
|
("rpcs3", f"{STANDALONE}/rpcs3-sa/scripts/start_rpcs3.sh"),
|
|
)
|
|
|
|
|
|
def links(tree: RepoTree) -> list[Launcher]:
|
|
records = []
|
|
for emulator, path in LINK_SCRIPTS:
|
|
script = Script(path, tree.read(path))
|
|
found = script.links(BIOS_ROOT)
|
|
if not found:
|
|
raise script.missing(f"ln -s of a path under {BIOS_ROOT}")
|
|
for link in found:
|
|
guard = _existence_guard(script, link)
|
|
if guard is not None:
|
|
link.lines.add(guard)
|
|
records.append(
|
|
link_record(
|
|
emulator,
|
|
script,
|
|
link,
|
|
case_insensitive=False if guard is not None else None,
|
|
fails_when_missing=False,
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
# Packages whose build patch replaces the function an emulator resolves a
|
|
# directory with by a fixed path: ROCKNIX's Flycast returns
|
|
# /storage/roms/dreamcast/data as its data directory, which start_flycast.sh
|
|
# then links to the BIOS tree.
|
|
DIRECTORY_PATCHES = (
|
|
("flycast", f"{STANDALONE}/flycast-sa/patches/000-conf-dir.patch", "find_user_data_dir"),
|
|
)
|
|
|
|
|
|
def patched_directories(tree: RepoTree) -> list[Launcher]:
|
|
records = []
|
|
for emulator, path, function in DIRECTORY_PATCHES:
|
|
lines = tree.read(path).splitlines()
|
|
target = next(
|
|
(line[len("+++ b/"):] for line in lines if line.startswith("+++ b/")), None
|
|
)
|
|
start = next(
|
|
(n for n, line in enumerate(lines, 1) if re.search(rf"\b{function}\(\)", line)),
|
|
None,
|
|
)
|
|
returned = None
|
|
if target is not None and start is not None:
|
|
returned = next(
|
|
(
|
|
(n, match.group(1))
|
|
for n, line in enumerate(lines[start:], start + 1)
|
|
if (match := re.match(r'^\+\s*return "(/[^"]+)";', line))
|
|
),
|
|
None,
|
|
)
|
|
if returned is None:
|
|
raise LauncherPatternMissing(path, f"{function}() patched to return a fixed path")
|
|
number, directory = returned
|
|
records.append(Launcher(
|
|
emulator=emulator,
|
|
build="standalone",
|
|
kind="passes_path",
|
|
names=[],
|
|
source_ref=source_ref([(path, {start, number})]),
|
|
search_dir=directory.rstrip("/"),
|
|
passes_to=f"{function}() in {target} returns it",
|
|
fails_when_missing=False,
|
|
))
|
|
return records
|
|
|
|
|
|
def _existence_guard(script: Script, link: Link) -> int | None:
|
|
"""The line of a -e test of the BIOS-side path the link waits for, if any."""
|
|
last = max(link.lines)
|
|
tests = [
|
|
number
|
|
for number, line in script.code()
|
|
if number < last
|
|
and re.search(
|
|
r"\[ -e \S*" + re.escape(link.bios_side) + "/", script.expand(line, number, set())
|
|
)
|
|
]
|
|
return tests[-1] if tests and last - tests[-1] <= 3 else None
|
|
|
|
|
|
EXTRACTORS: tuple[Callable[[RepoTree], list[Launcher]], ...] = (
|
|
retroarch,
|
|
amiberry,
|
|
hatari,
|
|
melonds,
|
|
dsperate,
|
|
nanoboyadvance,
|
|
xemu,
|
|
mednafen,
|
|
yabasanshiro,
|
|
duckstation,
|
|
armsx2,
|
|
pico8,
|
|
patched_directories,
|
|
links,
|
|
)
|
|
|
|
|
|
def extract_launchers(tree: RepoTree) -> list[dict]:
|
|
"""Every launcher record the extractors find, in extractor order."""
|
|
return [record.entry() for extractor in EXTRACTORS for record in extractor(tree)]
|