diff --git a/platforms/batocera.yml b/platforms/batocera.yml index 0d55954da..968db3d35 100644 --- a/platforms/batocera.yml +++ b/platforms/batocera.yml @@ -6581,3 +6581,20 @@ launchers: fails_when_missing: false depends_on_option: neocd_bios source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/libretro/libretroOptions.py:2040-2041" +- emulator: shadps4 + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/system/configs/shadps4 + passes_to: working directory of shadps4Generator.py + fails_when_missing: false + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/generators/shadps4/shadps4Generator.py:46, 228" +- emulator: citron + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/switch + passes_to: "CitronPath::KeysDir in src/common/fs/path_util.cpp returns it, in place of citron_path\ + \ / KEYS_DIR" + fails_when_missing: false + source_ref: "package/batocera/emulators/citron/002-adjust-paths.patch:9-10" diff --git a/platforms/knulli.yml b/platforms/knulli.yml index 3345c487d..2ac54eaff 100644 --- a/platforms/knulli.yml +++ b/platforms/knulli.yml @@ -5685,6 +5685,14 @@ launchers: passes_to: environment FLYCAST_BIOS_PATH fails_when_missing: false source_ref: "package/system/knulli-configgen/configgen/configgen/generators/flycast/flycastGenerator.py:239" +- emulator: shadps4 + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/system/configs/shadps4 + passes_to: working directory of shadps4Generator.py + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/shadps4/shadps4Generator.py:33, 164" - emulator: lexaloffle build: standalone kind: fixed_name @@ -6135,3 +6143,20 @@ launchers: depends_on_option: not system.name == 'ti99' source_ref: "package/system/knulli-configgen/configgen/configgen/generators/libretro/libretroMAMEConfig.py:431-432,\ \ 435" +- emulator: suyu + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/saves/switch + passes_to: environment XDG_DATA_HOME + fails_when_missing: false + source_ref: "package/system/knulli-configgen/configgen/configgen/generators/suyu/suyuGenerator.py:41" +- emulator: citron + build: standalone + kind: passes_path + names: [] + search_dir: /userdata/bios/switch + passes_to: "CitronPath::KeysDir in src/common/fs/path_util.cpp returns it, in place of citron_path\ + \ / KEYS_DIR" + fails_when_missing: false + source_ref: "batocera/package/batocera/emulators/citron/002-adjust-paths.patch:9-10" diff --git a/platforms/retrobat.yml b/platforms/retrobat.yml index 5786bc1a0..7da5f851f 100644 --- a/platforms/retrobat.yml +++ b/platforms/retrobat.yml @@ -4966,6 +4966,22 @@ launchers: passes_to: exec fails_when_missing: returns no process when the binary is absent source_ref: "emulatorLauncher/Generators/Pico8.Generator.cs:14-18, 56-57" +- emulator: citron + build: standalone + kind: passes_path + names: [] + search_dir: "/user" + passes_to: PORTABLE_DIR, the user folder beside citron.exe + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Citron.Generator.cs:25, 29-30" +- emulator: shadps4 + build: standalone + kind: passes_path + names: [] + search_dir: "/user" + passes_to: PORTABLE_DIR, the user folder beside shadPS4.exe + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/ShadPS4.Generator.cs:29, 158-160" - emulator: bizhawk build: standalone kind: names_searched diff --git a/scripts/launchers.py b/scripts/launchers.py index 37ac6a3b7..b79cb5678 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -431,13 +431,19 @@ def handed_directories(config: dict) -> list[HandedDirectory]: directory=directory, handles=handles, source_ref=str(record.get("source_ref", "")), - roms_place=None if folder is not None else _roms_place( - config, folder_in_roms(raw, config) + roms_place=None if folder is not None else ( + _roms_place(config, folder_in_roms(raw, config)) + or _emulators_place(folder_in_emulators(raw, config)) ), )) return found +def _emulators_place(path: str | None) -> Place | None: + """A folder an emulator key names, as a place of the emulators tree.""" + return None if path is None else (EMULATORS_TREE, path) + + def _roms_place(config: dict, path: str | None) -> Place | None: """A path below the roms root as a place: in the install tree when the platform installs above its BIOS folder (AmberELEC), else the roms tree.""" @@ -480,9 +486,11 @@ def folder_in_emulators(directory: str, config: dict) -> str | None: spelling = str((config.get("emulators_directory") or {}).get("launcher_spelling") or "") if "{key}" not in spelling: return None - pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)") + # The key, then any folder below it: Citron's generator creates + # /user, its portable data folder. + pattern = re.escape(spelling).replace(re.escape("{key}"), r"([\w.-]+)") + r"((?:/[\w.-]+)*)" found = re.fullmatch(pattern, directory.strip()) - return found.group(1) if found else None + return found.group(1) + found.group(2) if found else None @dataclass(frozen=True) @@ -566,8 +574,18 @@ def standalone_bindings( A subfolder two records give two places is left out. """ names = set(names) - wanted = {normalize_handle(d) for d in declared} - if not wanted: + # " = " names the code's handle for one folder of the + # layout: Citron's CitronPath::KeysDir is its keys/ folder, and a patch + # that rewrites the handle moves that folder alone. + named: dict[str, str] = {} + wanted: set[str] = set() + for raw in declared: + handle, sep, sub = str(raw).partition(" = ") + if sep: + named[normalize_handle(handle)] = sanitize_pack_path(sub.strip()) + else: + wanted.add(normalize_handle(handle)) + if not wanted and not named: return {} records = [h for h in handed if h.emulator in names] @@ -583,7 +601,23 @@ def standalone_bindings( subs = { sub for handle in record.handles for directory in wanted if (sub := below(handle, directory)) is not None + } | {named[handle] for handle in record.handles if handle in named} + # A record setting an environment variable the declared directory is + # spelled from ($XDG_DATA_HOME/suyu) puts the whole layout below it. + variables = { + directory: rest for handle in record.handles for directory in wanted + for spelled in (f"${handle}", handle) + if (rest := below(directory, spelled)) is not None and handle != directory } + for rest in variables.values(): + place = record.place() + if place is None: + found.setdefault("", set()).add((OUTSIDE_TREE, "")) + else: + root, folder = place + found.setdefault("", set()).add( + (root, "/".join(p for p in (folder, rest) if p)) + ) for sub in subs: if record.folder is not None: found.setdefault(sub, set()).add((BIOS_TREE, record.folder)) diff --git a/scripts/scraper/batocera_launchers.py b/scripts/scraper/batocera_launchers.py index 67f03887e..aa4222ac9 100644 --- a/scripts/scraper/batocera_launchers.py +++ b/scripts/scraper/batocera_launchers.py @@ -15,6 +15,7 @@ launcher looks for nothing". from __future__ import annotations import ast +import re from collections.abc import Callable from typing import Any @@ -104,9 +105,10 @@ def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None: and isinstance(node.func, ast.Name) and node.func.id == "Path" and len(node.args) == 1 - and isinstance(node.args[0], ast.Constant) ): - return str(node.args[0].value) + if isinstance(node.args[0], ast.Constant): + return str(node.args[0].value) + return _path_value(node.args[0], constants) if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): left = _path_value(node.left, constants) right = node.right.value if isinstance(node.right, ast.Constant) else None @@ -898,6 +900,44 @@ def environment_directories(cg: Configgen) -> list[Launcher]: return launchers +# Generators that start the emulator from a folder they enter with os.chdir. +WORKING_DIRECTORY_GENERATORS = ("shadps4.shadps4Generator",) + + +def working_directories(cg: Configgen) -> list[Launcher]: + """os.chdir() in the generators above, before the command runs. + + shadPS4 reads its user folder below the working directory first + (std::filesystem::current_path() / "user"): Batocera enters + /userdata/system/configs/shadps4. + """ + launchers = [] + for module in WORKING_DIRECTORY_GENERATORS: + src = cg.generator(module) + calls = _calls(src.tree, attr="chdir") + if len(calls) != 1 or len(calls[0].args) != 1: + src.fail("one os.chdir(folder) call") + call = calls[0] + argument = call.args[0] + constants = dict(cg.paths) + spans = [(call.lineno, call.end_lineno or call.lineno)] + if isinstance(argument, ast.Name): + scope = _innermost_function(src, call) + assigned = src.assignment(argument.id, scope) + spans.append((assigned.lineno, assigned.end_lineno or assigned.lineno)) + folder = _path_value(assigned.value, constants) + else: + folder = _path_value(argument, constants) + if folder is None: + src.fail("os.chdir names no folder") + launchers.append(Launcher( + emulator=cg.emulator(module), build="standalone", kind="passes_path", names=[], + search_dir=folder, passes_to=f"working directory of {src.path.rsplit('/', 1)[-1]}", + source_ref=src.ref(spans), + )) + return launchers + + # libretro core options whose value names the BIOS file the core opens. FILE_OPTIONS = (("_o2em_options", "o2em_bios"), ("_neocd_options", "neocd_bios")) @@ -950,13 +990,64 @@ def core_file_options(cg: Configgen) -> list[Launcher]: return launchers +# Batocera's patches to an emulator's own path code. Each rewrites one of +# the folders the emulator derives (yuzu's family generates every user +# folder through GeneratePath) to a fixed folder of /userdata. +PATH_PATCHES = ("package/batocera/emulators/citron/002-adjust-paths.patch",) +_PATH_REMOVED = re.compile(r"^-\s*Generate\w+Path\((\w+Path::\w+),\s*(.+?)\);\s*$") +_PATH_ADDED = re.compile(r'^\+\s*Generate\w+Path\((\w+Path::\w+),\s*"(/userdata/bios[^"]*)"\);\s*$') + + +def patched_paths(read: Callable[[str], str], patches: tuple[str, ...] = PATH_PATCHES) -> list[Launcher]: + """The emulator folders a path patch points at the BIOS tree. + + A patch line replacing `GenerateCitronPath(CitronPath::KeysDir, + citron_path / KEYS_DIR)` with a literal /userdata/bios folder hands + that folder to the emulator under the handle the code names it by. + The emulator is the package the patch belongs to. + """ + launchers: list[Launcher] = [] + for path in patches: + lines = read(path).splitlines() + target = "" + removed: dict[str, tuple[int, str]] = {} + found = 0 + for number, line in enumerate(lines, 1): + if line.startswith("+++ b/"): + target = line[len("+++ b/"):] + continue + if (old := _PATH_REMOVED.match(line)): + removed[old.group(1)] = (number, old.group(2)) + continue + if not (new := _PATH_ADDED.match(line)): + continue + handle, folder = new.groups() + if handle not in removed or not target: + raise LauncherShapeError(f"{path}:{number}: {handle} added with no line it replaces") + first, original = removed[handle] + found += 1 + launchers.append(Launcher( + emulator=path.split("/")[-2], build="standalone", kind="passes_path", names=[], + search_dir=folder, + passes_to=f"{handle} in {target} returns it, in place of {original}", + fails_when_missing=False, + source_ref=f"{path}:{first}-{number}", + )) + if not found: + raise LauncherShapeError(f"{path}: no path rewritten to /userdata/bios") + return launchers + + EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus, x16emu, gsplus, lexaloffle, environment_directories, core_file_options, + working_directories, ) def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]: """Every launcher record, read through `read(repo-relative path)`.""" cg = Configgen(read) - return [launcher.entry() for extractor in EXTRACTORS for launcher in extractor(cg)] + launchers = [launcher for extractor in EXTRACTORS for launcher in extractor(cg)] + launchers += patched_paths(read) + return [launcher.entry() for launcher in launchers] diff --git a/scripts/scraper/knulli_launchers.py b/scripts/scraper/knulli_launchers.py index 5a31bcd6b..be94eb13a 100644 --- a/scripts/scraper/knulli_launchers.py +++ b/scripts/scraper/knulli_launchers.py @@ -30,6 +30,7 @@ import yaml try: from .batocera_launchers import ( + PATH_PATCHES, Configgen, _path_value, _render, @@ -40,11 +41,14 @@ try: hatari, melonds, mupen64plus, + patched_paths, x16emu, + working_directories, xemu, ) from .configgen_launchers import ( Launcher, + _calls, LauncherShapeError, Source, _assigned_name, @@ -52,6 +56,7 @@ try: from .shell_launchers import LauncherPatternMissing, Script, source_ref except ImportError: from batocera_launchers import ( # type: ignore[no-redef] + PATH_PATCHES, Configgen, _path_value, _render, @@ -62,11 +67,14 @@ except ImportError: hatari, melonds, mupen64plus, + patched_paths, x16emu, + working_directories, xemu, ) from configgen_launchers import ( # type: ignore[no-redef] Launcher, + _calls, LauncherShapeError, Source, _assigned_name, @@ -86,7 +94,7 @@ ES_FEATURES = "package/emulationstation/knulli-es-system/es_features.yml" # Batocera's extractors that read KNULLI's generators unchanged. SHARED = ( duckstation, duckstation_legacy, hatari, xemu, melonds, mupen64plus, x16emu, - environment_directories, + environment_directories, working_directories, ) @@ -1054,14 +1062,65 @@ EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( ) -def extract_launchers(read: Callable[[str], str]) -> list[dict[str, Any]]: - """Every launcher record, read through `read(repo-relative path)`.""" +# Generators whose command sets the emulator's XDG data folder, the parent +# of every user folder a yuzu-family emulator derives. +DATA_HOME_GENERATORS = ("suyu.suyuGenerator",) + + +def data_homes(cg: Configgen) -> list[Launcher]: + """Command(..., env={"XDG_DATA_HOME": }) in the generators above. + + The path need not lie in /userdata/bios: KNULLI's suyu keeps its data + in /userdata/saves/switch, which tells where its keys are not. + """ + launchers = [] + for module in DATA_HOME_GENERATORS: + src = cg.generator(module) + found = [ + (_path_value(item, cg.paths), key) + for call in _calls(src.tree, attr="Command") + for keyword in call.keywords + if keyword.arg == "env" and isinstance(keyword.value, ast.Dict) + for key, item in zip(keyword.value.keys, keyword.value.values) + if isinstance(key, ast.Constant) and key.value == "XDG_DATA_HOME" + ] + if not found or found[0][0] is None: + src.fail("the command environment sets no XDG_DATA_HOME path") + value, node = found[0] + launchers.append(Launcher( + emulator=cg.emulator(module), build="standalone", kind="passes_path", names=[], + search_dir=value, passes_to="environment XDG_DATA_HOME", + source_ref=src.ref([(node.lineno, node.end_lineno)]), + )) + return launchers + + +# 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) + launchers += data_homes(cg) + 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] diff --git a/scripts/scraper/knulli_scraper.py b/scripts/scraper/knulli_scraper.py index 47af8e50c..abae8d987 100644 --- a/scripts/scraper/knulli_scraper.py +++ b/scripts/scraper/knulli_scraper.py @@ -45,12 +45,15 @@ from .batocera_scraper import ( requirements_from_systems, ) from .es_emulators import attach_system_cores -from .knulli_launchers import extract_launchers +from .knulli_launchers import BATOCERA_SUBMODULE, extract_launchers PLATFORM_NAME = "knulli" REPO = "knulli-cfw/knulli-linux" _RAW = "https://raw.githubusercontent.com/knulli-cfw/knulli-linux/{ref}/" +# The batocera.linux revision KNULLI's submodule pins, and its files. +_SUBMODULE = "https://api.github.com/repos/knulli-cfw/knulli-linux/contents/{path}?ref={ref}" +_BATOCERA_RAW = "https://raw.githubusercontent.com/batocera-linux/batocera.linux/{ref}/" SOURCE_TEMPLATE = _RAW + "package/system/knulli-scripts/scripts/knulli-systems" CONFIGGEN_DEFAULTS_TEMPLATE = ( _RAW + "package/system/knulli-configgen/configs/configgen-defaults.yml" @@ -83,6 +86,23 @@ class Scraper(BaseScraper): except urllib.error.URLError as e: raise ConnectionError(f"Failed to fetch {url}: {e}") from e + def submodule_revision(self) -> str: + """The batocera.linux commit KNULLI's batocera submodule pins at the tag.""" + import json # noqa: PLC0415 + + from .base_scraper import github_headers # noqa: PLC0415 + + url = _SUBMODULE.format(path=BATOCERA_SUBMODULE, ref=self.tag) + req = urllib.request.Request(url, headers=github_headers()) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + found = json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as e: + raise ConnectionError(f"Failed to fetch {url}: {e}") from e + if not isinstance(found, dict) or found.get("type") != "submodule" or not found.get("sha"): + raise RuntimeError(f"{url}: {BATOCERA_SUBMODULE} is not a submodule") + return str(found["sha"]) + def validate_format(self, raw_data: str) -> bool: has_dict = re.search(r"^systems\s*=\s*\{", raw_data, re.MULTILINE) is not None return has_dict and "def checkBios" in raw_data and '"biosFiles"' in raw_data @@ -126,8 +146,10 @@ class Scraper(BaseScraper): # configgen hands a libretro core /userdata/saves/, as # Batocera's does: the systems each core runs for are its save folders. result["libretro_systems"] = es_systems_libretro_systems(es_systems, dropped) + revision = self.submodule_revision() result["launchers"] = extract_launchers( - lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path) + lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path), + lambda path: self._fetch_text(_BATOCERA_RAW.format(ref=revision) + path), ) return result diff --git a/scripts/scraper/retrobat_launchers.py b/scripts/scraper/retrobat_launchers.py index 6c851eade..45ce221b2 100644 --- a/scripts/scraper/retrobat_launchers.py +++ b/scripts/scraper/retrobat_launchers.py @@ -25,6 +25,7 @@ SOURCE_FILES = ( f"{GENERATORS}/Bizhawk.CoreOptions.cs", f"{GENERATORS}/Bizhawk.Generator.cs", f"{GENERATORS}/Blastem.Generator.cs", + f"{GENERATORS}/Citron.Generator.cs", f"{GENERATORS}/Demul.Generator.cs", f"{GENERATORS}/Desmume.Generator.cs", f"{GENERATORS}/Dolphin.Generator.cs", @@ -58,6 +59,7 @@ SOURCE_FILES = ( f"{GENERATORS}/Rpcs3.Generator.cs", f"{GENERATORS}/SSF.Generator.cs", f"{GENERATORS}/ScummVm.Generator.cs", + f"{GENERATORS}/ShadPS4.Generator.cs", f"{GENERATORS}/Snes9x.Generator.cs", f"{GENERATORS}/Tsugaru.Generator.cs", f"{GENERATORS}/Uae.Generator.cs", @@ -2980,6 +2982,48 @@ def xroar(sources: dict[str, Source]) -> list[Record]: return _covered(src, records) +# Generators that create a portable user folder beside the emulator's binary, +# and the emulator each one starts: the folder AppConfig.GetFullPath gives. +PORTABLE_USERS = ( + ("Citron.Generator.cs", "citron", "citron.exe"), + ("ShadPS4.Generator.cs", "shadps4", "shadPS4.exe"), +) +_EMULATOR_FOLDER = ( + r"GetEmulatorPathAndExe\(emulator, out string path, out string exe, \"[^\"]+\"\)" + r"|string path = AppConfig\.GetFullPath\(emulator\);" +) + + +def portable_users(sources: dict[str, Source]) -> list[Record]: + """The portable user folder a generator creates beside the binary. + + The emulator folder comes from AppConfig.GetFullPath(emulator), and the + generator creates /user before launching: the emulator then + keeps its data there (keys/, sys_modules/) in place of %APPDATA%. + """ + records = [] + for name, emulator, binary in PORTABLE_USERS: + src = sources[f"{GENERATORS}/{name}"] + found = src.find(_EMULATOR_FOLDER, f"{emulator} folder") + user = src.find(r'string userFolder = Path\.Combine\(path, "(\w+)"\);', "user folder") + create = src.find( + r"Directory\.CreateDirectory\(userFolder\)", "user folder creation", user.end() + ) + records.append(_record( + emulator, + "standalone", + "passes_path", + [], + source_ref((src.path, [ + src.lines(found.start(), found.end()), src.lines(user.start(), create.end()), + ])), + search_dir=f'/{user.group(1)}', + passes_to=f"PORTABLE_DIR, the user folder beside {binary}", + fails_when_missing=False, + )) + return records + + def pico8(sources: dict[str, Source]) -> list[Record]: """The PICO-8 binary the generator runs, from an emulator folder. @@ -3287,6 +3331,7 @@ EXTRACTORS: tuple[Callable[[dict[str, Source]], list[Record]], ...] = ( pcsx2, pcsx2_libretro, pico8, + portable_users, psx_cascades, psxmame, raine, diff --git a/tests/test_launcher_paths.py b/tests/test_launcher_paths.py new file mode 100644 index 000000000..2f0cb8f1e --- /dev/null +++ b/tests/test_launcher_paths.py @@ -0,0 +1,217 @@ +"""Folders a platform's launcher hands an emulator, read from its code. + +Batocera patches Citron's path code to read its keys from /userdata/bios/ +switch and starts shadPS4 from /userdata/system/configs/shadps4, KNULLI sets +suyu's XDG data folder, and RetroBat creates a portable user folder beside +Citron and shadPS4. Each fixture is a cut-down copy of the lines at the +revisions the platform files pin, and a binding reads the record the +extractor writes. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import sys +import textwrap +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from launchers import handed_directories, standalone_bindings # noqa: E402 +from scraper import knulli_launchers as kl # noqa: E402 +from scraper import retrobat_launchers as rl # noqa: E402 +from scraper.batocera_launchers import ( # noqa: E402 + GENERATORS, + IMPORTER, + PATHS_MODULE, + Configgen, + LauncherShapeError, + patched_paths, + working_directories, +) + +PATCH = "package/batocera/emulators/citron/002-adjust-paths.patch" +PATCH_TEXT = textwrap.dedent("""\ + diff --git a/src/common/fs/path_util.cpp b/src/common/fs/path_util.cpp + index f92cbe0f7b..9e3dcd9c18 100644 + --- a/src/common/fs/path_util.cpp + +++ b/src/common/fs/path_util.cpp + @@ -121,12 +121,12 @@ public: + GenerateCitronPath(CitronPath::ConfigDir, citron_path_config); + GenerateCitronPath(CitronPath::DumpDir, citron_path / DUMP_DIR); + - GenerateCitronPath(CitronPath::KeysDir, citron_path / KEYS_DIR); + + GenerateCitronPath(CitronPath::KeysDir, "/userdata/bios/switch"); + - GenerateCitronPath(CitronPath::LogDir, citron_path / LOG_DIR); + + GenerateCitronPath(CitronPath::LogDir, "/userdata/system/logs"); + """) + +PATHS = """from pathlib import Path +from typing import Final + +USERDATA: Final = Path('/userdata') +BIOS: Final = USERDATA / 'bios' +SAVES: Final = USERDATA / 'saves' +CONFIGS: Final = USERDATA / 'system' / 'configs' +""" +IMPORTER_TEXT = """_LEGACY_GENERATOR_MAP = {} +_GENERATOR_MAP = {} +""" +SHADPS4 = f"{GENERATORS}/shadps4/shadps4Generator.py" +SHADPS4_TEXT = textwrap.dedent("""\ + import os + from ...batoceraPaths import CONFIGS + + class ShadPS4Generator: + def generate(self, system, rom): + configPath = CONFIGS / "shadps4" + userConfigPath = configPath / "user" + os.chdir(configPath) + return None + """) + + +def records(entries: list) -> list[dict]: + return [entry.entry() if hasattr(entry, "entry") else entry for entry in entries] + + +class APatchRewritingAFolder(unittest.TestCase): + def test_the_bios_folder_a_patch_gives_is_a_record(self): + (record,) = records(patched_paths({PATCH: PATCH_TEXT}.__getitem__)) + self.assertEqual( + (record["emulator"], record["kind"], record["search_dir"], record["source_ref"]), + ("citron", "passes_path", "/userdata/bios/switch", f"{PATCH}:8-9"), + ) + self.assertIn("CitronPath::KeysDir", record["passes_to"]) + self.assertIn("citron_path / KEYS_DIR", record["passes_to"]) + + def test_a_patch_that_no_longer_rewrites_a_bios_folder_stops_the_scrape(self): + text = PATCH_TEXT.replace('"/userdata/bios/switch"', '"/userdata/saves/switch"') + with self.assertRaises(LauncherShapeError): + patched_paths({PATCH: text}.__getitem__) + + def test_the_named_handle_binds_its_folder_alone(self): + config = {"base_destination": "bios", "standalone_cores": ["citron"], "launchers": + records(patched_paths({PATCH: PATCH_TEXT}.__getitem__))} + found = standalone_bindings( + {"citron"}, ["$XDG_DATA_HOME/citron", "CitronPath::KeysDir = keys"], + handed_directories(config), + ) + self.assertEqual(found, {"keys": ("", "switch")}) + + +class AFolderTheGeneratorEnters(unittest.TestCase): + def tree(self, text: str = SHADPS4_TEXT) -> Configgen: + files = {PATHS_MODULE: PATHS, IMPORTER: IMPORTER_TEXT, SHADPS4: text} + return Configgen(files.__getitem__) + + def test_the_working_directory_is_a_record(self): + (record,) = records(working_directories(self.tree())) + self.assertEqual( + (record["emulator"], record["search_dir"], record["passes_to"], record["source_ref"]), + ("shadps4", "/userdata/system/configs/shadps4", + "working directory of shadps4Generator.py", f"{SHADPS4}:6, 8"), + ) + + def test_a_generator_that_no_longer_enters_a_folder_stops_the_scrape(self): + with self.assertRaises(LauncherShapeError): + working_directories(self.tree(SHADPS4_TEXT.replace("os.chdir(configPath)", "pass"))) + + def test_the_user_folder_below_it_is_outside_the_bios_tree(self): + config = {"base_destination": "bios", "standalone_cores": ["shadps4"], + "launchers": records(working_directories(self.tree()))} + found = standalone_bindings( + {"shadps4"}, ["getcwd/user", "$XDG_DATA_HOME/shadPS4"], handed_directories(config) + ) + self.assertEqual(found, {"": ("outside", "")}) + + +class ADataFolderTheEnvironmentSets(unittest.TestCase): + SUYU = f"{kl.GENERATORS}/suyu/suyuGenerator.py" + TEXT = textwrap.dedent("""\ + from ...batoceraPaths import CACHE, CONFIGS, SAVES + + class SuyuGenerator: + def generate(self, system, rom): + commandArray = ["/usr/bin/suyu", "-f", "-g", rom] + return Command.Command(array=commandArray, env={ + "XDG_CONFIG_HOME": CONFIGS, + "XDG_DATA_HOME": SAVES / "switch", + }) + """) + + def test_the_data_folder_is_a_record_and_binds_outside(self): + files = {f"{kl.CONFIGGEN}/batoceraPaths.py": PATHS, + f"{kl.GENERATORS}/importer.py": IMPORTER_TEXT, self.SUYU: self.TEXT} + (record,) = records(kl.data_homes(kl.configgen(files.__getitem__))) + self.assertEqual( + (record["emulator"], record["search_dir"], record["passes_to"], record["source_ref"]), + ("suyu", "/userdata/saves/switch", "environment XDG_DATA_HOME", f"{self.SUYU}:8"), + ) + config = {"base_destination": "bios", "standalone_cores": ["suyu"], "launchers": [record]} + found = standalone_bindings( + {"suyu"}, ["$XDG_DATA_HOME/suyu", "SuyuPath::KeysDir = keys"], + handed_directories(config), + ) + self.assertEqual(found, {"": ("outside", "")}) + + +class APortableUserFolder(unittest.TestCase): + CITRON = """ + public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution) + { + if (!GetEmulatorPathAndExe(emulator, out string path, out string exe, "citron.exe")) + return null; + + // Ensure user folder exists + string userFolder = Path.Combine(path, "user"); + if (!Directory.Exists(userFolder)) try { Directory.CreateDirectory(userFolder); } + catch { } + } + """ + SHADPS4 = """ + public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution) + { + string path = AppConfig.GetFullPath(emulator); + } + + private void SetupConfigurationJSON(string path, string rom) + { + string userFolder = Path.Combine(path, "user"); + if (!Directory.Exists(userFolder)) + try { Directory.CreateDirectory(userFolder); } catch { } + } + """ + + def sources(self) -> dict[str, rl.Source]: + return { + f"{rl.GENERATORS}/Citron.Generator.cs": rl.Source( + "c", textwrap.dedent(self.CITRON).lstrip("\n")), + f"{rl.GENERATORS}/ShadPS4.Generator.cs": rl.Source( + "s", textwrap.dedent(self.SHADPS4).lstrip("\n")), + } + + def test_each_generator_gives_its_user_folder(self): + found = rl.portable_users(self.sources()) + self.assertEqual( + [(r["emulator"], r["search_dir"], r["source_ref"]) for r in found], + [("citron", '/user', "c:3, 7-8"), + ("shadps4", '/user', "s:3, 8-10")], + ) + + def test_the_user_folder_binds_below_the_emulator_folder(self): + config = { + "base_destination": "bios", "standalone_cores": ["citron"], + "emulators_directory": {"launcher_spelling": ''}, + "launchers": rl.portable_users(self.sources())[:1], + } + found = standalone_bindings( + {"citron"}, ["$XDG_DATA_HOME/citron", "PORTABLE_DIR"], handed_directories(config) + ) + self.assertEqual(found, {"": ("emulators", "citron/user")}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 838704d04..abc889e72 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -551,8 +551,13 @@ class PlatformRecords(unittest.TestCase): self.assertEqual(sorted(named - cores), []) def test_every_reference_is_in_configgen(self): + # Or in a patch Batocera applies to an emulator's own path code. + from scraper.batocera_launchers import PATH_PATCHES + for record in self.config["launchers"]: - self.assertTrue(record["source_ref"].startswith(f"{CONFIGGEN}/"), record) + self.assertTrue( + record["source_ref"].startswith((f"{CONFIGGEN}/", *PATH_PATCHES)), record + ) def test_the_schema_describes_what_the_scraper_writes(self): fields = {f.name for f in dataclasses.fields(Launcher)} diff --git a/tests/test_launchers_knulli.py b/tests/test_launchers_knulli.py index 62062446a..2657c8dd5 100644 --- a/tests/test_launchers_knulli.py +++ b/tests/test_launchers_knulli.py @@ -623,8 +623,12 @@ class PlatformFile(unittest.TestCase): def test_every_reference_is_in_knulli_code(self): for record in self.config["launchers"]: + # Or in Batocera's patches, read from KNULLI's batocera submodule. self.assertTrue( - record["source_ref"].startswith((f"{kl.CONFIGGEN}/", kl.PICO8_INSTALLER)), record + record["source_ref"].startswith(( + f"{kl.CONFIGGEN}/", kl.PICO8_INSTALLER, + *(f"{kl.BATOCERA_SUBMODULE}/{patch}" for patch in kl.PATH_PATCHES), + )), record ) def test_every_emulator_is_one_knulli_names(self):