From 598c930771d4d12a2ec301e00b01dff875b08919 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:50:27 +0200 Subject: [PATCH] fix: claim the archives a core reads by members --- install/lakka.json | 18 ++++----- install/retroarch.json | 18 ++++----- install/retropie.json | 18 ++++----- scripts/common.py | 6 +++ scripts/slots.py | 85 ++++++++++++++++++++++++++++++++++++++++-- tests/test_slots.py | 62 ++++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 31 deletions(-) diff --git a/install/lakka.json b/install/lakka.json index 4bb050b3..43c14b32 100644 --- a/install/lakka.json +++ b/install/lakka.json @@ -17,9 +17,9 @@ ], "standalone_copies": [], "total_files": 5869, - "total_size": 6160101174, + "total_size": 6161070712, "pack_files": 8512, - "pack_size": 6200633701, + "pack_size": 6201603239, "total_omitted": 0, "omitted_files": [], "core_names": { @@ -1528,10 +1528,10 @@ }, { "dest": "fbneo/nmk004.zip", - "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", - "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, - "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": null, "systems": [ "arcade" @@ -1841,10 +1841,10 @@ }, { "dest": "fbneo/neocdz.zip", - "sha1": "4f28af31ca0defdd73d80edec2fa296908e624dc", - "sha256": "6aa507a5dc33c45d2592bf2bc77eb3e497762221ddd9225f18583a5c55d38667", - "size": 214681, - "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.4f28af31", + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", "cores": null, "systems": [ "arcade", diff --git a/install/retroarch.json b/install/retroarch.json index c1f6972c..e2b49e4a 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -256,9 +256,9 @@ } ], "total_files": 5869, - "total_size": 6160101174, + "total_size": 6161070712, "pack_files": 8512, - "pack_size": 6200633701, + "pack_size": 6201603239, "total_omitted": 0, "omitted_files": [], "core_names": { @@ -1767,10 +1767,10 @@ }, { "dest": "fbneo/nmk004.zip", - "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", - "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, - "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": null, "systems": [ "arcade" @@ -2080,10 +2080,10 @@ }, { "dest": "fbneo/neocdz.zip", - "sha1": "4f28af31ca0defdd73d80edec2fa296908e624dc", - "sha256": "6aa507a5dc33c45d2592bf2bc77eb3e497762221ddd9225f18583a5c55d38667", - "size": 214681, - "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.4f28af31", + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", "cores": null, "systems": [ "arcade", diff --git a/install/retropie.json b/install/retropie.json index 33253e77..6af5821a 100644 --- a/install/retropie.json +++ b/install/retropie.json @@ -16,9 +16,9 @@ ], "standalone_copies": [], "total_files": 9906, - "total_size": 8494372405, + "total_size": 8495341943, "pack_files": 12549, - "pack_size": 8534904932, + "pack_size": 8535874470, "total_omitted": 0, "omitted_files": [], "core_names": { @@ -1545,10 +1545,10 @@ }, { "dest": "fbneo/nmk004.zip", - "sha1": "489256f5e2001070d2ad94c90d255282c71ed274", - "sha256": "6ca3142ebe127f5d2cb755c53740a92d9bf1574bde8daa32642de2991af89aaa", + "sha1": "9f84f563df14ea1a1bcaaf05b720c3f5545e5a83", + "sha256": "13d503ea6c7e7b9a2249837cf915bff6df862b7035f5c99b18240088ec10854a", "size": 3556, - "repo_path": "bios/Arcade/Arcade/nmk004.zip", + "repo_path": "bios/Arcade/Arcade/.variants/nmk004.zip.f70b6562", "cores": null, "systems": [ "arcade" @@ -1858,10 +1858,10 @@ }, { "dest": "fbneo/neocdz.zip", - "sha1": "4f28af31ca0defdd73d80edec2fa296908e624dc", - "sha256": "6aa507a5dc33c45d2592bf2bc77eb3e497762221ddd9225f18583a5c55d38667", - "size": 214681, - "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.4f28af31", + "sha1": "985d935c06fe278ce39d7690aa6c53df88954cd6", + "sha256": "4919ba2a7650b24d1bf2c9107c89a80d93b50703a0a371fa5113c791bd03e114", + "size": 1184219, + "repo_path": "bios/Arcade/Arcade/.variants/neocdz.zip.ba20a7be", "cores": null, "systems": [ "arcade", diff --git a/scripts/common.py b/scripts/common.py index 34d1fd7d..bc819fd8 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -623,6 +623,12 @@ def archive_entry(archive: str, files: list[dict]) -> dict: return {**own, "members": members} if members else own +def holds_required_members(path: str, members: list[dict]) -> bool: + """Whether an archive holds every member a profile requires inside it.""" + crcs = zip_member_crcs(path) + return all(m["crc32"] in crcs for m in members if m["required"]) + + def _holding_members(paths: list[str], members: list[dict]) -> list[str]: """The same-named archives holding the most required members, then the most members. diff --git a/scripts/slots.py b/scripts/slots.py index f8d9abd9..4468a724 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -20,8 +20,10 @@ from dataclasses import dataclass, field import nativemode from common import ( + archive_entry, build_zip_contents_index, check_inside_zip, + holds_required_members, load_data_dir_registry, parse_md5_list, resolution_is_hash_exact, @@ -34,7 +36,11 @@ from validation import outside_gap_scope # A profile entry can prove a slot without declaring a hash: Dolphin names no # checksum for the GameCube boot ROM because its source names none, and the # proof is then the path the repository stores the file at. -PROVEN_STATUSES = frozenset({"path_exact"}) +PROVEN_STATUSES = frozenset({"path_exact", "members_held"}) + +# A core that reads ROMs inside an archive proves the archive by its members: +# FBNeo's neocdz.zip is any copy holding neocd.bin and 000-lo.lo by crc. +MEMBERS_HELD = "members_held" @dataclass @@ -169,6 +175,12 @@ def profile_claims( if profile.get("type") in ("launcher", "alias"): continue is_standalone = runs_standalone(emu_name, profile, standalone_cores) + claims.extend( + _archive_claims( + emu_name, profile, is_standalone, db, base_dest, placement, + zip_contents, data_dir_registry, + ) + ) for entry in profile.get("files") or []: if not isinstance(entry, dict): continue @@ -178,9 +190,8 @@ def profile_claims( if outside_gap_scope(entry, is_standalone): continue # A member of a romset is not a file at the BIOS root: the archive - # holding it is what occupies a destination, and it is declared - # separately. Claiming the member put FBNeo's msx.zip contents - # loose at the platform's root. + # holding it occupies the destination (_archive_claims). Claiming + # the member put FBNeo's msx.zip contents loose at the root. if entry.get("archive"): continue dest = ( @@ -221,6 +232,69 @@ def profile_claims( return claims +def _archive_claims( + emu_name: str, + profile: dict, + is_standalone: bool, + db: dict, + base_dest: str, + placement, + zip_contents: dict | None, + data_dir_registry: dict | None, +) -> list[Claim]: + """The archives a core reads its ROMs from, where the builder places them. + + The archive sits at the root and, for a core that reads a directory of + its own (archive_prefix), under it as well. The claim is proven when the + copy holds every member the core requires. + """ + files = [ + entry for entry in profile.get("files") or [] + if isinstance(entry, dict) and not outside_gap_scope(entry, is_standalone) + ] + prefix = profile.get("archive_prefix", "") + claims: list[Claim] = [] + for archive in sorted({entry["archive"] for entry in files if entry.get("archive")}): + entry = archive_entry(archive, files) + members = entry.get("members") + if not members: + continue + system = next(f.get("system") for f in files if f.get("archive") == archive) + for dest in (archive, f"{prefix}/{archive}") if prefix else (archive,): + if placement is None: + full = f"{base_dest}/{dest}" if base_dest else dest + else: + placed = placement.place({"profile": emu_name, "system": system}, dest) + if placed is None: + continue + full = placed[1] + local, status = resolve_local_file( + {**entry, "source_profile": emu_name}, + db, + zip_contents, + dest_hint=dest, + data_dir_registry=data_dir_registry, + ) + if ( + local + and not resolution_is_hash_exact(status) + and holds_required_members(local, members) + ): + status = MEMBERS_HELD + claims.append( + Claim( + origin="profile", + destination=full, + name=archive, + emulator=emu_name, + entry=entry, + local_path=local, + status=status, + ) + ) + return claims + + def _claim_rank(claim: Claim) -> int: """How strongly a platform claim speaks for what the pack ships.""" if resolution_is_hash_exact(claim.status): @@ -526,6 +600,9 @@ def _satisfies(profile: Claim, platform: Claim, db: dict) -> bool: """ if profile.local_path == platform.local_path: return True + members = profile.entry.get("members") + if members and platform.local_path: + return holds_required_members(platform.local_path, members) if ( profile.local_path and _lists_hashes(platform.entry) diff --git a/tests/test_slots.py b/tests/test_slots.py index 8c6ef418..cb6fae52 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -646,5 +646,67 @@ class StrongestProfileClaimWins(unittest.TestCase): ) +class AnArchiveIsClaimedByItsMembers(unittest.TestCase): + """System.dat pins fbneo/neocdz.zip to a copy holding neocd.bin alone; + FBNeo also requires 000-lo.lo inside it. Members were skipped by the + claims, so nothing contested the pin and RetroArch shipped an archive + the core cannot boot from.""" + + def setUp(self): + import hashlib + import zipfile + import zlib + + roms = {"neocd.bin": b"bios" * 8, "000-lo.lo": b"zoom" * 8} + entries = {} + self.paths = {} + for label, members in (("short", ["neocd.bin"]), ("full", list(roms))): + path = Path(f"bios/Arc/{label}/neocdz.zip") + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as zf: + for member in members: + zf.writestr(member, roms[member]) + data = path.read_bytes() + entries[hashlib.sha1(data).hexdigest()] = { + "path": str(path), "md5": hashlib.md5(data).hexdigest(), "size": len(data), + } + self.paths[label] = (str(path), hashlib.md5(data).hexdigest()) + self.addCleanup(shutil.rmtree, "bios/Arc", True) + self.db = _db(entries) + self.profiles = {"fbneo": { + "emulator": "FBNeo", "type": "libretro", "systems": ["snk-neogeo-cd"], + "archive_prefix": "fbneo", + "files": [ + {"name": name, "archive": "neocdz.zip", "system": "snk-neogeo-cd", + "required": True, "crc32": format(zlib.crc32(data), "08x")} + for name, data in roms.items() + ], + }} + + def _config(self, mode: str, pinned: str) -> dict: + return { + "platform": "P", "verification_mode": mode, "base_destination": "system", + "cores": ["fbneo"], + "systems": {"snk-neogeo-cd": {"files": [{ + "name": "neocdz.zip", "destination": "fbneo/neocdz.zip", + "md5": self.paths[pinned][1], + }]}}, + } + + def test_existence_serves_the_copy_the_core_boots_from(self): + overrides = slots.pack_overrides(self._config("existence", "short"), self.profiles, self.db) + self.assertEqual(overrides, {"system/fbneo/neocdz.zip": self.paths["full"][0]}) + + def test_a_content_check_keeps_the_pin_and_reports_it(self): + config = self._config("md5", "short") + self.assertEqual(slots.pack_overrides(config, self.profiles, self.db), {}) + conflicts = slots.find_conflicts(config, self.profiles, self.db, "system") + self.assertEqual([c.destination for c in conflicts], ["system/fbneo/neocdz.zip"]) + + def test_a_pin_holding_the_members_is_agreement(self): + config = self._config("md5", "full") + self.assertEqual(slots.find_conflicts(config, self.profiles, self.db, "system"), []) + + if __name__ == "__main__": unittest.main()