From 8eda797562fe49c0749b3d991270fa332d34ed32 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:24:19 +0200 Subject: [PATCH] fix: a hashless entry is a path in any mode Whether the frontend compares content was decided per platform. EmuDeck (md5) declares neocdz.zip with no hash and checkBIOS.sh never names it, yet the proven claims of geolith and FBNeo were dropped and the pack shipped a neocdz.zip without 000-lo.lo. checked_by_path() decides per entry: no hash of the kind the frontend compares (any of md5, sha1, crc32 under RomM's rule) and no zipped_file means a path. Measured: the EmuDeck neocdz.zip slot and three RetroBat DS slots (dsi_bios7.bin, dsi_bios9.bin, firmware.bin) now take the file the cores prove. --- scripts/slots.py | 50 ++++++++++++++++++++++++++++++++++++--------- tests/test_slots.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/scripts/slots.py b/scripts/slots.py index 68a464fd..7b27857f 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -329,6 +329,29 @@ def _archive_claims( return claims +def checked_by_path(mode: str, entry: dict, rule: str = "") -> bool: + """Whether the frontend checks this declaration by its path alone. + + Whether a frontend reads the bytes is a property of the platform, but + what it compares them with is the entry's: Batocera skips an empty md5 + (batocera-systems:1088), and EmuDeck declares neocdz.zip with no hash at + all. Such an entry is a path, in an md5 platform as in an existence one. + A member pinned inside an archive is content. + """ + if not nativemode.reads_file_contents(mode): + return True + if entry.get("zipped_file"): + return False + if rule == nativemode.SIZE_AND_ANY_HASH: + fields: tuple[str, ...] = ("md5", "sha1", "crc32") + else: + fields = (nativemode.digest_algorithm(mode) or "",) + return not any( + parse_md5_list(entry.get(name)) if name == "md5" else entry.get(name) + for name in fields + ) + + def _claim_rank(claim: Claim) -> int: """How strongly a platform claim speaks for what the pack ships.""" if resolution_is_hash_exact(claim.status): @@ -382,10 +405,10 @@ def _slot_claims( # matches, asserts nothing about content. Where the frontend never reads # the bytes, a proven profile claim then decides: RetroArch's neocdz.zip # pins an md5 nobody holds, and the primary shipped without the 000-lo.lo - # geolith and FBNeo read inside it. - path_only = not nativemode.reads_file_contents( - config.get("verification_mode", "existence") - ) + # geolith and FBNeo read inside it. Decided per entry: EmuDeck, an md5 + # platform, declares the same archive with no hash. + mode = config.get("verification_mode", "existence") + rule = config.get("hash_rule", "") by_slot: dict[str, list[Claim]] = {} for claim in profile_claims( profiles, db, base_dest, standalone_cores, zip_contents, data_dir_registry, @@ -401,7 +424,9 @@ def _slot_claims( ): # A size proves a claim only against a file of another size. continue - if not platform.is_proven and not (path_only and platform.local_path): + if not platform.is_proven and not ( + platform.local_path and checked_by_path(mode, platform.entry, rule) + ): continue by_slot.setdefault(key, []).append(claim) @@ -518,8 +543,8 @@ def slot_choices( recorded whole in the install manifest, so a target narrowed from the full manifest gets the bytes the target's own pack carries. """ - if nativemode.reads_file_contents(config.get("verification_mode", "existence")): - return {} + mode = config.get("verification_mode", "existence") + rule = config.get("hash_rule", "") platform_profiles = { name: profiles[name] for name in resolve_platform_cores(config, profiles) } @@ -535,6 +560,9 @@ def slot_choices( choices: dict[str, list[tuple[str, str | None]]] = {} for key, claims in sorted(by_slot.items()): platform = by_dest[key] + # Where the frontend compares a hash, the platform's file stays. + if not checked_by_path(mode, platform.entry, rule): + continue agree = [_satisfies(c, platform, db) for c in claims] agreeing = [c for c, ok in zip(claims, agree) if ok] others = sorted( @@ -573,7 +601,9 @@ def _strongest(claims: list[Claim]) -> Claim: return max(claims, key=lambda claim: (_claim_rank(claim), -claims.index(claim))) -def arbitrate(conflict: Conflict, mode: str, addressee: str = "platform") -> Decision: +def arbitrate( + conflict: Conflict, mode: str, addressee: str = "platform", rule: str = "", +) -> Decision: """Decide a contested destination for the pack being built. A pack answers to whoever asked for it. A platform pack must leave the @@ -590,7 +620,7 @@ def arbitrate(conflict: Conflict, mode: str, addressee: str = "platform") -> Dec profile = _strongest(conflict.profile_claims) if addressee == "emulator": return Decision(conflict, profile, ADDRESSEE) - if not nativemode.reads_file_contents(mode): + if checked_by_path(mode, conflict.platform_claim.entry, rule): return Decision(conflict, profile, SERVES_BOTH) return Decision(conflict, conflict.platform_claim, FRONTEND_CHECKS_CONTENT) @@ -977,7 +1007,7 @@ def main() -> int: mode = config.get("verification_mode", "existence") print(f"{platform}: {len(conflicts)} contradicted [{mode}]") for conflict in conflicts: - decision = arbitrate(conflict, mode) + decision = arbitrate(conflict, mode, rule=config.get("hash_rule", "")) fixable += decision.serves_both print(f" {format_decision(decision)}") total = sum(len(c) for c in found.values()) diff --git a/tests/test_slots.py b/tests/test_slots.py index 9604a254..825ee5aa 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -349,6 +349,7 @@ class TestArbitration(unittest.TestCase): def _conflict(self): platform = slots.Claim( "platform", "GC/JAP/IPL.bin", "IPL.bin", + entry={"md5": "m" * 32, "sha1": "a" * 40}, local_path="bios/Console/GC/USA/IPL.bin", status="md5_exact", ) profile = slots.Claim( @@ -376,6 +377,40 @@ class TestArbitration(unittest.TestCase): self.assertEqual(decision.winner.origin, "profile") self.assertFalse(decision.serves_both) + def test_a_hashless_entry_is_a_path_in_an_md5_platform(self): + """EmuDeck declares neocdz.zip with no md5 and checkBIOS.sh never + names it: the frontend checks nothing there, so the core's file + serves both.""" + conflict = self._conflict() + conflict.platform_claim.entry = {"name": "IPL.bin"} + for mode in ("md5", "sha1"): + with self.subTest(mode=mode): + self.assertTrue(slots.arbitrate(conflict, mode).serves_both) + # RomM compares size then any hash: a hash of another kind is content. + conflict.platform_claim.entry = {"name": "IPL.bin", "sha1": "a" * 40, "size": 3} + self.assertFalse( + slots.arbitrate(conflict, "md5", rule="size_and_any_hash").serves_both + ) + conflict.platform_claim.entry = {"name": "x.zip", "zipped_file": "IPL.bin"} + self.assertFalse(slots.arbitrate(conflict, "md5").serves_both) + + def test_the_pack_serves_the_proven_file_at_a_hashless_md5_entry(self): + config = { + "verification_mode": "md5", + "cores": ["dolphin"], + "systems": {"console": {"files": [ + {"name": "IPL.bin", "destination": "IPL.bin"}, + {"name": "IPL.bin", "destination": "hashed/IPL.bin", "md5": "m" * 32}, + ]}}, + } + profiles = {"dolphin": {"type": "libretro", "systems": ["console"], "files": [ + {"name": "IPL.bin", "md5": "n" * 32}, + {"name": "IPL.bin", "path": "hashed/IPL.bin", "md5": "n" * 32}, + ]}} + overrides = slots.pack_overrides(config, profiles, REGIONS_DB) + # The hashed declaration keeps the file the frontend checks. + self.assertEqual(overrides, {"IPL.bin": "bios/Console/GC/JAP/IPL.bin"}) + def test_the_reported_line_gives_the_ground_for_the_decision(self): kept = slots.format_decision(slots.arbitrate(self._conflict(), "md5")) self.assertIn("verifies content", kept)