From 6810e8df9a395bfa7e88a298627c61ca1b1bce7a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:12:24 +0200 Subject: [PATCH] fix: never prove a file by another emulator's tree lrps2 names no hash for its GameIndex.yaml, and the only full-tail copy was ARMSX2's: it counted as path proof and replaced the file RetroArch pins on desktop targets, which have no ARMSX2. Upstream PCSX2 took ARMSX2's patches.zip by name the same way. A copy in another emulator's tree now proves nothing at any tail length, and LRPS2's own GameIndex comes from the tree the buildbot publishes. --- emulators/lrps2.yml | 18 ++++---- scripts/common.py | 18 ++++++-- tests/test_audit_regressions.py | 77 +++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/emulators/lrps2.yml b/emulators/lrps2.yml index 8d449e27..f2f2f8b3 100644 --- a/emulators/lrps2.yml +++ b/emulators/lrps2.yml @@ -19,6 +19,10 @@ bios_directory: "pcsx2/bios/" resources_directory: "pcsx2/resources/" notes: | + pcsx2/resources/GameIndex.yaml, the per-game patch, settings override and + compatibility database, is the one the buildbot publishes in LRPS2.zip (the + lrps2 data directory). An OSD warning is shown if it is missing; some games + do not boot without it. Hard fork of PCSX2 (pre-Qt, 1.7-dev era) ported to libretro. x86_64 only. BIOS detection is filename-agnostic: scans pcsx2/bios/ for any file between 4-8 MB with a valid romdir structure (RESET + ROMVER entries). No hash validation. @@ -28,6 +32,11 @@ notes: | .info firmware_count=2 counts the bios folder and GameIndex.yaml. .info description mentions EROM.BIN but no EROM loading path exists in the code. +data_directories: + - ref: lrps2 + destination: "" + source_ref: "pcsx2/GameDatabase.cpp:49, pcsx2/GameDatabase.cpp:979" + files: - name: ".bin" path: "pcsx2/bios/.bin" @@ -85,15 +94,6 @@ files: Mechacon (disc drive controller) version as u32. Auto-created with default 0x00020603 if missing. - - name: "GameIndex.yaml" - path: "pcsx2/resources/GameIndex.yaml" - required: false - description: "game compatibility database" - source_ref: "pcsx2/GameDatabase.cpp:49, pcsx2/GameDatabase.cpp:979" - note: > - YAML database of per-game patches, settings overrides, and compatibility fixes. - OSD warning shown if missing. Some games may not boot or have issues without it. - - name: "cheats_ws.zip" path: "pcsx2/resources/cheats_ws.zip" required: false diff --git a/scripts/common.py b/scripts/common.py index 25d9d766..40eae77b 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -644,8 +644,8 @@ def _rank_tail_candidates( ) -> list[str]: """Order the files a destination tail designates, the likeliest first. - A shortened tail found only in another emulator's tree is that - emulator's asset, not evidence about this entry. A full tail claimed + A tail found in another emulator's tree is that emulator's asset, not + evidence about this entry, whatever its length. A full tail claimed twice goes to the file whose path carries the least on top of the destination, then to the owner's own copy (super3's Games.xml was served to Supermodel-Dojo by index order alone), then to any copy outside @@ -655,8 +655,11 @@ def _rank_tail_candidates( def path_of(sha1: str) -> str: return files_db.get(sha1, {}).get("path", "") - if start: - candidates = [h for h in candidates if not _foreign_tree(path_of(h), owners)] + # Another emulator's tree answers that emulator's entry, at any tail + # length: lrps2 names no hash for pcsx2/resources/GameIndex.yaml, and the + # only full-tail copy was ARMSX2's, which then counted as path proof and + # replaced the GameIndex RetroArch pins on every desktop target. + candidates = [h for h in candidates if not _foreign_tree(path_of(h), owners)] if len(candidates) < 2: return candidates depth = len(tail.split("/")) @@ -1040,6 +1043,13 @@ def resolve_local_file( if file_entry.get("members"): candidates = _holding_members(candidates, file_entry["members"]) candidates = _by_affinity(candidates, file_entry, dest_hint) + # A copy outside another emulator's tree comes first: the + # affinity read pcsx2 in bios/Other/armsx2/pcsx2/ and served + # upstream PCSX2 the patches.zip of ARMSX2. + owners = _owner_tokens(file_entry) + candidates = [ + p for p in candidates if not _foreign_tree(p, owners) + ] or candidates primary = [p for p in candidates if "/.variants/" not in p] if primary or candidates: chosen = primary[0] if primary else candidates[0] diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 3967206a..ba65e4f5 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -2287,6 +2287,83 @@ class ARegionDroppedExtraBlocksNothing(unittest.TestCase): self.assertEqual(report({"foo.rom"}), {}) +class AnotherEmulatorsTreeProvesNothing(unittest.TestCase): + """lrps2 names no hash for pcsx2/resources/GameIndex.yaml; the only + full-tail copy was ARMSX2's, which counted as path proof and replaced + the GameIndex RetroArch pins on desktop targets, where ARMSX2 does not + exist. Upstream PCSX2 took ARMSX2's patches.zip by name the same way.""" + + def _db(self, root, files): + import hashlib + + db = {"files": {}, "indexes": {"by_name": {}, "by_md5": {}, "by_crc32": {}, + "by_path_suffix": {}}} + for relative, payload in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + sha1 = hashlib.sha1(payload).hexdigest() + db["files"][sha1] = {"path": relative, "name": path.name, "sha1": sha1, + "size": len(payload)} + db["indexes"]["by_name"].setdefault(path.name, []).append(sha1) + parts = relative.split("/") + for start in range(1, len(parts) - 1): + db["indexes"]["by_path_suffix"].setdefault( + "/".join(parts[start:]), []).append(sha1) + return db + + def test_neither_the_full_tail_nor_the_name_lands_in_a_foreign_tree(self): + from scripts.common import resolve_local_file + + with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory: + root = Path(directory) + # The collection's layout, read relative to its root. + previous = os.getcwd() + os.chdir(root) + self.addCleanup(os.chdir, previous) + db = self._db(root, { + "bios/Other/armsx2/pcsx2/resources/GameIndex.yaml": b"fork", + "bios/Other/armsx2/pcsx2/resources/patches.zip": b"fork patches", + "bios/Sony/PlayStation 2/patches.zip": b"upstream patches", + }) + index, status = resolve_local_file( + {"name": "GameIndex.yaml", "path": "pcsx2/resources/GameIndex.yaml", + "source_profile": "lrps2"}, + db, dest_hint="pcsx2/resources/GameIndex.yaml", + ) + # Held only in the fork's tree, the file answers by name and + # proves nothing about this entry. + self.assertEqual(status, "name_exact") + patches, _status = resolve_local_file( + {"name": "patches.zip", "path": "resources/patches.zip", + "source_profile": "pcsx2"}, + db, dest_hint="resources/patches.zip", + ) + self.assertTrue(patches.endswith("PlayStation 2/patches.zip")) + + def test_no_desktop_retroarch_pack_serves_armsx2s_tree(self): + from scripts import slots + from scripts.common import ( + load_data_dir_registry, + load_database, + load_emulator_profiles, + load_platform_config, + load_target_config, + ) + + if not (ROOT / "database.json").exists(): + self.skipTest("database.json is not generated") + db = load_database(str(ROOT / "database.json")) + config = load_platform_config("retroarch", str(ROOT / "platforms")) + profiles = load_emulator_profiles(str(ROOT / "emulators")) + cores = set(load_target_config("retroarch", "x86_64", str(ROOT / "platforms"))) + overrides = slots.pack_overrides( + config, profiles, db, None, load_data_dir_registry(str(ROOT / "platforms")), + target_cores=cores, + ) + self.assertFalse([d for d, p in overrides.items() if "/armsx2/" in p]) + + class TheDatabaseCacheIsWrittenWhole(unittest.TestCase): """A crash while serializing left a truncated db_cache.json behind, and the next run rehashed the whole collection from scratch."""