From 3837d6f097a9262f0a8987a1f8e5a2c401c87a9f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:43:36 +0200 Subject: [PATCH] feat: read EmuDeck launchers from EmuScripts The scraper reads the EmuScripts at the commit checkBIOS.sh is pinned to: the BIOS paths changeLine, iniFieldUpdate and RetroArch_setConfigOverride write, the ares and melonDS configs whose paths sed rewrites, and the bios/ directories linked to Flycast, Azahar, the Switch emulators and shadPS4. The script to emulator mapping moves beside the extractors so both read one table. --- scripts/scraper/emudeck_launchers.py | 388 +++++++++++++++++++++++++ scripts/scraper/emudeck_scraper.py | 37 ++- tests/test_launchers_emudeck.py | 408 +++++++++++++++++++++++++++ 3 files changed, 814 insertions(+), 19 deletions(-) create mode 100644 scripts/scraper/emudeck_launchers.py create mode 100644 tests/test_launchers_emudeck.py diff --git a/scripts/scraper/emudeck_launchers.py b/scripts/scraper/emudeck_launchers.py new file mode 100644 index 00000000..71d68fcf --- /dev/null +++ b/scripts/scraper/emudeck_launchers.py @@ -0,0 +1,388 @@ +"""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 .batocera_launchers import Launcher + from .shell_launchers import ( + LauncherPatternMissing, + RepoTree, + Script, + describe_keys, + settings, + source_ref, + words, + ) +except ImportError: + from batocera_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)] diff --git a/scripts/scraper/emudeck_scraper.py b/scripts/scraper/emudeck_scraper.py index 1b51b909..75381a5b 100644 --- a/scripts/scraper/emudeck_scraper.py +++ b/scripts/scraper/emudeck_scraper.py @@ -26,6 +26,8 @@ try: github_headers, requirement_entry, ) + from .emudeck_launchers import emulator_of_script, extract_launchers + from .shell_launchers import RepoTree except ImportError: from base_scraper import ( BaseScraper, @@ -35,6 +37,8 @@ except ImportError: github_headers, requirement_entry, ) + from emudeck_launchers import emulator_of_script, extract_launchers + from shell_launchers import RepoTree PLATFORM_NAME = "emudeck" @@ -219,6 +223,14 @@ def _fetch_url(url: str) -> str: raise ConnectionError(f"Failed to fetch {url}: {e}") from e +def launcher_revision(url: str) -> str: + """The commit a checkBIOS.sh URL is read at.""" + found = re.search(r"/dragoonDorise/EmuDeck/([0-9a-f]{40})/", url) + if found is None: + raise ValueError(f"{url} names no EmuDeck commit") + return found.group(1) + + class Scraper(BaseScraper): """Scraper for EmuDeck checkBIOS.sh and CSV cheat sheets.""" @@ -459,6 +471,10 @@ class Scraper(BaseScraper): # standalone build keeps its layout (runs_standalone). "standalone_cores": [core for core in cores if core != "retroarch"], "systems": systems, + # Read at the commit checkBIOS.sh comes from. + "launchers": extract_launchers( + RepoTree.github(EMUDECK_REPO, launcher_revision(self.checkbios_url)) + ), } def _fetch_installed_emulators(self) -> list[str]: @@ -472,15 +488,6 @@ class Scraper(BaseScraper): "https://api.github.com/repos/dragoonDorise/EmuDeck/" "contents/functions/EmuScripts" ) - name_overrides = { - "pcsx2qt": "pcsx2", - "rpcs3legacy": "rpcs3", - "cemuproton": "cemu", - "rmg": "mupen64plus_next", - } - # The main RetroArch config script installs no emulator. - skip = {"retroarch_maincfg"} - try: req = urllib.request.Request(api_url, headers=github_headers()) with urllib.request.urlopen(req, timeout=30) as resp: @@ -491,17 +498,9 @@ class Scraper(BaseScraper): cores: list[str] = [] seen: set[str] = set() for entry in data if isinstance(data, list) else []: - name = entry.get("name", "") - if not name.endswith(".sh"): + core = emulator_of_script(entry.get("name", "")) + if core is None: continue - name = re.sub(r"\.sh$", "", name) - name = re.sub(r"^emuDeck", "", name, flags=re.IGNORECASE) - if not name: - continue - key = name.lower() - if key in skip: - continue - core = name_overrides.get(key, key) if core not in seen: seen.add(core) cores.append(core) diff --git a/tests/test_launchers_emudeck.py b/tests/test_launchers_emudeck.py new file mode 100644 index 00000000..31499497 --- /dev/null +++ b/tests/test_launchers_emudeck.py @@ -0,0 +1,408 @@ +"""EmuDeck's launcher layer read from its EmuScripts. + +Each fixture is a cut-down copy of the lines an extractor reads at +dragoonDorise/EmuDeck dddb30229adbbd54d5ff7283cfd70e11c4322c26. The records +must carry the names in the order the script writes them, point at the +fixture's own lines, and a construct that is gone must stop the scrape. +""" + +from __future__ import annotations + +import sys +import textwrap +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from scraper import emudeck_launchers as el # noqa: E402 +from scraper.emudeck_scraper import launcher_revision # noqa: E402 +from scraper.shell_launchers import LauncherPatternMissing, RepoTree, words # noqa: E402 + +S = el.EMUSCRIPTS + + +def text(block: str) -> str: + return textwrap.dedent(block).lstrip("\n") + + +ARES_SH = text( + """ + ares_configFile="$HOME/.var/app/dev.ares.ares/data/ares/settings.bml" + ares_setEmulationFolder(){ + UserROMsPath='/home/deck/Emulation/roms/' + sed -i "s|$UserROMsPath|${romsPath}\\/|g" "$ares_configFile" + UserBIOSPath='/home/deck/Emulation/bios/' + sed -i "s|$UserBIOSPath|${biosPath}\\/|g" "$ares_configFile" + } +""" +) + +ARES_BML = text( + """ + ColecoVision + Path: /home/deck/Emulation/roms/colecovision/ + Firmware + BIOS.World: /home/deck/Emulation/bios/colecovision.rom + Visible: true + PCEngineCD + Path: /home/deck/Emulation/roms/pcenginecd/ + Firmware + BIOS.US: /home/deck/Emulation/bios/syscard3u.pce + BIOS.Japan: /home/deck/Emulation/bios/syscard3.pce + Visible: true + NeoGeoMVS + Path: /home/deck/Emulation/roms/arcade/ + Firmware + BIOS.World + Visible: true +""" +) + +MELONDS_SH = text( + """ + melonDS_configFile="$HOME/.var/app/net.kuribo64.melonDS/config/melonDS/melonDS.ini" + melonDS_resolutionFile="$HOME/.var/app/net.kuribo64.melonDS/config/melonDS/melonDS.toml" + melonDS_init(){ + sed -i "s|/run/media/mmcblk0p1/Emulation|$emulationPath|g" "$melonDS_configFile" + sed -i "s|/run/media/mmcblk0p1/Emulation|$emulationPath|g" "$melonDS_resolutionFile" + } + melonDS_setEmulationFolder(){ + BIOS9PathSetting='BIOS9Path=' + BIOS7PathSetting='BIOS7Path=' + LastROMFolderSetting='LastROMFolder=' + changeLine "$BIOS9PathSetting" "${BIOS9PathSetting}${biosPath}/bios9.bin" "${melonDS_configFile}" + changeLine "$BIOS7PathSetting" "${BIOS7PathSetting}${biosPath}/bios7.bin" "${melonDS_configFile}" + changeLine "$LastROMFolderSetting" "${LastROMFolderSetting}${romsPath}/nds" "${melonDS_configFile}" + } + melonDS_setResolution(){ + changeLine "ScaleFactor = " "$scaleLine" "$melonDS_resolutionFile" + } + melonDS_setABXYstyle(){ + buttonA='Joy_A=' + changeLine "$buttonA" "$buttonA""0" "${melonDS_configFile}" + } +""" +) + +MELONDS_INI = text( + """ + ExternalBIOSEnable=0 + BIOS9Path=/run/media/mmcblk0p1/Emulation/bios/bios9.bin + BIOS7Path=/run/media/mmcblk0p1/Emulation/bios/bios7.bin +""" +) + +MELONDS_TOML = text( + """ + LastROMFolder = "/run/media/mmcblk0p1/Emulation/roms/ds" + [Emu] + ExternalBIOSEnable = false + [DS] + FirmwarePath = "/run/media/mmcblk0p1/Emulation/bios/firmware.bin" + BIOS7Path = "/run/media/mmcblk0p1/Emulation/bios/bios7.bin" + [DSi] + NANDPath = "/run/media/mmcblk0p1/Emulation/bios/dsi_nand.bin" +""" +) + +XEMU_SH = text( + """ + Xemu_configFile="$HOME/.var/app/app.xemu.xemu/data/xemu/xemu/xemu.toml" + Xemu_setEmulationFolder(){ + bootrrom_path='bootrom_path = ' + flashrom_path='flashrom_path = ' + eeprom_path='eeprom_path = ' + bootrrom_pathSetting="${bootrrom_path}""'${biosPath}/mcpx_1.0.bin'" + flashrom_pathSetting="${flashrom_path}""'${biosPath}/Complex_4627v1.03.bin'" + eeprom_pathSetting="${eeprom_path}""'${storagePath}/xemu/eeprom.bin'" + changeLine "${bootrrom_path}" "${bootrrom_pathSetting}" "$Xemu_configFile" + changeLine "${flashrom_path}" "${flashrom_pathSetting}" "$Xemu_configFile" + changeLine "${eeprom_path}" "${eeprom_pathSetting}" "$Xemu_configFile" + } +""" +) + +DUCKSTATION_SH = text( + """ + DuckStation_configFile="$HOME/.local/share/duckstation/settings.ini" + DuckStation_setEmulationFolder(){ + biosDir='SearchDirectory = ' + biosDirSetting="${biosDir}""${biosPath}" + statesDir='SaveStates = ' + statesDirSetting="${statesDir}""${savesPath}/duckstation/states" + changeLine "$biosDir" "$biosDirSetting" "$DuckStation_configFile" + changeLine "$statesDir" "$statesDirSetting" "$DuckStation_configFile" + } +""" +) + +PCSX2_SH = text( + """ + PCSX2QT_configFile="$HOME/.config/PCSX2/inis/PCSX2.ini" + PCSX2QT_setEmulationFolder() { + iniFieldUpdate "$PCSX2QT_configFile" "UI" "StartFullscreen" "true" + iniFieldUpdate "$PCSX2QT_configFile" "Folders" "Bios" "${biosPath}" + iniFieldUpdate "$PCSX2QT_configFile" "Folders" "Snapshots" "${storagePath}/pcsx2/snaps" + } +""" +) + +MAME_SH = text( + """ + MAME_configFile="$HOME/.mame/mame.ini" + MAME_setEmulationFolder(){ + gameDirOpt='rompath ' + newGameDirOpt="$gameDirOpt""${romsPath}/arcade;${biosPath};${biosPath}/mame" + changeLine "$gameDirOpt" "$newGameDirOpt" "$MAME_configFile" + } +""" +) + +RETROARCH_SH = text( + """ + RetroArch_configFile="$HOME/.var/app/org.libretro.RetroArch/config/retroarch/retroarch.cfg" + RetroArch_setEmulationFolder(){ + RetroArch_setConfigOverride 'system_directory' "\\"${biosPath}\\"" "$RetroArch_configFile" + RetroArch_setConfigOverride 'rgui_browser_directory' "\\"${romsPath}\\"" "$RetroArch_configFile" + } +""" +) + +FLYCAST_SH = text( + """ + Flycast_configFile="$HOME/.var/app/org.flycast.Flycast/config/flycast/emu.cfg" + Flycast_setEmulationFolder(){ + mkdir -p "${biosPath}/flycast/" + ln -sn "$HOME/.var/app/org.flycast.Flycast/data/flycast/" "${biosPath}/flycast/bios" + } +""" +) + +CITRON_SH = text( + """ + Citron_setupStorage(){ + unlink "${biosPath}/citron/keys" 2>/dev/null + mkdir -p "${biosPath}/citron" + ln -sn "$HOME/.local/share/citron/keys/" "${biosPath}/citron/keys" + ln -sn "$HOME/.local/share/citron/nand/system/Contents/registered/" "${biosPath}/citron/firmware" + } +""" +) + +SHADPS4_SH = text( + """ + ShadPS4_dir="$HOME/.local/share/shadPS4" + ShadPS4_setupStorage(){ + mkdir -p "${biosPath}/shadps4/" + ln -sn "$ShadPS4_dir/sys_modules" "${biosPath}/shadps4/sys_modules" + } +""" +) + + +def tree(**scripts: str) -> RepoTree: + files = {f"{S}/{name}": body for name, body in scripts.items()} + files[f"{el.CONFIGS}/dev.ares.ares/data/ares/settings.bml"] = ARES_BML + files[f"{el.CONFIGS}/net.kuribo64.melonDS/config/melonDS/melonDS.ini"] = MELONDS_INI + files[f"{el.CONFIGS}/net.kuribo64.melonDS/config/melonDS/melonDS.toml"] = MELONDS_TOML + return RepoTree.from_files(files) + + +class WrittenIntoTheConfig(unittest.TestCase): + def test_ares_firmware_follows_the_rewritten_prefix(self): + colecovision, pcecd = (r.entry() for r in el.ares(tree(**{"emuDeckares.sh": ARES_SH}))) + self.assertEqual( + (colecovision["slot"], colecovision["names"]), ("ColecoVision", ["colecovision.rom"]) + ) + self.assertEqual(pcecd["names"], ["syscard3u.pce", "syscard3.pce"]) + self.assertEqual(pcecd["search_dir"], "${biosPath}") + self.assertEqual( + pcecd["passes_to"], + "settings.bml PCEngineCD/Firmware/BIOS.US, PCEngineCD/Firmware/BIOS.Japan", + ) + self.assertEqual( + pcecd["source_ref"], + f"{el.CONFIGS}/dev.ares.ares/data/ares/settings.bml:9-10, {S}/emuDeckares.sh:1, 5-6", + ) + + def test_a_firmware_key_without_a_value_is_not_a_name(self): + records = [r.entry() for r in el.ares(tree(**{"emuDeckares.sh": ARES_SH}))] + self.assertNotIn("NeoGeoMVS", [r["slot"] for r in records]) + + def test_ares_without_the_bios_rewrite_raises(self): + script = ARES_SH.replace("${biosPath}", "${romsPath}") + with self.assertRaises(LauncherPatternMissing): + el.ares(tree(**{"emuDeckares.sh": script})) + + def test_melonds_ini_keys_and_toml_rewrite(self): + ini, toml = (r.entry() for r in el.melonds(tree(**{"emuDeckMelonDS.sh": MELONDS_SH}))) + self.assertEqual(ini["names"], ["bios9.bin", "bios7.bin"]) + self.assertEqual(ini["search_dir"], "${biosPath}") + self.assertEqual(ini["passes_to"], "melonDS.ini BIOS9Path, BIOS7Path") + self.assertEqual( + ini["depends_on_option"], "ExternalBIOSEnable = 0 in the shipped melonDS.ini" + ) + self.assertEqual(toml["names"], ["firmware.bin", "bios7.bin", "dsi_nand.bin"]) + self.assertEqual(toml["search_dir"], "${emulationPath}/bios") + self.assertEqual( + toml["passes_to"], "melonDS.toml [DS] FirmwarePath, BIOS7Path [DSi] NANDPath" + ) + self.assertEqual( + toml["depends_on_option"], "ExternalBIOSEnable = false in the shipped melonDS.toml" + ) + + def test_a_config_the_setters_rewrite_keeps_their_values(self): + records = [r.entry() for r in el.melonds(tree(**{"emuDeckMelonDS.sh": MELONDS_SH}))] + self.assertEqual( + [r["search_dir"] for r in records], ["${biosPath}", "${emulationPath}/bios"] + ) + + def test_files_are_grouped_by_directory(self): + bios, storage = ( + r.entry() + for r in el.setter_records(tree(**{"emuDeckXemu.sh": XEMU_SH}), "emuDeckXemu.sh") + ) + self.assertEqual(bios["names"], ["mcpx_1.0.bin", "Complex_4627v1.03.bin"]) + self.assertEqual(bios["passes_to"], "xemu.toml bootrom_path, flashrom_path") + self.assertEqual( + (storage["search_dir"], storage["names"]), ("${storagePath}/xemu", ["eeprom.bin"]) + ) + self.assertEqual(bios["source_ref"], f"{S}/emuDeckXemu.sh:1-10") + + def test_a_bios_directory_is_left_to_the_emulator(self): + (record,) = ( + r.entry() + for r in el.setter_records( + tree(**{"emuDeckDuckStation.sh": DUCKSTATION_SH}), "emuDeckDuckStation.sh" + ) + ) + self.assertEqual(record["kind"], "scans_dir") + self.assertEqual(record["search_dir"], "${biosPath}") + self.assertEqual(record["passes_to"], "settings.ini SearchDirectory") + (pcsx2,) = ( + r.entry() + for r in el.setter_records(tree(**{"emuDeckPCSX2QT.sh": PCSX2_SH}), "emuDeckPCSX2QT.sh") + ) + self.assertEqual( + (pcsx2["emulator"], pcsx2["passes_to"]), ("pcsx2", "PCSX2.ini [Folders] Bios") + ) + + def test_a_search_path_list_is_kept_whole(self): + (record,) = ( + r.entry() + for r in el.setter_records(tree(**{"emuDeckMAME.sh": MAME_SH}), "emuDeckMAME.sh") + ) + self.assertEqual(record["search_dir"], "${romsPath}/arcade;${biosPath};${biosPath}/mame") + self.assertEqual(record["passes_to"], "mame.ini rompath") + + def test_retroarch_is_handed_the_directory(self): + (record,) = ( + r.entry() + for r in el.setter_records( + tree(**{"emuDeckRetroArch.sh": RETROARCH_SH}), "emuDeckRetroArch.sh" + ) + ) + self.assertEqual( + (record["build"], record["kind"], record["search_dir"]), + ("libretro", "passes_path", "${biosPath}"), + ) + + def test_an_unknown_directory_key_raises(self): + script = DUCKSTATION_SH.replace("SearchDirectory", "BiosFolder") + with self.assertRaises(LauncherPatternMissing): + el.setter_records(tree(**{"emuDeckDuckStation.sh": script}), "emuDeckDuckStation.sh") + + def test_a_script_that_writes_no_bios_path_raises(self): + script = PCSX2_SH.replace('"Bios" "${biosPath}"', '"Bios" "${storagePath}/bios"') + with self.assertRaises(LauncherPatternMissing): + el.setter_records(tree(**{"emuDeckPCSX2QT.sh": script}), "emuDeckPCSX2QT.sh") + + +class LinkedIntoBios(unittest.TestCase): + def records(self) -> list[dict]: + # Every link script must hold a BIOS link: the ones not under test reuse Flycast's. + scripts = {name: FLYCAST_SH for name in el.LINK_SCRIPTS} + scripts.update({"emuDeckCitron.sh": CITRON_SH, "emuDeckShadPS4.sh": SHADPS4_SH}) + return [r.entry() for r in el.links(tree(**scripts))] + + def test_the_link_lives_in_bios_and_points_at_the_emulator(self): + flycast = next(r for r in self.records() if r["emulator"] == "flycast") + self.assertEqual(flycast["search_dir"], "${biosPath}/flycast/bios") + self.assertEqual( + flycast["passes_to"], + "symlink ${biosPath}/flycast/bios -> ${HOME}/.var/app/org.flycast.Flycast/data/flycast/", + ) + self.assertEqual(flycast["source_ref"], f"{S}/emuDeckFlycast.sh:4") + + def test_each_directory_of_a_different_name_is_its_own_record(self): + citron = [r for r in self.records() if r["emulator"] == "citron"] + self.assertEqual( + [r["search_dir"] for r in citron], + ["${biosPath}/citron/keys", "${biosPath}/citron/firmware"], + ) + + def test_a_variable_in_the_target_is_expanded(self): + shadps4 = next(r for r in self.records() if r["emulator"] == "shadps4") + self.assertEqual( + shadps4["passes_to"], + "symlink ${biosPath}/shadps4/sys_modules -> ${HOME}/.local/share/shadPS4/sys_modules", + ) + self.assertEqual(shadps4["source_ref"], f"{S}/emuDeckShadPS4.sh:1, 4") + + def test_a_script_without_a_bios_link_raises(self): + scripts = {name: FLYCAST_SH for name in el.LINK_SCRIPTS} + scripts["emuDeckCitron.sh"] = 'ln -sn "$HOME/a" "$HOME/b"\n' + with self.assertRaises(LauncherPatternMissing): + el.links(tree(**scripts)) + + +class Helpers(unittest.TestCase): + def test_a_quote_ends_a_variable_name(self): + self.assertEqual( + [w.text for w in words('changeLine "$buttonA""0" "${f}"')], + ["changeLine", "${buttonA}0", "${f}"], + ) + + def test_script_names_map_to_the_emulator_profile(self): + self.assertEqual(el.emulator_of_script("emuDeckRMG.sh"), "mupen64plus_next") + self.assertEqual(el.emulator_of_script("emuDeckPCSX2QT.sh"), "pcsx2") + self.assertEqual(el.emulator_of_script("emuDeckXemu.sh"), "xemu") + self.assertIsNone(el.emulator_of_script("RetroArch_maincfg.sh")) + self.assertIsNone(el.emulator_of_script("README.md")) + + def test_the_revision_comes_from_the_pinned_url(self): + url = ( + "https://raw.githubusercontent.com/dragoonDorise/EmuDeck/" + "dddb30229adbbd54d5ff7283cfd70e11c4322c26/functions/checkBIOS.sh" + ) + self.assertEqual(launcher_revision(url), "dddb30229adbbd54d5ff7283cfd70e11c4322c26") + with self.assertRaises(ValueError): + launcher_revision( + "https://raw.githubusercontent.com/dragoonDorise/EmuDeck/main/functions/checkBIOS.sh" + ) + + +class PlatformRecords(unittest.TestCase): + """The launchers platforms/emudeck.yml carries, as the scraper wrote them.""" + + @classmethod + def setUpClass(cls) -> None: + cls.config = yaml.safe_load((REPO_ROOT / "platforms" / "emudeck.yml").read_text()) + + def test_every_emulator_is_one_emudeck_installs(self): + cores = set(self.config["cores"]) + self.assertEqual(sorted({r["emulator"] for r in self.config["launchers"]} - cores), []) + + def test_every_reference_is_an_emuscript_or_a_shipped_config(self): + for record in self.config["launchers"]: + for part in record["source_ref"].split(", "): + if ":" in part: + self.assertTrue(part.startswith((f"{S}/", f"{el.CONFIGS}/")), record) + + +if __name__ == "__main__": + unittest.main()