From de0bdbd84e4cd49f1fb027b4d462601ab798cda8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:35:04 +0100 Subject: [PATCH 1/4] fix: drop defaults the build has no package for KNULLI's configgen-defaults.yml picks suyu for the Switch, which its es_systems.yml does not offer and no package of its tree, its batocera submodule or its buildroot builds: KNULLI does not ship suyu. A default es_systems.yml does not offer now counts only when a package of the build's trees has that name, or when its launcher runs a program from the BIOS folder (Batocera's lexaloffle). Batocera's lists are unchanged by the rule. suyu leaves KNULLI's cores, and the XDG_DATA_HOME record that served it goes. --- platforms/knulli.yml | 10 --- scripts/scraper/batocera_scraper.py | 99 ++++++++++++++++++++++++++--- scripts/scraper/knulli_launchers.py | 36 ----------- scripts/scraper/knulli_scraper.py | 30 +++++++-- tests/test_launcher_paths.py | 92 +++++++++++++++++++-------- 5 files changed, 180 insertions(+), 87 deletions(-) diff --git a/platforms/knulli.yml b/platforms/knulli.yml index 2ac54eaff..846328981 100644 --- a/platforms/knulli.yml +++ b/platforms/knulli.yml @@ -186,7 +186,6 @@ cores: - superbroswar - supermodel - supermodel-legacy -- suyu - swanstation - taradino - tgbdual @@ -4970,7 +4969,6 @@ standalone_cores: - stella - supermodel - supermodel-legacy -- suyu - taradino - theforceengine - thextech @@ -6143,14 +6141,6 @@ 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 diff --git a/scripts/scraper/batocera_scraper.py b/scripts/scraper/batocera_scraper.py index f1e3fcdcc..399d8bd43 100644 --- a/scripts/scraper/batocera_scraper.py +++ b/scripts/scraper/batocera_scraper.py @@ -14,6 +14,7 @@ import re import sys import urllib.error import urllib.request +from collections.abc import Callable, Iterable from pathlib import Path @@ -168,7 +169,8 @@ def es_systems_libretro_systems(text: str, dropped: set[str]) -> dict[str, list[ def es_systems_by_system( - es_systems_text: str, defaults_text: str, dropped: set[str] + es_systems_text: str, defaults_text: str, dropped: set[str], + built: Callable[[str], bool] | None = None, ) -> dict[str, list[str]]: """system -> the cores es_systems.yml offers it, in the file's order. @@ -195,6 +197,10 @@ def es_systems_by_system( default = defaults.get(system) if isinstance(default, dict): emulator, core = default.get("emulator"), default.get("core") + if (built is not None and emulator and emulator != "libretro" + and str(emulator) not in names and not built(str(emulator))): + # A default the build lacks (configgen_cores). + emulator = core = None if emulator and emulator != "libretro": names.append(str(emulator)) if core: @@ -472,7 +478,8 @@ def platform_systems( def configgen_cores( - defaults_text: str, es_systems_text: str, dropped: set[str] + defaults_text: str, es_systems_text: str, dropped: set[str], + built: Callable[[str], bool] | None = None, ) -> tuple[list[str], list[str], list[str]]: """Core names, standalone cores and dual-mode cores of a configgen build. @@ -481,9 +488,16 @@ def configgen_cores( dual-mode ones those es_systems.yml also offers under libretro (dolphin for GameCube). `dropped` names what es_systems.yml files under an emulator without being one. + + A default es_systems.yml does not offer names an emulator the build may + lack: KNULLI's defaults pick suyu for the Switch, and no package of its + tree, its batocera submodule or its buildroot builds suyu. `built` says + whether the build has a standalone emulator (packages_and_programs); a + default it answers no for is not run, nor its core. """ data = yaml_load_upstream(defaults_text) cores, standalone = es_systems_emulators(es_systems_text) + offered = set(cores) libretro = es_systems_libretro(es_systems_text) if not cores: raise RuntimeError("es_systems.yml names no emulator") @@ -492,6 +506,9 @@ def configgen_cores( continue emulator = cfg.get("emulator", "") core = cfg.get("core", "") + if (built is not None and emulator and emulator != "libretro" + and emulator not in offered and not built(emulator)): + continue if core: cores.add(core) if emulator and emulator != "libretro": @@ -502,6 +519,57 @@ def configgen_cores( return sorted(cores - dropped), sorted(standalone - dropped), sorted(dual) +BATOCERA_REPO = "batocera-linux/batocera.linux" + + +def package_names(paths: Iterable[str]) -> set[str]: + """The buildroot packages a tree defines: each folder holding a Config.in.""" + return { + path.rsplit("/", 2)[-2] + for path in paths + if path.endswith("/Config.in") and path.count("/") >= 1 + } + + +def repo_tree(repo: str, ref: str) -> list[str]: + """Every path of a GitHub repository at a revision.""" + url = f"https://api.github.com/repos/{repo}/git/trees/{ref}?recursive=1" + req = urllib.request.Request(url, headers=github_headers()) + with urllib.request.urlopen(req, timeout=60) as resp: + found = json.loads(resp.read().decode("utf-8")) + if found.get("truncated"): + raise RuntimeError(f"{url}: tree truncated") + return [entry["path"] for entry in found.get("tree", [])] + + +def submodule_revision(repo: str, path: str, ref: str) -> str: + """The commit a repository's submodule at `path` pins at a revision.""" + url = f"https://api.github.com/repos/{repo}/contents/{path}?ref={ref}" + req = urllib.request.Request(url, headers=github_headers()) + with urllib.request.urlopen(req, timeout=30) as resp: + found = json.loads(resp.read().decode("utf-8")) + if not isinstance(found, dict) or found.get("type") != "submodule" or not found.get("sha"): + raise RuntimeError(f"{url}: {path} is not a submodule") + return str(found["sha"]) + + +def packages_and_programs(packages: set[str], launchers: list[dict]) -> Callable[[str], bool]: + """Whether a build has a standalone emulator. + + It does when a package of that name exists, or when its launcher runs a + program the user puts in the BIOS folder (Batocera's lexaloffle runs + /userdata/bios/pico-8/pico8), which no package builds. + """ + supplied = { + name.strip() + for record in launchers + if record.get("passes_to") == "exec" + and str(record.get("search_dir") or "").startswith("/userdata/bios") + for name in str(record.get("emulator", "")).split(",") + } + return lambda emulator: emulator in packages or emulator in supplied + + class Scraper(BaseScraper): """Scraper for batocera-systems Python dict.""" @@ -509,12 +577,26 @@ class Scraper(BaseScraper): self.tag = fetch_stable_tag() super().__init__(url=SOURCE_URL.format(tag=self.tag)) - def _fetch_cores(self) -> tuple[list[str], list[str], list[str]]: - """Core names, standalone cores and dual-mode cores at the pinned tag.""" + def _fetch_cores( + self, launchers: list[dict] | None = None + ) -> tuple[list[str], list[str], list[str]]: + """Core names, standalone cores and dual-mode cores at the pinned tag. + + A default es_systems.yml does not offer counts when a package of + Batocera's tree or its buildroot builds it, or when its launcher + runs a program from the BIOS folder. + """ + built = None + if launchers is not None: + paths = repo_tree(BATOCERA_REPO, self.tag) + buildroot = submodule_revision(BATOCERA_REPO, "buildroot", self.tag) + paths += repo_tree("batocera-linux/buildroot", buildroot) + built = packages_and_programs(package_names(paths), launchers) return configgen_cores( self._fetch_text(CONFIGGEN_DEFAULTS_URL.format(tag=self.tag)), self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)), _not_cores(), + built, ) def _fetch_text(self, url: str) -> str: @@ -548,7 +630,10 @@ class Scraper(BaseScraper): SYSTEM_SLUG_MAP, ) - cores, standalone, dual = self._fetch_cores() + launchers = extract_launchers( + lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}") + ) + cores, standalone, dual = self._fetch_cores(launchers) libretro_systems = es_systems_libretro_systems( self._fetch_text(ES_SYSTEMS_URL.format(tag=self.tag)), _not_cores() ) @@ -570,9 +655,7 @@ class Scraper(BaseScraper): result["dual_mode_cores"] = dual # The systems each libretro core runs for: its save folders. result["libretro_systems"] = libretro_systems - result["launchers"] = extract_launchers( - lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}") - ) + result["launchers"] = launchers return result diff --git a/scripts/scraper/knulli_launchers.py b/scripts/scraper/knulli_launchers.py index be94eb13a..dbf5354b4 100644 --- a/scripts/scraper/knulli_launchers.py +++ b/scripts/scraper/knulli_launchers.py @@ -48,7 +48,6 @@ try: ) from .configgen_launchers import ( Launcher, - _calls, LauncherShapeError, Source, _assigned_name, @@ -74,7 +73,6 @@ except ImportError: ) from configgen_launchers import ( # type: ignore[no-redef] Launcher, - _calls, LauncherShapeError, Source, _assigned_name, @@ -1062,39 +1060,6 @@ EXTRACTORS: tuple[Callable[[Configgen], list[Launcher]], ...] = ( ) -# 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" @@ -1114,7 +1079,6 @@ def extract_launchers( 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( diff --git a/scripts/scraper/knulli_scraper.py b/scripts/scraper/knulli_scraper.py index abae8d987..80068ebd9 100644 --- a/scripts/scraper/knulli_scraper.py +++ b/scripts/scraper/knulli_scraper.py @@ -41,8 +41,12 @@ from .batocera_scraper import ( es_systems_by_system, es_systems_libretro_systems, extract_systems_dict, + package_names, + packages_and_programs, platform_systems, + repo_tree, requirements_from_systems, + submodule_revision, ) from .es_emulators import attach_system_cores from .knulli_launchers import BATOCERA_SUBMODULE, extract_launchers @@ -123,11 +127,27 @@ class Scraper(BaseScraper): defaults = self._fetch_text(CONFIGGEN_DEFAULTS_TEMPLATE.format(ref=self.tag)) es_systems = self._fetch_text(ES_SYSTEMS_TEMPLATE.format(ref=self.tag)) dropped = _not_cores("batocera") | _not_cores(PLATFORM_NAME) - cores, standalone, dual = configgen_cores(defaults, es_systems, dropped) + revision = self.submodule_revision() + launchers = extract_launchers( + lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path), + lambda path: self._fetch_text(_BATOCERA_RAW.format(ref=revision) + path), + ) + # The packages KNULLI builds come from its own tree, its batocera + # submodule and its buildroot submodule: a default no package of + # them builds (suyu) is not run. + paths = repo_tree(REPO, self.tag) + paths += repo_tree("batocera-linux/batocera.linux", revision) + paths += repo_tree( + "knulli-cfw/buildroot", submodule_revision(REPO, "buildroot", self.tag) + ) + built = packages_and_programs(package_names(paths), launchers) + cores, standalone, dual = configgen_cores(defaults, es_systems, dropped, built) # Each system's own list (psx, es_systems.yml:640-653: libretro # pcsx_rearmed, swanstation, mednafen_psx, then duckstation and # duckstation-legacy). - attach_system_cores(systems, es_systems_by_system(es_systems, defaults, dropped)) + attach_system_cores( + systems, es_systems_by_system(es_systems, defaults, dropped, built) + ) result = { "platform": "KNULLI", "version": self.tag, @@ -146,11 +166,7 @@ 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(_BATOCERA_RAW.format(ref=revision) + path), - ) + result["launchers"] = launchers return result diff --git a/tests/test_launcher_paths.py b/tests/test_launcher_paths.py index 2f0cb8f1e..0b978bb76 100644 --- a/tests/test_launcher_paths.py +++ b/tests/test_launcher_paths.py @@ -21,7 +21,6 @@ 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, @@ -128,35 +127,27 @@ class AFolderTheGeneratorEnters(unittest.TestCase): 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]} +class AVariableADeclaredFolderIsSpelledFrom(unittest.TestCase): + def test_a_data_folder_outside_the_tree_binds_outside(self): + record = {"emulator": "emu", "build": "standalone", "kind": "passes_path", + "names": [], "search_dir": "/userdata/saves/switch", + "passes_to": "environment XDG_DATA_HOME", "source_ref": "x.py:1"} + config = {"base_destination": "bios", "standalone_cores": ["emu"], "launchers": [record]} found = standalone_bindings( - {"suyu"}, ["$XDG_DATA_HOME/suyu", "SuyuPath::KeysDir = keys"], - handed_directories(config), + {"emu"}, ["$XDG_DATA_HOME/emu"], handed_directories(config) ) self.assertEqual(found, {"": ("outside", "")}) + def test_a_data_folder_in_the_tree_binds_below_it(self): + record = {"emulator": "emu", "build": "standalone", "kind": "passes_path", + "names": [], "search_dir": "/userdata/bios/data", + "passes_to": "environment XDG_DATA_HOME", "source_ref": "x.py:1"} + config = {"base_destination": "bios", "standalone_cores": ["emu"], "launchers": [record]} + found = standalone_bindings( + {"emu"}, ["$XDG_DATA_HOME/emu"], handed_directories(config) + ) + self.assertEqual(found, {"": ("", "data/emu")}) + class APortableUserFolder(unittest.TestCase): CITRON = """ @@ -213,5 +204,54 @@ class APortableUserFolder(unittest.TestCase): self.assertEqual(found, {"": ("emulators", "citron/user")}) + +class ADefaultTheBuildLacksIsNotRun(unittest.TestCase): + """KNULLI's configgen-defaults.yml picks suyu for the Switch, which its + es_systems.yml does not offer and no package builds.""" + + ES = textwrap.dedent("""\ + switch: + emulators: + citron: + citron: { requireAnyOf: [BR2_PACKAGE_CITRON] } + voxatron: + emulators: {} + """) + DEFAULTS = textwrap.dedent("""\ + switch: + emulator: suyu + core: suyu + voxatron: + emulator: lexaloffle + core: vox_official + kodi: + emulator: kodi + core: kodi + """) + LAUNCHERS = [{"emulator": "lexaloffle", "kind": "fixed_name", "passes_to": "exec", + "search_dir": "/userdata/bios"}] + + def test_only_what_a_package_or_the_user_supplies_is_run(self): + from scraper import batocera_scraper as bs + + paths = ["package/batocera/emulators/citron/Config.in", "package/kodi/Config.in"] + built = bs.packages_and_programs(bs.package_names(paths), self.LAUNCHERS) + cores, standalone, _dual = bs.configgen_cores(self.DEFAULTS, self.ES, set(), built) + self.assertEqual(sorted(cores), ["citron", "kodi", "vox_official"]) + self.assertIn("lexaloffle", standalone) + self.assertNotIn("suyu", standalone) + by_system = bs.es_systems_by_system(self.ES, self.DEFAULTS, set(), built) + self.assertEqual(by_system["switch"], ["citron"]) + # Without the packages, every default counts, as before. + self.assertIn("suyu", bs.configgen_cores(self.DEFAULTS, self.ES, set())[0]) + + def test_the_real_knulli_list_names_no_suyu(self): + import yaml + + config = yaml.safe_load((REPO_ROOT / "platforms" / "knulli.yml").read_text()) + self.assertNotIn("suyu", config["cores"]) + self.assertNotIn("suyu", config.get("standalone_cores") or []) + + if __name__ == "__main__": unittest.main() From 9d955bb980eda35d395bb2294c0a7c2997c38829 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:53:35 +0100 Subject: [PATCH 2/4] feat: add the shadps4 system fonts The font library opens each SST font by its dumped or PS4 name in fonts/, fonts/font/ and fonts/font2/ and falls back to the Noto fonts it embeds, so the 38 entries are optional with an HLE fallback. --- emulators/shadps4.yml | 274 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 1 deletion(-) diff --git a/emulators/shadps4.yml b/emulators/shadps4.yml index 46db72954..81dc1b868 100644 --- a/emulators/shadps4.yml +++ b/emulators/shadps4.yml @@ -39,7 +39,12 @@ notes: | priority over the global sys_modules/ directory. System fonts are mounted from fonts/font/ (/preinst/common/font on real - PS4) and fonts/font2/ (/system/common/font2 on real PS4). These are SST + PS4) and fonts/font2/ (/system/common/font2 on real PS4) + (src/emulator.cpp:639-661). The font library opens each by its dumped name + or its PS4 name in fonts/, then fonts/font/, then fonts/font2/ + (src/core/libraries/font/font_internal.cpp:391-468), and falls back to the + Noto fonts it embeds (font_internal.cpp:119-127). The entries below put + each in fonts/font/, which both the mount and the library read. These are SST (Sony Standard Text) typeface files in OTF format. The emulator mounts the directories wholesale, and games load individual fonts by name. Without fonts, the emulator warns "No dumped system fonts, expect missing text @@ -239,3 +244,270 @@ files: description: "PS4 XML parser" note: "No HLE implementation. The module is stubbed when absent." source_ref: "src/core/libraries/sysmodule/sysmodule_internal.cpp:251" + + - name: "SST-Light.otf" + aliases: ["SST-EU-ROMAN-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:419, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Roman.otf" + aliases: ["SST-EU-ROMAN.OTF", "SST-EU-ROMAN-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:420, 422, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Medium.otf" + aliases: ["SST-EU-ROMAN-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:421, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Italic.otf" + aliases: ["SST-EU-ROMAN-I.OTF", "SST-ITALIC-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Italic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:423, 427, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-Bold.otf" + aliases: ["SST-EU-ROMAN-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:424, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-BoldItalic.otf" + aliases: ["SST-EU-ROMAN-BI.OTF", "SST-ITALIC-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-BoldItalic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:425, 429, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-LightItalic.otf" + aliases: ["SST-ITALIC-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-LightItalic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:426, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SST-MediumItalic.otf" + aliases: ["SST-ITALIC-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SST-MediumItalic.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:428, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTTypewriter-Roman.otf" + aliases: ["SST-TYPEWRITER-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTTypewriter-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:430, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTTypewriter-Bd.otf" + aliases: ["SST-TYPEWRITER-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTTypewriter-Bd.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:431, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTJpPro-Regular.otf" + aliases: ["SST-JPPRO-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTJpPro-Regular.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:432, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTJpPro-Bold.otf" + aliases: ["SST-JPPRO-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTJpPro-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:433, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "DFHEI5-SONY.ttf" + aliases: ["SST-CNGB-HEI-R.TTF"] + required: false + hle_fallback: true + path: "fonts/font/DFHEI5-SONY.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:434, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTAribStdB24-Regular.ttf" + aliases: ["SST-ARIB-STD-B24-R.TTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTAribStdB24-Regular.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:435, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Roman.otf" + aliases: ["SST-ARABIC-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:436, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Light.otf" + aliases: ["SST-ARABIC-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:437, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Medium.otf" + aliases: ["SST-ARABIC-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:438, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTArabic-Bold.otf" + aliases: ["SST-ARABIC-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTArabic-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:439, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCEPS4Yoongd-Light.otf" + aliases: ["SCE-EXT-HANGUL-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCEPS4Yoongd-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:440, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCEPS4Yoongd-Medium.otf" + aliases: ["SCE-EXT-HANGUL-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCEPS4Yoongd-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:441, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCEPS4Yoongd-Bold.otf" + aliases: ["SCE-EXT-HANGUL-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCEPS4Yoongd-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:442, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "e046323ms.ttf" + aliases: ["SSTCC-SERIF-MONO.OTF"] + required: false + hle_fallback: true + path: "fonts/font/e046323ms.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:443, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "e046323ts.ttf" + aliases: ["SSTCC-SERIF.OTF"] + required: false + hle_fallback: true + path: "fonts/font/e046323ts.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:444, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "n023055ms.ttf" + aliases: ["SSTCC-SANSSERIF-MONO.OTF"] + required: false + hle_fallback: true + path: "fonts/font/n023055ms.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:445, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "n023055ts.ttf" + aliases: ["SSTCC-SANSSERIF.OTF"] + required: false + hle_fallback: true + path: "fonts/font/n023055ts.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:446, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "d013013ds.ttf" + aliases: ["SSTCC-CUSUAL.OTF"] + required: false + hle_fallback: true + path: "fonts/font/d013013ds.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:447, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "k006004ds.ttf" + aliases: ["SSTCC-CURSIVE.OTF"] + required: false + hle_fallback: true + path: "fonts/font/k006004ds.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:448, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "c041056ts.ttf" + aliases: ["SSTCC-SMALLCAPITAL.OTF"] + required: false + hle_fallback: true + path: "fonts/font/c041056ts.ttf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:449, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCE-RDC-R-JPN.otf" + aliases: ["SCE-JP-CATTLEYA-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCE-RDC-R-JPN.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:450, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SCE-RDC-B-JPN.otf" + aliases: ["SCE-JP-CATTLEYA-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SCE-RDC-B-JPN.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:451, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Light.otf" + aliases: ["SST-THAI-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:452, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Roman.otf" + aliases: ["SST-THAI-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:453, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Medium.otf" + aliases: ["SST-THAI-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:454, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTThai-Bold.otf" + aliases: ["SST-THAI-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTThai-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:455, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Light.otf" + aliases: ["SST-VIETNAMESE-L.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Light.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:456, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Roman.otf" + aliases: ["SST-VIETNAMESE-R.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Roman.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:457, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Medium.otf" + aliases: ["SST-VIETNAMESE-M.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Medium.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:458, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" + - name: "SSTVietnamese-Bold.otf" + aliases: ["SST-VIETNAMESE-B.OTF"] + required: false + hle_fallback: true + path: "fonts/font/SSTVietnamese-Bold.otf" + description: "PS4 system font" + source_ref: "src/core/libraries/font/font_internal.cpp:459, src/core/libraries/font/font_internal.cpp:391-415, src/emulator.cpp:639-661" From 7e22c3811369375b1062ad386ab14a6ac6012704 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:53:49 +0100 Subject: [PATCH 3/4] feat: place shadps4 files below the configs root Batocera and KNULLI start shadPS4 from CONFIGS/shadps4, so its user folder sits below /userdata/system/configs. The scrapers read that root from batoceraPaths and write configs_root with its source_ref. The manifest names it configs_directory, and install.py honours it through the same checks as roms_directory: character rules, at most two ups, the ancestor match and the lexical bound. --- install.ps1 | 2 +- install.py | 131 ++++++++++++++------------ install.sh | 2 +- install/launchers/index.json | 16 ++-- platforms/batocera.yml | 4 + platforms/knulli.yml | 4 + schemas/install-manifest.schema.json | 39 ++++++-- schemas/platform.schema.json | 24 +++++ scripts/common.py | 12 ++- scripts/launchers.py | 31 +++++- scripts/scraper/batocera_launchers.py | 26 +++++ scripts/scraper/batocera_scraper.py | 5 +- scripts/scraper/knulli_scraper.py | 5 + scripts/verify.py | 1 + tests/test_install.py | 10 +- tests/test_launcher_paths.py | 40 ++++++++ tests/test_launcher_placement.py | 14 +-- 17 files changed, 275 insertions(+), 91 deletions(-) diff --git a/install.ps1 b/install.ps1 index fcab5282b..615aac053 100644 --- a/install.ps1 +++ b/install.ps1 @@ -16,7 +16,7 @@ # and Invoke-WebRequest throws on a cut transfer before the hash is read: # the site's build rewrites this line there (scripts/sitebootstrap.py). $mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." - $defaultInstallSha256 = "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c" + $defaultInstallSha256 = "fc575b6b4ac26a12d6d2b28aaa5ffe7dcfbbd57ead80e719e2ce57b8af00c6a8" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 29335e500..f44329b0d 100644 --- a/install.py +++ b/install.py @@ -143,6 +143,7 @@ OMISSION_REASONS = { "no_save_system": "no system runs the core", "outside_bios_folder": "read outside the BIOS folder", "no_roms_directory": "no roms folder", + "no_configs_directory": "no configs folder", "no_emulators_directory": "no emulator folder", } # What a file is when it is not firmware, as the manifest's category field @@ -3528,14 +3529,20 @@ def _roms_relation(value: object, field: str) -> tuple[int, PurePosixPath]: return ups, PurePosixPath(*names) -def _roms_root(bios_path: Path, from_bios: str) -> Path | None: - """The roms root of a BIOS folder, on the host's own path type. +# The roots a list places files below by their place relative to the BIOS +# folder: the roms root (ROCKNIX's /storage/roms) and the frontend's +# configs folder (Batocera's /userdata/system/configs). +RELATIVE_ROOTS = ("roms", "configs") + + +def _relative_root(bios_path: Path, from_bios: str, root: str = "roms") -> Path | None: + """A relative root of a BIOS folder, on the host's own path type. The BIOS folder's ancestor at the depth the relation climbs, then the named folders below it. None when the joined and normalized path is anything else. """ - ups, names = _roms_relation(from_bios, "roms_directory.from_bios") + ups, names = _roms_relation(from_bios, f"{root}_directory.from_bios") if ups > len(bios_path.parents): return None ancestor = bios_path if not ups else bios_path.parents[ups - 1] @@ -3671,8 +3678,9 @@ class SavePlace(NamedTuple): # BIOS folder -> where the files read below its frontend's save directory go, # set once the save directory is known (save_directory_for). _SAVE_DIRS: dict[Path, SavePlace] = {} -# BIOS folder -> the platform's roms folder, set by place_roms_entries. -_ROMS_DIRS: dict[Path, Path] = {} +# (BIOS folder, root) -> the platform's roms or configs folder, set by +# place_relative_entries. +_RELATIVE_DIRS: dict[tuple[Path, str], Path] = {} # (BIOS folder, emulator key) -> the emulator folder, set by # place_emulator_entries. _EMULATOR_DIRS: dict[tuple[Path, str], Path] = {} @@ -3686,11 +3694,11 @@ def _entry_place(root: Path, entry: dict) -> tuple[Path, object]: core's own folder where the frontend sorts saves by core. Any other is relative to the BIOS folder. """ - if entry.get("root") == "roms": - roms = _ROMS_DIRS.get(root) - if roms is None: - raise ValueError(f"no roms folder known for {entry.get('dest')}") - return roms, entry.get("dest") + if entry.get("root") in RELATIVE_ROOTS: + folder = _RELATIVE_DIRS.get((root, entry["root"])) + if folder is None: + raise ValueError(f"no {entry['root']} folder known for {entry.get('dest')}") + return folder, entry.get("dest") if entry.get("root") == "emulators": key, _, rest = str(entry.get("dest")).partition("/") folder = _EMULATOR_DIRS.get((root, key)) @@ -3731,7 +3739,7 @@ def _entry_key(entry: dict) -> str: if root == "emulator": # A path in one emulator's own folder: two emulators read two files. return f"emulator\0{','.join(entry.get('cores') or [])}\0{dest}" - return f"{root}\0{dest}" if root in ("save", "roms", "emulators") else dest + return f"{root}\0{dest}" if root in ("save", "emulators", *RELATIVE_ROOTS) else dest def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: @@ -3739,7 +3747,8 @@ def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: the emulator's own folder, outside the BIOS tree), and core_folder one folder name of a save entry.""" root = entry.get("root") - if root not in (None, "save", "roms", "emulators") and not (omitted and root == "emulator"): + if root not in (None, "save", "emulators", *RELATIVE_ROOTS) and not ( + omitted and root == "emulator"): raise ValueError(f"invalid root for {dest}") if root == "emulators" and len(PurePosixPath(dest).parts) < 2: raise ValueError(f"an emulators entry names its folder first: {dest}") @@ -3752,24 +3761,27 @@ def _check_root(entry: dict, dest: str, omitted: bool = False) -> None: raise ValueError(f"core_folder must be one folder name for {dest}") -def _check_roms_directory(data: dict) -> None: - """The manifest's roms_directory, which every installable root: roms entry needs.""" - layout = data.get("roms_directory") +def _check_relative_directories(data: dict) -> None: + """The manifest's roms_directory and configs_directory, which every + installable root: roms or root: configs entry needs.""" installable = [ entry for field in ("files", "case_variants") for entry in data.get(field) or [] if isinstance(entry, dict) ] - if layout is None: - if any(entry.get("root") == "roms" for entry in installable): - raise ValueError("entries below the roms folder and no roms_directory") - return - if ( - not isinstance(layout, dict) - or set(layout) != {"bios_folder", "from_bios"} - or len(_safe_relative_path(layout["bios_folder"], "roms_directory.bios_folder").parts) != 1 - ): - raise ValueError("invalid roms_directory") - _roms_relation(layout["from_bios"], "roms_directory.from_bios") + for root in RELATIVE_ROOTS: + field = f"{root}_directory" + layout = data.get(field) + if layout is None: + if any(entry.get("root") == root for entry in installable): + raise ValueError(f"entries below the {root} folder and no {field}") + continue + if ( + not isinstance(layout, dict) + or set(layout) != {"bios_folder", "from_bios"} + or len(_safe_relative_path(layout["bios_folder"], f"{field}.bios_folder").parts) != 1 + ): + raise ValueError(f"invalid {field}") + _roms_relation(layout["from_bios"], f"{field}.from_bios") def _check_emulators_directory(data: dict) -> None: @@ -3969,7 +3981,7 @@ def _check_entry( # Below a roms or emulator folder a colon has no use: refused outright. dest = str(_safe_relative_path( entry.get("dest"), f"{where}.dest", - colons=entry.get("root") not in ("roms", "emulators"), + colons=entry.get("root") not in ("emulators", *RELATIVE_ROOTS), )) key = _entry_key({**entry, "dest": dest}) if key in seen_destinations: @@ -4109,7 +4121,7 @@ def _validate_manifest(data: object, plat: str) -> dict: for index, entry in enumerate(data_dirs): _check_data_directory(entry, f"data_directories[{index}]") _check_save_directory(data) - _check_roms_directory(data) + _check_relative_directories(data) _check_emulators_directory(data) declared_total_files = data.get("total_files") @@ -5376,39 +5388,41 @@ def place_save_entries( return kept, unplaced -def place_roms_entries( +def place_relative_entries( files: list[dict], bios_path: Path, manifest: dict, display: str, say: bool = True, ) -> tuple[list[dict], list[dict]]: - """Find the roms folder for the entries a launcher reads below it. + """Find the roms and configs folders for the entries read below them. - The list gives the roms root relative to the BIOS folder, as the OS's - own source places them (ROCKNIX's /storage/roms holds bios/): a BIOS - folder of the platform's name puts the roms root there. Any other is a - folder this run cannot relate to the roms, and the entries are left out - with the reason. + The list gives each root relative to the BIOS folder, as the OS's own + source places them (ROCKNIX's /storage/roms holds bios/, Batocera's + configs folder is ../system/configs): a BIOS folder of the platform's + name puts the root there. Any other is a folder this run cannot relate + to the root, and the entries are left out with the reason. """ - below = [entry for entry in files if entry.get("root") == "roms"] - if not below: - return files, [] - layout = manifest.get("roms_directory") or {} - roms = ( - _roms_root(bios_path, layout["from_bios"]) - if layout and bios_path.name == layout.get("bios_folder") else None - ) - if roms is not None: - _ROMS_DIRS[bios_path] = roms - return files, [] - if say: - print(f" {_plural(len(below), 'file')} read from {display}'s roms folder left out: " - f"{bios_path} is not its {layout.get('bios_folder') or 'BIOS'} folder.") - kept = [entry for entry in files if entry.get("root") != "roms"] - unplaced = [ - {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, - "system": "", "systems": list(entry.get("systems") or []), "required": False, - "reason": "no_roms_directory", "cores": entry.get("cores"), "root": "roms"} - for entry in below - ] - return kept, unplaced + unplaced: list[dict] = [] + for root in RELATIVE_ROOTS: + below = [entry for entry in files if entry.get("root") == root] + if not below: + continue + layout = manifest.get(f"{root}_directory") or {} + folder = ( + _relative_root(bios_path, layout["from_bios"], root) + if layout and bios_path.name == layout.get("bios_folder") else None + ) + if folder is not None: + _RELATIVE_DIRS[(bios_path, root)] = folder + continue + if say: + print(f" {_plural(len(below), 'file')} read from {display}'s {root} folder left " + f"out: {bios_path} is not its {layout.get('bios_folder') or 'BIOS'} folder.") + files = [entry for entry in files if entry.get("root") != root] + unplaced += [ + {"dest": entry["dest"], "name": PurePosixPath(entry["dest"]).name, + "system": "", "systems": list(entry.get("systems") or []), "required": False, + "reason": f"no_{root}_directory", "cores": entry.get("cores"), "root": root} + for entry in below + ] + return files, unplaced def _config_keys(path: Path) -> set[str]: @@ -16098,7 +16112,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: display = display_name(plat_name, manifest) files, unplaced = place_save_entries( files, bios_path, manifest, args.save_dir, display, say=False) - files, beyond = place_roms_entries(files, bios_path, manifest, display, say=not listing) + files, beyond = place_relative_entries( + files, bios_path, manifest, display, say=not listing) files, elsewhere = place_emulator_entries( files, bios_path, manifest, display, say=not listing) unplaced = [*unplaced, *beyond, *elsewhere] diff --git a/install.sh b/install.sh index b5e0bc583..acd8fb1d8 100755 --- a/install.sh +++ b/install.sh @@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins # curl or wget stop on a cut transfer before the hash is read: the site's # build rewrites this line there (scripts/sitebootstrap.py). MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that." -DEFAULT_INSTALL_SHA256="e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c" +DEFAULT_INSTALL_SHA256="fc575b6b4ac26a12d6d2b28aaa5ffe7dcfbbd57ead80e719e2ce57b8af00c6a8" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index bf070ce79..119df24a0 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1142840, - "sha256": "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c", + "size": 1143647, + "sha256": "fc575b6b4ac26a12d6d2b28aaa5ffe7dcfbbd57ead80e719e2ce57b8af00c6a8", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1196465, - "sha256": "7ef6e947618ffe51b95823b28ce2a064c6eddf2b7fb77e2066f9c2d279176f03" + "size": 1197272, + "sha256": "d37af192ab41abfd9e2f0d82c67fcc4ca3252e864a91582b4f6b23f840bc00aa" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1142840, - "sha256": "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c", + "size": 1143647, + "sha256": "fc575b6b4ac26a12d6d2b28aaa5ffe7dcfbbd57ead80e719e2ce57b8af00c6a8", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1142840, - "sha256": "e076488b9766904d7f859e0463e55214fed9386dcaaca362b04a371a820c6c7c", + "size": 1143647, + "sha256": "fc575b6b4ac26a12d6d2b28aaa5ffe7dcfbbd57ead80e719e2ce57b8af00c6a8", "executable": false }, { diff --git a/platforms/batocera.yml b/platforms/batocera.yml index 968db3d35..312f75f09 100644 --- a/platforms/batocera.yml +++ b/platforms/batocera.yml @@ -6598,3 +6598,7 @@ launchers: \ / KEYS_DIR" fails_when_missing: false source_ref: "package/batocera/emulators/citron/002-adjust-paths.patch:9-10" +configs_root: + path: /userdata/system/configs + from_bios: ../system/configs + source_ref: "package/batocera/core/batocera-configgen/configgen/configgen/batoceraPaths.py:14, 19-20, 25" diff --git a/platforms/knulli.yml b/platforms/knulli.yml index 846328981..51edf4c42 100644 --- a/platforms/knulli.yml +++ b/platforms/knulli.yml @@ -6150,3 +6150,7 @@ launchers: \ / KEYS_DIR" fails_when_missing: false source_ref: "batocera/package/batocera/emulators/citron/002-adjust-paths.patch:9-10" +configs_root: + path: /userdata/system/configs + from_bios: ../system/configs + source_ref: "package/system/knulli-configgen/configgen/configgen/batoceraPaths.py:14, 19-20, 25" diff --git a/schemas/install-manifest.schema.json b/schemas/install-manifest.schema.json index 76d7512f5..9c3da6fe5 100644 --- a/schemas/install-manifest.schema.json +++ b/schemas/install-manifest.schema.json @@ -207,7 +207,8 @@ "no_save_system", "outside_bios_folder", "no_roms_directory", - "no_emulators_directory" + "no_emulators_directory", + "no_configs_directory" ], "description": "Why the file is left out, as a code. enumDescriptions gives the meaning of each code, in the order of enum.", "enumDescriptions": [ @@ -222,7 +223,8 @@ "The launcher gives each system its own save folder and no system of the platform runs the core.", "The platform runs the emulator from a folder outside the BIOS tree and links none of the BIOS tree where the emulator reads this file (root: emulator).", "The platform's launcher runs the file from its roms folder (root: roms) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps in it.", - "The platform's launcher runs the file from one of its emulator folders (root: emulators) and the run cannot place it there: the BIOS folder is not the one beside the platform's launcher config, or that config sets the folder." + "The platform's launcher runs the file from one of its emulator folders (root: emulators) and the run cannot place it there: the BIOS folder is not the one beside the platform's launcher config, or that config sets the folder.", + "The platform's launcher starts the emulator from a folder of its configs folder (root: configs) and the run cannot tell where that folder is: the BIOS folder is not the one the platform keeps beside it." ] }, "category": { @@ -367,12 +369,13 @@ } }, "root": { - "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). emulators: dest is the emulator folder's key then the path in it (emulators_directory). Absent for a file of the BIOS tree.", + "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. emulator: dest is the path the emulator reads in its own folder, which the platform keeps outside the BIOS tree (reason outside_bios_folder). roms: dest is relative to the platform's roms folder (roms_directory). emulators: dest is the emulator folder's key then the path in it (emulators_directory). configs: dest is relative to the frontend's configs folder (configs_directory). Absent for a file of the BIOS tree.", "enum": [ "save", "emulator", "roms", - "emulators" + "emulators", + "configs" ] }, "core_folder": { @@ -581,6 +584,27 @@ "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" } } + }, + "configs_directory": { + "description": "Where the frontend keeps its emulators' configs, present when an entry carries root: configs. from_bios is that folder relative to the BIOS folder, as the platform's own path code builds it (Batocera's batoceraPaths CONFIGS, ../system/configs), with the rules of roms_directory.from_bios. It is followed only from a BIOS folder named bios_folder; any other leaves root: configs entries out (no_configs_directory).", + "type": "object", + "additionalProperties": false, + "required": [ + "bios_folder", + "from_bios" + ], + "properties": { + "bios_folder": { + "type": "string", + "minLength": 1, + "pattern": "^[^/\\\\]+$" + }, + "from_bios": { + "type": "string", + "pattern": "^(\\.|\\.\\.(/\\.\\.)?(/[^/\\\\:.][^/\\\\:]*)*)$", + "maxLength": 1024 + } + } } }, "$defs": { @@ -599,7 +623,7 @@ "dest": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$", - "description": "Path below base_destination, below the save directory when root is save, below the roms folder when root is roms, or in an emulator folder when root is emulators (its key first)." + "description": "Path below base_destination, below the save directory when root is save, below the roms folder when root is roms, below the configs folder when root is configs, or in an emulator folder when root is emulators (its key first)." }, "sha1": { "type": "string", @@ -1082,11 +1106,12 @@ } }, "root": { - "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). emulators: dest is the key of an emulator folder of the platform, then the path in it, located by emulators_directory (RetroBat's emulators/pico8). Absent for a file of the BIOS tree.", + "description": "save: dest is relative to the save directory the frontend hands the core (RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY), not to the BIOS directory. roms: dest is relative to the platform's roms folder, which roms_directory locates (a file its launcher runs from there, ROCKNIX's roms/pico-8). emulators: dest is the key of an emulator folder of the platform, then the path in it, located by emulators_directory (RetroBat's emulators/pico8). configs: dest is relative to the frontend's configs folder, which configs_directory locates (Batocera starts shadPS4 from configs/shadps4). Absent for a file of the BIOS tree.", "enum": [ "save", "roms", - "emulators" + "emulators", + "configs" ] }, "core_folder": { diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index 0d409de16..1bed464e1 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -188,6 +188,30 @@ } } } + }, + "configs_root": { + "description": "Where the frontend keeps its emulators' configs, as its own path code builds it (Batocera's batoceraPaths CONFIGS): path as the OS spells it, from_bios relative to the BIOS folder. A launcher that starts an emulator from a folder below it (shadPS4's configs/shadps4) places that emulator's user folder there, and the install manifest writes such a file as root: configs.", + "type": "object", + "additionalProperties": false, + "required": [ + "path", + "from_bios", + "source_ref" + ], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "from_bios": { + "type": "string", + "minLength": 1 + }, + "source_ref": { + "type": "string", + "minLength": 1 + } + } } }, "$defs": { diff --git a/scripts/common.py b/scripts/common.py index 9522a8618..8f58c4708 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -2825,7 +2825,8 @@ OUTSIDE_BIOS_FOLDER = "outside_bios_folder" # describes. Each is located by the registry entry _directory. ROMS_ROOT = "roms" EMULATORS_ROOT = "emulators" -OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT) +CONFIGS_ROOT = "configs" +OUTER_ROOTS = (ROMS_ROOT, EMULATORS_ROOT, CONFIGS_ROOT) def outer_layout(config: dict, root: str) -> dict | None: @@ -2858,6 +2859,15 @@ def outer_layout(config: dict, root: str) -> dict | None: return {"path": str(path), "install_prefix": "" if inside == "." else inside} if root == EMULATORS_ROOT: return config.get("emulators_directory") or None + if root == CONFIGS_ROOT: + # The frontend's configs folder, as its path code builds it + # (configs_root, Batocera's batoceraPaths CONFIGS). + found = config.get("configs_root") or {} + base = str(config.get("base_destination") or "") + if not (found.get("path") and found.get("from_bios") and base): + return None + return {"path": str(found["path"]), "from_bios": str(found["from_bios"]), + "bios_folder": base} return None diff --git a/scripts/launchers.py b/scripts/launchers.py index b79cb5678..aabac759c 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -331,6 +331,8 @@ BIOS_TREE = "" ROMS_TREE = "roms" # The emulator folders of a platform whose registry describes them. EMULATORS_TREE = "emulators" +# The frontend's configs folder of a platform whose file records it. +CONFIGS_TREE = "configs" # The tree a platform's list installs into when it starts above the BIOS # folder: a plain dest, with no prefix. INSTALL_TREE = "install" @@ -433,12 +435,18 @@ def handed_directories(config: dict) -> list[HandedDirectory]: source_ref=str(record.get("source_ref", "")), roms_place=None if folder is not None else ( _roms_place(config, folder_in_roms(raw, config)) + or _configs_place(folder_in_configs(raw, config)) or _emulators_place(folder_in_emulators(raw, config)) ), )) return found +def _configs_place(path: str | None) -> Place | None: + """A folder below the configs root, as a place of the configs tree.""" + return None if path is None else (CONFIGS_TREE, path) + + 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) @@ -458,6 +466,18 @@ def _roms_place(config: dict, path: str | None) -> Place | None: return (ROMS_TREE, path) +def folder_in_configs(directory: str, config: dict) -> str | None: + """A directory relative to the frontend's configs folder, None outside it. + + Only a platform whose file records its configs root has one + (``configs_root.path``): Batocera's /userdata/system/configs, where its + generators start shadPS4 from configs/shadps4. + """ + from common import CONFIGS_ROOT # noqa: PLC0415 + + return folder_in_root(directory, config, CONFIGS_ROOT) + + def folder_in_roms(directory: str, config: dict) -> str | None: """A directory relative to the platform's roms directory, None outside it. @@ -465,9 +485,16 @@ def folder_in_roms(directory: str, config: dict) -> str | None: (``roms_root.path``): ROCKNIX's /storage/roms, which holds its BIOS folder and the folders its launchers run programs from. """ - from common import ROMS_ROOT, outer_layout # noqa: PLC0415 + from common import ROMS_ROOT # noqa: PLC0415 - roms = str((outer_layout(config, ROMS_ROOT) or {}).get("path") or "").rstrip("/") + return folder_in_root(directory, config, ROMS_ROOT) + + +def folder_in_root(directory: str, config: dict, root: str) -> str | None: + """A directory relative to an outer root's path, None outside it.""" + from common import outer_layout # noqa: PLC0415 + + roms = str((outer_layout(config, root) or {}).get("path") or "").rstrip("/") directory = directory.strip().rstrip("/") if not roms or not directory.startswith(roms + "/"): return None diff --git a/scripts/scraper/batocera_launchers.py b/scripts/scraper/batocera_launchers.py index aa4222ac9..45f84bd60 100644 --- a/scripts/scraper/batocera_launchers.py +++ b/scripts/scraper/batocera_launchers.py @@ -97,6 +97,32 @@ def path_constants(source: Source) -> dict[str, str]: return constants +def configs_root(cg: Configgen) -> dict[str, str]: + """Where configgen keeps the emulators' configs, relative to the BIOS folder. + + batoceraPaths' CONFIGS (/userdata/system/configs), the folder a generator + starts an emulator from (shadPS4's configs/shadps4), with the lines that + build it and BIOS. + """ + import posixpath # noqa: PLC0415 + + src = cg.source(f"{cg.generators.rsplit('/', 1)[0]}/batoceraPaths.py") + if "CONFIGS" not in cg.paths: + src.fail("no CONFIGS path constant") + wanted: set[str] = {"CONFIGS", "BIOS"} + spans = [] + for node in reversed(src.tree.body): + name = _assigned_name(node) + if name in wanted: + spans.append((node.lineno, node.end_lineno or node.lineno)) + wanted |= {n.id for n in ast.walk(node.value) if isinstance(n, ast.Name)} # type: ignore[union-attr] + return { + "path": cg.paths["CONFIGS"], + "from_bios": posixpath.relpath(cg.paths["CONFIGS"], cg.bios), + "source_ref": src.ref(spans), + } + + def _path_value(node: ast.AST | None, constants: dict[str, str]) -> str | None: if isinstance(node, ast.Name): return constants.get(node.id) diff --git a/scripts/scraper/batocera_scraper.py b/scripts/scraper/batocera_scraper.py index 399d8bd43..8ef10db16 100644 --- a/scripts/scraper/batocera_scraper.py +++ b/scripts/scraper/batocera_scraper.py @@ -29,7 +29,7 @@ from .base_scraper import ( md5_field, requirement_entry, ) -from .batocera_launchers import extract_launchers +from .batocera_launchers import Configgen, configs_root, extract_launchers PLATFORM_NAME = "batocera" @@ -656,6 +656,9 @@ class Scraper(BaseScraper): # The systems each libretro core runs for: its save folders. result["libretro_systems"] = libretro_systems result["launchers"] = launchers + result["configs_root"] = configs_root( + Configgen(lambda path: self._fetch_text(f"{_RAW_BASE}/{self.tag}/{path}")) + ) return result diff --git a/scripts/scraper/knulli_scraper.py b/scripts/scraper/knulli_scraper.py index 80068ebd9..763c25ec4 100644 --- a/scripts/scraper/knulli_scraper.py +++ b/scripts/scraper/knulli_scraper.py @@ -49,7 +49,9 @@ from .batocera_scraper import ( submodule_revision, ) from .es_emulators import attach_system_cores +from .batocera_launchers import configs_root from .knulli_launchers import BATOCERA_SUBMODULE, extract_launchers +from .knulli_launchers import configgen as knulli_configgen PLATFORM_NAME = "knulli" @@ -167,6 +169,9 @@ class Scraper(BaseScraper): # Batocera's does: the systems each core runs for are its save folders. result["libretro_systems"] = es_systems_libretro_systems(es_systems, dropped) result["launchers"] = launchers + result["configs_root"] = configs_root( + knulli_configgen(lambda path: self._fetch_text(_RAW.format(ref=self.tag) + path)) + ) return result diff --git a/scripts/verify.py b/scripts/verify.py index 7a05a1be9..beaeb9b6b 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -980,6 +980,7 @@ _UNPLACED_REASONS = { "outside_bios_folder": "read in a folder the platform keeps outside the BIOS tree", "no_roms_directory": "read below a roms folder the platform's registry does not name", "no_emulators_directory": "read in an emulator folder the platform's registry does not describe", + "no_configs_directory": "read below a configs folder the platform's file does not record", "no_platform_slug": "no platform slug for its system", "below_platform_folder": "below the folder the platform lists", "no_save_directory": "read below a save directory the platform keeps in no fixed place", diff --git a/tests/test_install.py b/tests/test_install.py index 9ca17cabb..3f475b166 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1150,7 +1150,7 @@ class OuterRootsAreUntrusted(unittest.TestCase): def test_unsafe_from_bios_is_refused(self): for value in (*self.UNSAFE, "../../../x", "../x/..", "x", "./x", "/x", "..x", None): with self.subTest(from_bios=value), self.assertRaises(ValueError): - install._check_roms_directory({"roms_directory": {**self.ROMS, "from_bios": value}}) + install._check_relative_directories({"roms_directory": {**self.ROMS, "from_bios": value}}) def test_the_relations_the_rom_folders_files_use_are_accepted(self): bios = Path("/card/system/bios") @@ -1161,13 +1161,13 @@ class OuterRootsAreUntrusted(unittest.TestCase): } for value, root in expected.items(): with self.subTest(from_bios=value): - install._check_roms_directory({"roms_directory": {**self.ROMS, "from_bios": value}}) - self.assertEqual(install._roms_root(bios, value), root) + install._check_relative_directories({"roms_directory": {**self.ROMS, "from_bios": value}}) + self.assertEqual(install._relative_root(bios, value), root) self.assertEqual(install.MAX_ROMS_UPS, 2) def test_a_list_without_a_roms_root_leaves_the_files_out(self): files = [{"dest": "pico-8/pico8_64", "root": "roms", "cores": ["x"]}] - kept, left = install.place_roms_entries(files, Path("/card/roms/bios"), {}, "x", say=False) + kept, left = install.place_relative_entries(files, Path("/card/roms/bios"), {}, "x", say=False) self.assertEqual((kept, [o["reason"] for o in left]), ([], ["no_roms_directory"])) def test_unsafe_emulator_folders_are_refused(self): @@ -1192,7 +1192,7 @@ class OuterRootsAreUntrusted(unittest.TestCase): with unittest.mock.patch.object(install.os, "name", host): for value in ("../C:x", "..\\x", "../x:", "../Roms:x"): with self.subTest(host=host, from_bios=value), self.assertRaises(ValueError): - install._check_roms_directory( + install._check_relative_directories( {"roms_directory": {**self.ROMS, "from_bios": value}}) for value in ("C:x/{key}", "x:/{key}", "emu:x/{key}"): layout = {**self.EMULATORS, "searched": [value], "default": value} diff --git a/tests/test_launcher_paths.py b/tests/test_launcher_paths.py index 0b978bb76..261de19b8 100644 --- a/tests/test_launcher_paths.py +++ b/tests/test_launcher_paths.py @@ -253,5 +253,45 @@ class ADefaultTheBuildLacksIsNotRun(unittest.TestCase): self.assertNotIn("suyu", config.get("standalone_cores") or []) + +class AUserFolderInTheConfigsFolder(unittest.TestCase): + """Batocera starts shadPS4 from /userdata/system/configs/shadps4, a folder + of the configs root its batoceraPaths builds (../system/configs).""" + + def test_the_sys_modules_go_below_the_configs_root(self): + from common import load_emulator_profiles, load_platform_config, platform_layouts + from common import standalone_set + + profile = load_emulator_profiles(str(REPO_ROOT / "emulators"))["shadps4"] + for platform in ("batocera", "knulli"): + config = load_platform_config(platform, str(REPO_ROOT / "platforms")) + ((view, _standalone),) = platform_layouts("shadps4", profile, standalone_set(config)) + with self.subTest(platform=platform): + self.assertEqual( + {(f.get("root"), f["standalone_path"].rsplit("/", 1)[0]) for f in view["files"]}, + {("configs", "shadps4/user/sys_modules"), ("configs", "shadps4/user/fonts/font")}, + ) + + def test_the_installer_follows_from_bios_to_the_configs_folder(self): + import install + + manifest = {"configs_directory": {"bios_folder": "bios", "from_bios": "../system/configs"}} + install._check_relative_directories(manifest) + files = [{"dest": "shadps4/user/sys_modules/libSceNgs2.sprx", "root": "configs"}] + bios = Path("/userdata/bios") + kept, left = install.place_relative_entries(files, bios, manifest, "x", say=False) + self.assertEqual((len(kept), left), (1, [])) + self.assertEqual( + install._entry_place(bios, kept[0])[0], Path("/userdata/system/configs") + ) + kept, left = install.place_relative_entries( + files, Path("/elsewhere/firmware"), manifest, "x", say=False) + self.assertEqual((kept, [o["reason"] for o in left]), ([], ["no_configs_directory"])) + for value in ("../C:x", "../../../x", "../system/../x"): + with self.subTest(from_bios=value), self.assertRaises(ValueError): + install._check_relative_directories( + {"configs_directory": {"bios_folder": "bios", "from_bios": value}}) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_launcher_placement.py b/tests/test_launcher_placement.py index 2e23f5c75..cf0e2a55e 100644 --- a/tests/test_launcher_placement.py +++ b/tests/test_launcher_placement.py @@ -383,10 +383,10 @@ class AProgramRunFromItsFolder(unittest.TestCase): import install manifest = self.manifest("romsplat") - install._check_roms_directory(manifest) + install._check_relative_directories(manifest) files = list(manifest["files"]) card = self.root / "card" / "roms" / "bios" - kept, left = install.place_roms_entries(files, card, manifest, "romsplat", say=False) + kept, left = install.place_relative_entries(files, card, manifest, "romsplat", say=False) self.assertEqual((len(kept), left), (2, [])) self.assertEqual( install._entry_path(card, kept[0]), @@ -394,22 +394,22 @@ class AProgramRunFromItsFolder(unittest.TestCase): if install._entry_path(card, kept[0]).is_absolute() else card.parent / kept[0]["dest"], ) other = self.root / "elsewhere" - kept, left = install.place_roms_entries(files, other, manifest, "romsplat", say=False) + kept, left = install.place_relative_entries(files, other, manifest, "romsplat", say=False) self.assertEqual(kept, []) self.assertEqual({o["reason"] for o in left}, {"no_roms_directory"}) with self.assertRaises(ValueError): - install._check_roms_directory({"files": files}) + install._check_relative_directories({"files": files}) # A roms root beside the BIOS folder (Batocera's ../roms). beside = {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": "../roms"}} - install._check_roms_directory(beside) + install._check_relative_directories(beside) disk = self.root / "userdata" / "bios" - install.place_roms_entries(files, disk, beside, "romsplat", say=False) + install.place_relative_entries(files, disk, beside, "romsplat", say=False) self.assertEqual(install._entry_place(disk, kept_entry := files[0])[0], self.root / "userdata" / "roms") self.assertEqual(kept_entry["root"], "roms") for climbing in ("roms", "../roms/..", "/storage/roms"): with self.subTest(from_bios=climbing), self.assertRaises(ValueError): - install._check_roms_directory( + install._check_relative_directories( {**manifest, "roms_directory": {"bios_folder": "bios", "from_bios": climbing}} ) From 111a93440fe96559233b3d72da918f5d0288a2c7 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:54:00 +0100 Subject: [PATCH 4/4] feat: bind retrobat switch portable folders RetroBat's generators create user/ beside yuzu.exe and eden.exe and portable/ beside Ryujinx.exe, which the emulators read in place of %APPDATA% (PORTABLE_DIR, Ryujinx AppDataManager.cs:12, 60). Their keys now go below emulators//. --- emulators/eden.yml | 1 + emulators/ryujinx.yml | 1 + emulators/yuzu.yml | 1 + platforms/retrobat.yml | 29 ++++++++++++- scripts/scraper/retrobat_launchers.py | 60 ++++++++++++++++++--------- tests/test_launcher_paths.py | 34 ++++++++++++--- 6 files changed, 100 insertions(+), 26 deletions(-) diff --git a/emulators/eden.yml b/emulators/eden.yml index 2f8b58c8e..80ba11f1f 100644 --- a/emulators/eden.yml +++ b/emulators/eden.yml @@ -63,6 +63,7 @@ files: standalone_directories: - "$XDG_DATA_HOME/eden" - "$HOME/.local/share/eden" + - "PORTABLE_DIR" data_root: windows: path: "%APPDATA%/eden" diff --git a/emulators/ryujinx.yml b/emulators/ryujinx.yml index 74a9b5b43..aa3d4df08 100644 --- a/emulators/ryujinx.yml +++ b/emulators/ryujinx.yml @@ -19,6 +19,7 @@ mode: standalone standalone_directories: - "$XDG_CONFIG_HOME/Ryujinx" - "$HOME/.config/Ryujinx" + - "DefaultPortableDir" data_root: windows: path: "%APPDATA%/Ryujinx" diff --git a/emulators/yuzu.yml b/emulators/yuzu.yml index 77ec2ef35..7aafef9c5 100644 --- a/emulators/yuzu.yml +++ b/emulators/yuzu.yml @@ -59,6 +59,7 @@ files: standalone_directories: - "$XDG_DATA_HOME/yuzu" - "$HOME/.local/share/yuzu" + - "PORTABLE_DIR" data_root: windows: path: "%APPDATA%/yuzu" diff --git a/platforms/retrobat.yml b/platforms/retrobat.yml index 7da5f851f..1a89d0f1c 100644 --- a/platforms/retrobat.yml +++ b/platforms/retrobat.yml @@ -4971,17 +4971,42 @@ launchers: kind: passes_path names: [] search_dir: "/user" - passes_to: PORTABLE_DIR, the user folder beside citron.exe + passes_to: PORTABLE_DIR, the portable folder beside citron.exe fails_when_missing: false source_ref: "emulatorLauncher/Generators/Citron.Generator.cs:25, 29-30" +- emulator: eden + build: standalone + kind: passes_path + names: [] + search_dir: "/user" + passes_to: PORTABLE_DIR, the portable folder beside eden.exe + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Eden.Generator.cs:28, 35-36" +- emulator: ryujinx + build: standalone + kind: passes_path + names: [] + search_dir: "/portable" + passes_to: DefaultPortableDir, the portable folder beside Ryujinx.exe + fails_when_missing: false + depends_on_option: unless saves/switch/ryujinx/portable exists, passed with -r + source_ref: "emulatorLauncher/Generators/Ryujinx.Generator.cs:31, 73-75" - emulator: shadps4 build: standalone kind: passes_path names: [] search_dir: "/user" - passes_to: PORTABLE_DIR, the user folder beside shadPS4.exe + passes_to: PORTABLE_DIR, the portable folder beside shadPS4.exe fails_when_missing: false source_ref: "emulatorLauncher/Generators/ShadPS4.Generator.cs:29, 158-160" +- emulator: yuzu + build: standalone + kind: passes_path + names: [] + search_dir: "/user" + passes_to: PORTABLE_DIR, the portable folder beside yuzu.exe + fails_when_missing: false + source_ref: "emulatorLauncher/Generators/Yuzu.Generator.cs:24, 33-34" - emulator: bizhawk build: standalone kind: names_searched diff --git a/scripts/scraper/retrobat_launchers.py b/scripts/scraper/retrobat_launchers.py index 45ce221b2..6a8f178f6 100644 --- a/scripts/scraper/retrobat_launchers.py +++ b/scripts/scraper/retrobat_launchers.py @@ -32,6 +32,7 @@ SOURCE_FILES = ( f"{GENERATORS}/DosBoxPure.Generator.cs", f"{GENERATORS}/DosBoxStaging.Generator.cs", f"{GENERATORS}/Duckstation.Generator.cs", + f"{GENERATORS}/Eden.Generator.cs", f"{GENERATORS}/Eka2l1.Generator.cs", f"{GENERATORS}/Fbneo.Generator.cs", f"{GENERATORS}/Flycast.Generator.cs", @@ -57,6 +58,7 @@ SOURCE_FILES = ( f"{GENERATORS}/Project64.Generator.cs", f"{GENERATORS}/Raine.Generator.cs", f"{GENERATORS}/Rpcs3.Generator.cs", + f"{GENERATORS}/Ryujinx.Generator.cs", f"{GENERATORS}/SSF.Generator.cs", f"{GENERATORS}/ScummVm.Generator.cs", f"{GENERATORS}/ShadPS4.Generator.cs", @@ -68,6 +70,7 @@ SOURCE_FILES = ( f"{GENERATORS}/Xroar.Generator.cs", f"{GENERATORS}/Yabasanshiro.Generator.cs", f"{GENERATORS}/Ymir.Generator.cs", + f"{GENERATORS}/Yuzu.Generator.cs", f"{GENERATORS}/mGBA.Generator.cs", FEATURES, ) @@ -2982,44 +2985,63 @@ 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. +# Generators that start an emulator with a portable data folder beside its +# binary: (generator, emulator key, binary, the code's name for the folder). PORTABLE_USERS = ( - ("Citron.Generator.cs", "citron", "citron.exe"), - ("ShadPS4.Generator.cs", "shadps4", "shadPS4.exe"), + ("Citron.Generator.cs", "citron", "citron.exe", "PORTABLE_DIR"), + ("Eden.Generator.cs", "eden", "eden.exe", "PORTABLE_DIR"), + ("Ryujinx.Generator.cs", "ryujinx", "Ryujinx.exe", "DefaultPortableDir"), + ("ShadPS4.Generator.cs", "shadps4", "shadPS4.exe", "PORTABLE_DIR"), + ("Yuzu.Generator.cs", "yuzu", "yuzu.exe", "PORTABLE_DIR"), ) _EMULATOR_FOLDER = ( r"GetEmulatorPathAndExe\(emulator, out string path, out string exe, \"[^\"]+\"\)" - r"|string path = AppConfig\.GetFullPath\(emulator\);" + r"|string path = AppConfig\.GetFullPath\((?:emulator(?:\.Replace\(\"-\", \" \"\))?" + r"|\"\w+\")\);" +) +_PORTABLE_FOLDER = r'string (userFolder|setupPath) = Path\.Combine\(path, "(\w+)"\);' +# Ryujinx's generator hands --root-data-dir saves/switch/ryujinx/portable +# instead when that folder exists. +_ALTERNATE_ROOT = ( + r'string portablePath = Path\.Combine\(AppConfig\.GetFullPath\("saves"\), "switch", ' + r'"ryujinx", "portable"\);' ) def portable_users(sources: dict[str, Source]) -> list[Record]: - """The portable user folder a generator creates beside the binary. + """The portable data folder a generator gives an emulator beside its 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%. + The emulator folder comes from AppConfig.GetFullPath, and the generator + creates or configures /user (/portable for Ryujinx) + before launching: the emulator then keeps its data there (keys/, + sys_modules/, system/) in place of %APPDATA%. """ records = [] - for name, emulator, binary in PORTABLE_USERS: + for name, emulator, binary, handle 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() - ) + folder = src.find(_PORTABLE_FOLDER, "portable folder") + spans = [src.lines(found.start(), found.end()), src.lines(folder.start(), folder.end())] + if folder.group(1) == "userFolder": + create = src.find( + r"Directory\.CreateDirectory\(userFolder\)", "user folder creation", folder.end() + ) + spans[-1] = src.lines(folder.start(), create.end()) + depends = None + alternate = re.search(_ALTERNATE_ROOT, src.text) + if alternate: + spans.append(src.lines(alternate.start(), alternate.end())) + depends = "unless saves/switch/ryujinx/portable exists, passed with -r" 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}", + source_ref((src.path, spans)), + search_dir=f'/{folder.group(2)}', + passes_to=f"{handle}, the portable folder beside {binary}", fails_when_missing=False, + depends_on_option=depends, )) return records diff --git a/tests/test_launcher_paths.py b/tests/test_launcher_paths.py index 261de19b8..e2b8a6866 100644 --- a/tests/test_launcher_paths.py +++ b/tests/test_launcher_paths.py @@ -176,12 +176,28 @@ class APortableUserFolder(unittest.TestCase): } """ + RYUJINX = """ + public override System.Diagnostics.ProcessStartInfo Generate(string system, string emulator, string core, string rom, string playersControllers, ScreenResolution resolution) + { + string path = AppConfig.GetFullPath("ryujinx"); + string portablePath = Path.Combine(AppConfig.GetFullPath("saves"), "switch", "ryujinx", "portable"); + string setupPath = Path.Combine(path, "portable"); + } + """ + def sources(self) -> dict[str, rl.Source]: + def source(ref: str, text: str) -> rl.Source: + return rl.Source(ref, textwrap.dedent(text).lstrip("\n")) + + def yuzu_like(exe: str) -> str: + return self.CITRON.replace("citron.exe", exe) + 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")), + f"{rl.GENERATORS}/Citron.Generator.cs": source("c", self.CITRON), + f"{rl.GENERATORS}/Eden.Generator.cs": source("e", yuzu_like("eden.exe")), + f"{rl.GENERATORS}/Ryujinx.Generator.cs": source("r", self.RYUJINX), + f"{rl.GENERATORS}/ShadPS4.Generator.cs": source("s", self.SHADPS4), + f"{rl.GENERATORS}/Yuzu.Generator.cs": source("y", yuzu_like("yuzu.exe")), } def test_each_generator_gives_its_user_folder(self): @@ -189,7 +205,15 @@ class APortableUserFolder(unittest.TestCase): 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")], + ("eden", '/user', "e:3, 7-8"), + ("ryujinx", '/portable', "r:3-5"), + ("shadps4", '/user', "s:3, 8-10"), + ("yuzu", '/user', "y:3, 7-8")], + ) + ryujinx = found[2] + self.assertEqual(ryujinx["passes_to"], "DefaultPortableDir, the portable folder beside Ryujinx.exe") + self.assertEqual( + ryujinx["depends_on_option"], "unless saves/switch/ryujinx/portable exists, passed with -r" ) def test_the_user_folder_binds_below_the_emulator_folder(self):