"""EmuDeck's launcher layer, read from its EmuScripts at the pinned commit. Each functions/EmuScripts/emuDeck.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)]