From 8114f48e302891b9b29f2899ea763c65279237f5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:53:48 +0100 Subject: [PATCH] feat: fill a core option default by model RetroDECK sets LRPS2's pcsx2_bios to scph10000.bin. The record names the frontend, so the core is the one whose entry reads that setting. Its entry is a placeholder, and the name spells a model: the held dump the catalogues describe as SCPH-10000 goes in under that name in the entry's directory, primaries first, then the earliest Redump revision (ps2-0100j-20000117). --- scripts/cross_reference.py | 37 +++++++++-- scripts/launchers.py | 116 +++++++++++++++++++++++++++++++++-- tests/test_launcher_layer.py | 35 +++++++++++ 3 files changed, 179 insertions(+), 9 deletions(-) diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index 8d9d69e22..faa0e90ba 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -50,7 +50,15 @@ from common import ( select_emulator_profiles, ) from hashing import parse_hash_list -from launchers import LauncherLayer, declared_destinations, held_record, set_by, suffixed_stem +from launchers import ( + LauncherLayer, + catalogued_model_files, + declared_destinations, + held_record, + set_by, + setting_readers, + suffixed_stem, +) from launchers import _directories as launcher_directories from launchers import _names as launcher_names from validation import ( @@ -1180,6 +1188,11 @@ def launcher_reads_not_held( for part in str(record.get("emulator", "")).split(",") for key in LauncherLayer._profiles_named(part.strip(), profiles, every_profile) }) + if not readers: + # RetroDECK names its frontend and the core option names the core. + readers = setting_readers( + str(record.get("passes_to") or ""), profiles, every_profile + ) if emulator and emulator not in readers: continue folds = bool(record.get("case_insensitive") or config.get("case_insensitive_fs")) @@ -1187,12 +1200,26 @@ def launcher_reads_not_held( if record.get("kind") == "copies" and not launcher_directories(record, base): continue passes_to = str(record.get("passes_to") or "") - if passes_to and any( - set_by(entry, passes_to) and holder._entry_held(entry) + setting_entries = [ + entry for key in readers for entry in profiles[key].get("files") or [] - ): + if passes_to and set_by(entry, passes_to) + ] + if any(holder._entry_held(entry) for entry in setting_entries): continue + # A placeholder read from the setting takes the held dump the + # catalogues name after the model the launcher's name spells. + by_model = { + name + for name in names + for entry in setting_entries + if "<" in str(entry.get("name", "")) + and any( + name_match_size_ok(entry, record_.get("size")) + for record_ in catalogued_model_files(name, db) + ) + } if record.get("kind") == "names_searched" and any( (found := holder.held(name)) and (found[1] or folds) for name in names ): @@ -1216,7 +1243,7 @@ def launcher_reads_not_held( } for name in names: found = holder.held(name) - if found and (found[1] or folds): + if found and (found[1] or folds) or name in by_model: continue key = name.casefold() if folds else name row = by_key.get(key) diff --git a/scripts/launchers.py b/scripts/launchers.py index 26b8e1e2b..9dcd9bbca 100644 --- a/scripts/launchers.py +++ b/scripts/launchers.py @@ -112,6 +112,49 @@ def suffixed_stem(name: str) -> str | None: return stem if "." in stem else None +_MODEL = re.compile(r"\b([A-Z]{2,5})-?(\d{3,6}[A-Z]?)\b") + + +def catalogued_model_files(name: str, db: dict) -> list[dict]: + """Held files a dump catalogue describes as the model a launcher name spells. + + RetroDECK sets LRPS2's pcsx2_bios option to scph10000.bin, a name no code + hashes: the SCPH-10000 is the model whose BIOS it means. Redump and TOSEC + describe two revisions for it. Primaries come before .variants copies, + then the Redump name, which carries the version (ps2-0100j before + ps2-0101j), then the No-Intro and TOSEC names. + """ + stem = name.rsplit("/", 1)[-1].rsplit(".", 1)[0].casefold() + if not re.fullmatch(r"[a-z]{2,5}\d{3,6}[a-z]?", stem): + return [] + found = [] + for record in db.get("files", {}).values(): + catalogues = record.get("provenance") or {} + models = { + (a + b).casefold() + for entry in catalogues.values() + for a, b in _MODEL.findall(str(entry.get("description") or "")) + } + if stem in models: + names = [ + str((catalogues.get(source) or {}).get("name") or "") + for source in ("redump", "no-intro", "tosec") + ] + found.append(("/.variants/" in str(record.get("path", "")), names, record)) + return [record for *_key, record in sorted(found, key=lambda f: (f[0], f[1]))] + + +def setting_readers(passes_to: str, profiles: dict, keys: Iterable[str]) -> list[str]: + """The profiles with an entry read from the setting a launcher writes.""" + if not passes_to: + return [] + return sorted( + key for key in keys + if (profile := profiles.get(key)) is not None + and any(set_by(entry, passes_to) for entry in profile.get("files") or []) + ) + + def set_by(entry: dict, passes_to: str) -> bool: """Whether a launcher hands its file to the setting a profile entry is read from.""" key = str(entry.get("config_key") or "") @@ -451,12 +494,30 @@ class LauncherLayer: self._by_name_folded.setdefault(name.lower(), []).extend(sha1s) self._files = db.get("files", {}) self._owners: dict[tuple[str, bool], list[LauncherPlace]] = {} + self._placeholders: dict[tuple[str, bool], list[tuple[dict, dict]]] = {} relevant = set(relevant) for place in self.places: standalone = place.build == "standalone" - for emulator in (e.strip() for e in place.emulator.split(",")): - for key in self._profiles_named(emulator, profiles, relevant): - self._owners.setdefault((key, standalone), []).append(place) + owners = { + key + for emulator in (e.strip() for e in place.emulator.split(",")) + for key in self._profiles_named(emulator, profiles, relevant) + } + # RetroDECK names its frontend, retroarch, and the core option it + # writes, pcsx2_bios, names the core. + if not owners: + owners = set(setting_readers(place.passes_to, profiles, relevant)) + for key in sorted(owners): + self._owners.setdefault((key, standalone), []).append(place) + # A placeholder entry stands for every file a scan finds and + # is no gap, so the report never offers it: the setting a + # launcher writes still reads one file of it. + for entry in profiles[key].get("files") or []: + if "<" in str(entry.get("name", "")) and set_by(entry, place.passes_to): + self._placeholders.setdefault((key, standalone), []).append( + (entry, _placeholder_report(key, profiles[key], entry)) + ) + self._db = db @staticmethod def _profiles_named(name: str, profiles: dict, relevant: set[str]) -> list[str]: @@ -568,6 +629,8 @@ class LauncherLayer: by_layout: dict[tuple[str, bool], list[tuple[dict, dict]]] = {} for emulator, standalone, entry, report in candidates: by_layout.setdefault((emulator, standalone), []).append((entry, report)) + for layout, members in self._placeholders.items(): + by_layout.setdefault(layout, []).extend(members) placed: set[str] = set() for (emulator, standalone), members in sorted(by_layout.items()): for place in self.places_for(emulator, standalone): @@ -627,7 +690,31 @@ class LauncherLayer: ): return True if not place.anchored: - continue + # A setting holds a name the core resolves in the directory + # of the entry read from it: LRPS2 joins pcsx2_bios under + # pcsx2/bios. + setting = [ + (candidate, report) for candidate, report in matched + if set_by(candidate.entry, place.passes_to) + ] + directory = next( + ( + str(c.entry.get("path") or "").rpartition("/")[0] + for c, _report in setting + if "/" in str(c.entry.get("path") or "") + ), + None, + ) + if directory is None: + continue + path = f"{directory}/{name}" + if place.fold(path) in folded_reported: + return True + source = self._content_for(place, name, key, setting, resolve_held) + if source is None or place.fold(path) in placed: + continue + placed.add(place.fold(path)) + return _launcher_entry(source[0], name, path, source[1], place) # The emulator as this platform installs it brings the file: the # place is filled, nothing is packed (ROCKNIX's xemu hdd image). if any(key in candidate.names and report.get("bundled") @@ -676,9 +763,30 @@ class LauncherLayer: record = resolve_held(report) if record is not None: return report, record + for candidate, report in matched: + if key not in candidate.names or not set_by(candidate.entry, place.passes_to): + continue + for record in catalogued_model_files(name, self._db): + if name_match_size_ok(candidate.entry, record.get("size")): + return report, record return None +def _placeholder_report(key: str, profile: dict, entry: dict) -> dict: + """The report entry of a placeholder, which the gap report never builds.""" + return { + "profile": key, + "emulator": profile.get("emulator", key), + "name": entry.get("name", ""), + "path": entry.get("path") or entry.get("name", ""), + "system": entry.get("system"), + "systems": profile.get("systems"), + "required": entry.get("required", False), + "category": entry.get("category"), + "in_repo": False, + } + + def _launcher_entry( report: dict, name: str, path: str, record: dict, place: LauncherPlace ) -> dict: diff --git a/tests/test_launcher_layer.py b/tests/test_launcher_layer.py index 63ee14324..f82a55997 100644 --- a/tests/test_launcher_layer.py +++ b/tests/test_launcher_layer.py @@ -289,6 +289,41 @@ class LauncherLayerTest(unittest.TestCase): self.assertEqual(added["kick.A500.rom"]["md5"], _md5(CONTENT["core.bin"])) self.assertIsNone(suffixed_stem("scph1001.bin")) + def test_a_core_option_names_the_dump_of_a_model(self) -> None: + """RetroDECK sets LRPS2's pcsx2_bios to scph10000.bin. LRPS2's entry + is a placeholder for every BIOS its scan finds, and the catalogues + name two revisions of the SCPH-10000: the earlier goes in.""" + older, newer = b"S" * 64, b"T" * 64 + for data, catalogue in ((newer, "ps2-0101j-20000217.bin"), + (older, "ps2-0100j-20000117.bin")): + sha1 = hashlib.sha1(data).hexdigest() + path = os.path.join(self.bios_dir, catalogue) + with open(path, "wb") as fh: + fh.write(data) + self.db["files"][sha1] = { + "path": path, "name": catalogue, "md5": _md5(data), "sha1": sha1, + "size": len(data), "crc32": "", + "provenance": {"redump": {"name": catalogue, + "description": "SCPH-10000 (Version 5.0)"}}, + } + profile = { + "emulator": "OptEmu", "type": "libretro", "systems": ["test-sys"], + "files": [{"name": ".bin", "path": "ps2/bios/.bin", "required": True, + "min_size": 32, "max_size": 64, "validation": ["size"], + "config_key": "emu_bios"}], + } + with open(os.path.join(self.emulators_dir, "optemu.yml"), "w") as fh: + yaml.dump(profile, fh) + self.config["launchers"].append({ + "emulator": "retroarch", "build": "libretro", "kind": "core_option", + "names": ["scph10000.bin"], "passes_to": "core option emu_bios", + "source_ref": "core-options.cfg:515", + }) + _emulator_profiles_cache.clear() + self.profiles = load_emulator_profiles(self.emulators_dir) + added = {u["path"]: u for u in self._report() if u.get("launcher")} + self.assertEqual(added["ps2/bios/scph10000.bin"]["md5"], _md5(older)) + def test_the_default_name_a_launcher_replaces_is_not_packed(self) -> None: """EmuDeck passes Complex_4627v1.03.bin to xemu, which then never opens Complex_4627.bin: the pack carried both, one read by nobody."""