mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-21 03:09:08 -05:00
389 lines
14 KiB
Python
389 lines
14 KiB
Python
"""EmuDeck's launcher layer, read from its EmuScripts at the pinned commit.
|
|
|
|
Each functions/EmuScripts/emuDeck<Name>.sh sets an emulator up once, at
|
|
install or update: it writes BIOS paths into the emulator's config with
|
|
changeLine, iniFieldUpdate or RetroArch_setConfigOverride, rewrites the
|
|
paths of a config it ships with sed, or links a directory of bios/ to the
|
|
emulator's own. Nothing runs between that setup and a launch, so these are
|
|
the names and directories the emulator is left with.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import posixpath
|
|
import re
|
|
from collections.abc import Callable
|
|
|
|
try:
|
|
from .configgen_launchers import Launcher
|
|
from .shell_launchers import (
|
|
LauncherPatternMissing,
|
|
RepoTree,
|
|
Script,
|
|
describe_keys,
|
|
settings,
|
|
source_ref,
|
|
words,
|
|
)
|
|
except ImportError:
|
|
from configgen_launchers import Launcher
|
|
from shell_launchers import (
|
|
LauncherPatternMissing,
|
|
RepoTree,
|
|
Script,
|
|
describe_keys,
|
|
settings,
|
|
source_ref,
|
|
words,
|
|
)
|
|
|
|
EMUSCRIPTS = "functions/EmuScripts"
|
|
CONFIGS = "configs"
|
|
BIOS = "${biosPath}"
|
|
# EmuDeck's settings put the BIOS directory under the Emulation folder.
|
|
EMULATION_BIOS = "${emulationPath}/bios"
|
|
STORAGE = "${storagePath}"
|
|
FLATPAK_DATA = "${HOME}/.var/app/"
|
|
|
|
# EmuScripts file names that differ from the emulator profile they set up.
|
|
SCRIPT_EMULATORS = {
|
|
"pcsx2qt": "pcsx2",
|
|
"rpcs3legacy": "rpcs3",
|
|
"cemuproton": "cemu",
|
|
"rmg": "mupen64plus_next",
|
|
}
|
|
# The main RetroArch config script installs no emulator.
|
|
NOT_EMULATORS = {"retroarch_maincfg"}
|
|
|
|
# What the emulator does with a directory written into each key.
|
|
DIRECTORY_KINDS = {
|
|
"SearchDirectory": "scans_dir",
|
|
"Bios": "scans_dir",
|
|
"rompath": "scans_dir",
|
|
"system_directory": "passes_path",
|
|
}
|
|
|
|
|
|
def emulator_of_script(file_name: str) -> str | None:
|
|
"""The emulator an EmuScripts file sets up, None for a helper script."""
|
|
if not file_name.endswith(".sh"):
|
|
return None
|
|
key = re.sub(r"^emuDeck", "", file_name[: -len(".sh")], flags=re.IGNORECASE).lower()
|
|
if not key or key in NOT_EMULATORS:
|
|
return None
|
|
return SCRIPT_EMULATORS.get(key, key)
|
|
|
|
|
|
def _script(tree: RepoTree, name: str) -> Script:
|
|
path = f"{EMUSCRIPTS}/{name}"
|
|
return Script(path, tree.read(path))
|
|
|
|
|
|
def _emulator(name: str) -> str:
|
|
emulator = emulator_of_script(name)
|
|
if emulator is None:
|
|
raise LauncherPatternMissing(f"{EMUSCRIPTS}/{name}", "emulator script")
|
|
return emulator
|
|
|
|
|
|
def _is_bios(value: str) -> bool:
|
|
return any(value == root or value.startswith(root + "/") for root in (BIOS, EMULATION_BIOS))
|
|
|
|
|
|
def _repo_config(script: Script, path: str) -> str:
|
|
"""The repository copy of a Flatpak config EmuDeck installs."""
|
|
if not path.startswith(FLATPAK_DATA):
|
|
raise script.missing(f"repository copy of {path}")
|
|
return f"{CONFIGS}/{path[len(FLATPAK_DATA):]}"
|
|
|
|
|
|
def setters(script: Script) -> list[tuple[str, str, str, str, set[int]]]:
|
|
"""(config file, section, key, value, lines) for each path setter call.
|
|
|
|
Reads changeLine "$KEY" "$KEY$VALUE" "$FILE", iniFieldUpdate "$FILE"
|
|
"Section" "Key" "Value" and RetroArch_setConfigOverride 'key' "value" "$FILE".
|
|
"""
|
|
found = []
|
|
for number, line in script.code():
|
|
command = words(line)
|
|
if not command:
|
|
continue
|
|
used = {number}
|
|
args = [script.expand(w.text, number, used) for w in command[1:]]
|
|
name = command[0].raw
|
|
if name == "changeLine" and len(args) == 3:
|
|
key, value, file = args
|
|
if not value.startswith(key):
|
|
if BIOS in value:
|
|
raise script.missing(f"changeLine value starting with its key at line {number}")
|
|
continue
|
|
value = value[len(key) :].strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
|
|
value = value[1:-1]
|
|
found.append((file, "", key.strip().rstrip("=").strip(), value, used))
|
|
elif name == "iniFieldUpdate" and len(args) == 4:
|
|
file, section, key, value = args
|
|
found.append((file, section, key, value, used))
|
|
elif name == "RetroArch_setConfigOverride" and len(args) == 3:
|
|
key, value, file = args
|
|
found.append((file, "", key, value.strip('"'), used))
|
|
return found
|
|
|
|
|
|
def setter_records(tree: RepoTree, name: str, option_key: str | None = None) -> list[Launcher]:
|
|
"""Records for the paths a script's setters write under the BIOS or storage directory.
|
|
|
|
A file named under bios/ or storage/ is a fixed name, grouped by config
|
|
and directory. A bios directory is handed to the emulator, which picks.
|
|
"""
|
|
script = _script(tree, name)
|
|
emulator = _emulator(name)
|
|
records: list[Launcher] = []
|
|
groups: dict[tuple[str, str], tuple[list[tuple[str, str]], list[str], set[int]]] = {}
|
|
for file, section, key, value, lines in setters(script):
|
|
config = posixpath.basename(file)
|
|
paths = value.split(";")
|
|
if not any(_is_bios(p) for p in paths) and not value.startswith(STORAGE + "/"):
|
|
continue
|
|
is_file = len(paths) == 1 and re.search(r"\.[A-Za-z0-9]+$", posixpath.basename(value))
|
|
if is_file:
|
|
folder = posixpath.dirname(value)
|
|
keys, names, refs = groups.setdefault((config, folder), ([], [], set()))
|
|
keys.append((section, key))
|
|
names.append(posixpath.basename(value))
|
|
refs |= lines
|
|
continue
|
|
if not any(_is_bios(p) for p in paths):
|
|
continue
|
|
if key not in DIRECTORY_KINDS:
|
|
raise script.missing(f"known directory key, got {key}")
|
|
records.append(
|
|
Launcher(
|
|
emulator=emulator,
|
|
build="libretro" if emulator == "retroarch" else "standalone",
|
|
kind=DIRECTORY_KINDS[key],
|
|
names=[],
|
|
source_ref=source_ref([(script.path, lines)]),
|
|
search_dir=value,
|
|
passes_to=describe_keys(config, ((section, key),)),
|
|
)
|
|
)
|
|
for (config, folder), (keys, names, refs) in groups.items():
|
|
records.append(
|
|
Launcher(
|
|
emulator=emulator,
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=names,
|
|
source_ref=source_ref([(script.path, refs)]),
|
|
search_dir=folder,
|
|
passes_to=describe_keys(config, tuple(keys)),
|
|
)
|
|
)
|
|
if not records:
|
|
raise script.missing(f"setter writing a path under {BIOS}")
|
|
if option_key:
|
|
for record in records:
|
|
_add_option(tree, script, record, option_key)
|
|
return records
|
|
|
|
|
|
def _add_option(tree: RepoTree, script: Script, record: Launcher, option_key: str) -> None:
|
|
"""depends_on_option from the value the shipped config gives option_key."""
|
|
config = record.passes_to.split(" ", 1)[0]
|
|
file_var = script.assignment_ending("/" + config)
|
|
if file_var is None:
|
|
raise script.missing(f"config variable for {config}")
|
|
path = _repo_config(script, script.expand(file_var.value, file_var.line, set()))
|
|
option = next((s for s in settings(tree.read(path)) if s.key == option_key), None)
|
|
if option is None:
|
|
raise LauncherPatternMissing(path, option_key)
|
|
record.depends_on_option = f"{option_key} = {option.value} in the shipped {config}"
|
|
record.source_ref += f", {source_ref([(path, {option.line})])}"
|
|
|
|
|
|
def sed_rewrites(script: Script, function: str) -> list[tuple[str, str, str, set[int]]]:
|
|
"""(config path, old prefix, new prefix, lines) for sed -i "s|old|new|g" "$file" in a function."""
|
|
start, _ = script.find(rf"^\s*{re.escape(function)}\s*\(\)\s*\{{", f"function {function}")
|
|
end = next(
|
|
(n for n, line in script.code() if n > start and re.match(r"^\}", line)), len(script.lines)
|
|
)
|
|
found = []
|
|
for number, line in script.code():
|
|
if not start < number < end:
|
|
continue
|
|
command = words(line)
|
|
if len(command) == 4 and command[0].raw == "sed" and command[1].raw == "-i":
|
|
pattern = re.fullmatch(r"s\|([^|]*)\|([^|]*)\|g", command[2].text)
|
|
if pattern is None:
|
|
continue
|
|
used: set[int] = {number}
|
|
old, new, file = (
|
|
script.expand(text, number, used).replace("\\/", "/")
|
|
for text in (pattern.group(1), pattern.group(2), command[3].text)
|
|
)
|
|
found.append((file, old, new, used))
|
|
return found
|
|
|
|
|
|
def _rewritten(value: str, old: str, new: str) -> str | None:
|
|
if not value.startswith(old):
|
|
return None
|
|
rest = value[len(old) :]
|
|
return new.rstrip("/") + "/" + rest.lstrip("/")
|
|
|
|
|
|
def ares(tree: RepoTree) -> list[Launcher]:
|
|
"""settings.bml Firmware keys, their /home/deck/Emulation/bios/ prefix rewritten."""
|
|
name = "emuDeckares.sh"
|
|
script = _script(tree, name)
|
|
rewrites = [
|
|
r for r in sed_rewrites(script, "ares_setEmulationFolder") if _is_bios(r[2].rstrip("/"))
|
|
]
|
|
if not rewrites:
|
|
raise script.missing("sed rewrite of the BIOS path")
|
|
file, old, new, script_lines = rewrites[0]
|
|
path = _repo_config(script, file)
|
|
records = []
|
|
system, in_firmware = "", False
|
|
entries: dict[str, list[tuple[str, str, int]]] = {}
|
|
for number, line in enumerate(tree.read(path).splitlines(), 1):
|
|
indent = len(line) - len(line.lstrip(" "))
|
|
text = line.strip()
|
|
if indent == 0 and text:
|
|
system, in_firmware = text, False
|
|
elif indent == 2:
|
|
in_firmware = text == "Firmware"
|
|
elif indent == 4 and in_firmware and ":" in text:
|
|
key, value = (part.strip() for part in text.split(":", 1))
|
|
target = _rewritten(value, old, new)
|
|
if target is not None:
|
|
entries.setdefault(system, []).append((key, target, number))
|
|
if not entries:
|
|
raise LauncherPatternMissing(path, f"Firmware path under {old}")
|
|
for system, found in entries.items():
|
|
folder = posixpath.commonpath([posixpath.dirname(t) for _, t, _ in found])
|
|
records.append(
|
|
Launcher(
|
|
emulator="ares",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=[posixpath.relpath(t, folder) for _, t, _ in found],
|
|
source_ref=source_ref(
|
|
[(path, {n for _, _, n in found}), (script.path, script_lines)]
|
|
),
|
|
slot=system,
|
|
search_dir=folder,
|
|
passes_to="settings.bml "
|
|
+ ", ".join(f"{system}/Firmware/{k}" for k, _, _ in found),
|
|
)
|
|
)
|
|
return records
|
|
|
|
|
|
def melonds(tree: RepoTree) -> list[Launcher]:
|
|
"""melonDS.ini paths set by changeLine, melonDS.toml paths shipped and rewritten by sed."""
|
|
name = "emuDeckMelonDS.sh"
|
|
records = setter_records(tree, name, option_key="ExternalBIOSEnable")
|
|
script = _script(tree, name)
|
|
# A config whose BIOS keys the setters write again keeps their values.
|
|
set_by_setters = {file for file, _, _, value, _ in setters(script) if _is_bios(value)}
|
|
for file, old, new, sed_lines in sed_rewrites(script, "melonDS_init"):
|
|
if file in set_by_setters:
|
|
continue
|
|
path = _repo_config(script, file)
|
|
found = []
|
|
for setting in settings(tree.read(path)):
|
|
target = _rewritten(setting.value, old, new)
|
|
if target is not None and _is_bios(posixpath.dirname(target)):
|
|
found.append((setting, target))
|
|
if not found:
|
|
continue
|
|
folder = posixpath.commonpath([posixpath.dirname(t) for _, t in found])
|
|
config = posixpath.basename(path)
|
|
option = next((s for s in settings(tree.read(path)) if s.key == "ExternalBIOSEnable"), None)
|
|
if option is None:
|
|
raise LauncherPatternMissing(path, "ExternalBIOSEnable")
|
|
record = Launcher(
|
|
emulator="melonds",
|
|
build="standalone",
|
|
kind="fixed_name",
|
|
names=[posixpath.relpath(t, folder) for _, t in found],
|
|
source_ref=source_ref(
|
|
[(path, {option.line, *(s.line for s, _ in found)}), (script.path, sed_lines)]
|
|
),
|
|
search_dir=folder,
|
|
passes_to=describe_keys(config, tuple((s.section, s.key) for s, _ in found)),
|
|
depends_on_option=f"ExternalBIOSEnable = {option.value} in the shipped {config}",
|
|
)
|
|
records.append(record)
|
|
if len(records) < 2:
|
|
raise script.missing("melonDS.toml BIOS paths rewritten by sed")
|
|
return records
|
|
|
|
|
|
# Scripts whose setters write a BIOS path or directory into the emulator config.
|
|
SETTER_SCRIPTS = (
|
|
"emuDeckRMG.sh",
|
|
"emuDeckXemu.sh",
|
|
"emuDeckDuckStation.sh",
|
|
"emuDeckPCSX2QT.sh",
|
|
"emuDeckARMSX2.sh",
|
|
"emuDeckMAME.sh",
|
|
"emuDeckRetroArch.sh",
|
|
)
|
|
|
|
# Scripts that link a directory of bios/ to one the emulator reads.
|
|
LINK_SCRIPTS = (
|
|
"emuDeckFlycast.sh",
|
|
"emuDeckAzahar.sh",
|
|
"emuDeckCitron.sh",
|
|
"emuDeckEden.sh",
|
|
"emuDeckSuyu.sh",
|
|
"emuDeckYuzu.sh",
|
|
"emuDeckRyujinx.sh",
|
|
"emuDeckShadPS4.sh",
|
|
)
|
|
|
|
|
|
def configured(tree: RepoTree) -> list[Launcher]:
|
|
return [record for name in SETTER_SCRIPTS for record in setter_records(tree, name)]
|
|
|
|
|
|
def links(tree: RepoTree) -> list[Launcher]:
|
|
records = []
|
|
for name in LINK_SCRIPTS:
|
|
script = _script(tree, name)
|
|
found = script.links(BIOS)
|
|
if not found:
|
|
raise script.missing(f"ln -s of a path under {BIOS}")
|
|
records.extend(
|
|
Launcher(
|
|
emulator=_emulator(name),
|
|
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}",
|
|
)
|
|
for link in found
|
|
)
|
|
return records
|
|
|
|
|
|
EXTRACTORS: tuple[Callable[[RepoTree], list[Launcher]], ...] = (
|
|
ares,
|
|
melonds,
|
|
configured,
|
|
links,
|
|
)
|
|
|
|
SOURCE_SCRIPTS = ("emuDeckares.sh", "emuDeckMelonDS.sh", *SETTER_SCRIPTS, *LINK_SCRIPTS)
|
|
|
|
|
|
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)]
|