diff --git a/emulators/dolphin-mmjr.yml b/emulators/dolphin-mmjr.yml index 8792f237..55ff538d 100644 --- a/emulators/dolphin-mmjr.yml +++ b/emulators/dolphin-mmjr.yml @@ -85,7 +85,9 @@ files: hle_fallback: true region: [north-america] size: 2097152 + crc32: ["6dac1f2a", "d5e6feea", "86573808", "667d0b64"] validation: [crc32] + on_mismatch: warn description: "GameCube boot ROM, NTSC-U directory" note: "Read whole into a 2 MiB buffer with no size check, then descrambled from 0x100 for 0x1afe00 bytes. The crc32 gate runs when the IPL itself is booted, and the four non-PAL dumps pass here without complaint." source_ref: "Source/Core/Core/ConfigManager.cpp:861-868, Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:99-112, Source/Core/Core/HW/EXI/EXI_DeviceIPL.h:43, Source/Core/Core/Boot/Boot.cpp:296-330" @@ -97,7 +99,9 @@ files: hle_fallback: true region: [europe] size: 2097152 + crc32: ["4f319f43", "ad1b7f16"] validation: [crc32] + on_mismatch: warn description: "GameCube boot ROM, PAL directory" note: "The two PAL dumps are the ones this directory takes without a mismatch warning." source_ref: "Source/Core/Core/ConfigManager.cpp:835-849, Source/Core/Core/Boot/Boot.cpp:298-337" @@ -109,7 +113,9 @@ files: hle_fallback: true region: [japan] size: 2097152 + crc32: ["6dac1f2a", "d5e6feea", "86573808", "667d0b64"] validation: [crc32] + on_mismatch: warn description: "GameCube boot ROM, NTSC-J directory" note: "JAP is the only Japanese directory name this fork knows. FindIPLDump, which serves the font path, scans USA then EUR then JAP and stops at the first hit." source_ref: "Source/Core/Common/CommonPaths.h:32-34, Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:174-186" diff --git a/emulators/dolphin-mmjr2.yml b/emulators/dolphin-mmjr2.yml index b64668fb..2f12b994 100644 --- a/emulators/dolphin-mmjr2.yml +++ b/emulators/dolphin-mmjr2.yml @@ -121,7 +121,9 @@ files: hle_fallback: true region: [north-america] size: 2097152 + crc32: ["6dac1f2a", "d5e6feea", "86573808", "667d0b64"] validation: [crc32] + on_mismatch: warn description: "GameCube boot ROM, NTSC-U directory" note: "Read whole into a 2 MiB buffer with no size check, then descrambled from 0x100 for 0x1afe00 bytes. The crc32 gate runs when the IPL itself is booted and only warns; the four non-PAL dumps, the Brazilian MPAL one included, pass here without complaint." source_ref: "Source/Core/Core/Config/MainSettings.cpp:594-600, Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:105-113, Source/Core/Core/HW/EXI/EXI_DeviceIPL.h:43, Source/Core/Core/Boot/Boot.cpp:408-442, Source/Core/Core/Boot/Boot.cpp:444-449" @@ -133,7 +135,9 @@ files: hle_fallback: true region: [europe] size: 2097152 + crc32: ["4f319f43", "ad1b7f16"] validation: [crc32] + on_mismatch: warn description: "GameCube boot ROM, PAL directory" note: "The two PAL dumps are the ones this directory takes without a mismatch warning." source_ref: "Source/Core/Core/Config/MainSettings.cpp:580-581, Source/Core/Core/Boot/Boot.cpp:444-449" @@ -145,7 +149,9 @@ files: hle_fallback: true region: [japan] size: 2097152 + crc32: ["6dac1f2a", "d5e6feea", "86573808", "667d0b64"] validation: [crc32] + on_mismatch: warn description: "GameCube boot ROM, NTSC-J directory" note: "JPN is defined alongside JAP but the boot ROM callers use the legacy default, so JAP is the directory read. FindIPLDump, which serves the font path, scans USA then EUR then JAP and stops at the first hit." source_ref: "Source/Core/Common/CommonPaths.h:34-35, Source/Core/Core/Config/MainSettings.cpp:574-575, Source/Core/Core/HW/EXI/EXI_DeviceIPL.cpp:175-187" diff --git a/schemas/emulator.schema.json b/schemas/emulator.schema.json index 2b503598..d30227a4 100644 --- a/schemas/emulator.schema.json +++ b/schemas/emulator.schema.json @@ -511,8 +511,21 @@ "pattern": "^[a-fA-F0-9]{64}(,[a-fA-F0-9]{64})*$" }, "crc32": { - "type": "string", - "pattern": "^[a-fA-F0-9]{8}$" + "description": "CRC32 the code accepts; a list when several dumps pass.", + "oneOf": [ + { + "type": "string", + "pattern": "^[a-fA-F0-9]{8}$" + }, + { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-fA-F0-9]{8}$" + }, + "minItems": 1 + } + ] }, "known_hash_adler32": { "description": "Adler-32 values the code accepts; a list when several revisions pass.", diff --git a/scripts/common.py b/scripts/common.py index 87984be8..d3ea0180 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -408,7 +408,7 @@ def declared_hash_verdict( sha1_candidates: set, sha256_candidates: set, md5_list: list, - crc_raw: str, + crc_candidates: set, zipped_file: str | None, declared_size, ) -> str: @@ -427,7 +427,7 @@ def declared_hash_verdict( algorithms.add("sha256") if md5_list and not zipped_file: algorithms.add("md5") - if crc_raw: + if crc_candidates: algorithms.add("crc32") actual = compute_hashes(candidate, frozenset(algorithms)) if algorithms else {} if sha1_candidates and actual.get("sha1", "").lower() not in sha1_candidates: @@ -438,9 +438,9 @@ def declared_hash_verdict( actual.get("md5", "").lower().startswith(expected) for expected in md5_list ): return "hash_mismatch" - if crc_raw and actual.get("crc32", "").lower() != crc_raw: + if crc_candidates and actual.get("crc32", "").lower() not in crc_candidates: return "hash_mismatch" - if crc_raw and declared_size is not None: + if crc_candidates and declared_size is not None: allowed_sizes = declared_size if isinstance(declared_size, list) else [declared_size] if os.path.getsize(candidate) not in allowed_sizes: return "hash_mismatch" @@ -664,10 +664,17 @@ def resolve_local_file( for candidate in str(value).split(",") if len(candidate.strip()) == 64 ] - crc_raw = str(file_entry.get("crc32", "") or "").strip().lower() + # One value, a list, or a comma-separated string, like the other hashes. + crc_values = file_entry.get("crc32") or [] + crc_candidates = { + candidate.strip().lower().removeprefix("0x") + for value in (crc_values if isinstance(crc_values, list) else [crc_values]) + for candidate in str(value).split(",") + if candidate.strip() + } declared_size = file_entry.get("size") has_strong_hash = bool( - sha1_candidates or sha256_candidates or md5_list or crc_raw + sha1_candidates or sha256_candidates or md5_list or crc_candidates ) files_db = db.get("files", {}) by_md5 = db.get("indexes", {}).get("by_md5", {}) @@ -694,8 +701,8 @@ def resolve_local_file( if not any(actual_md5.startswith(expected) for expected in md5_list): return None statuses.append("md5_exact") - if crc_raw: - if str(entry.get("crc32", "")).lower() != crc_raw: + if crc_candidates: + if str(entry.get("crc32", "")).lower() not in crc_candidates: return None if declared_size is not None: allowed_sizes = ( @@ -730,20 +737,21 @@ def resolve_local_file( # size confirms it when available; a handful of emulators validate CRC32 # alone, so those entries retain the same (weaker) evidence as the core. if ( - crc_raw + crc_candidates and not zipped_file and not md5_list and not sha1_candidates and not sha256_candidates ): by_crc32 = db.get("indexes", {}).get("by_crc32", {}) - match = by_crc32.get(crc_raw) - if match and match in files_db: - entry = files_db[match] - path = entry["path"] - status = _record_match_status(match) - if status and os.path.exists(path): - return path, status + for crc in sorted(crc_candidates): + match = by_crc32.get(crc) + if match and match in files_db: + entry = files_db[match] + path = entry["path"] + status = _record_match_status(match) + if status and os.path.exists(path): + return path, status # 2. MD5 direct lookup (skip for zipped_file: md5 is inner ROM, not container) # Guard: only accept if the found file's name matches the requested name @@ -900,7 +908,7 @@ def resolve_local_file( candidates = sized if candidates: - if md5_set and not (sha1_candidates or sha256_candidates or crc_raw): + if md5_set and not (sha1_candidates or sha256_candidates or crc_candidates): for path, db_md5, _size in candidates: if ".zip" in os.path.basename(path): try: @@ -938,7 +946,7 @@ def resolve_local_file( zipped_file and md5_list and zip_contents - and not (sha1_candidates or sha256_candidates or crc_raw) + and not (sha1_candidates or sha256_candidates or crc_candidates) ): for md5_candidate in md5_list: if md5_candidate in zip_contents: @@ -978,7 +986,7 @@ def resolve_local_file( sha1_candidates=sha1_candidates, sha256_candidates=sha256_candidates, md5_list=md5_list, - crc_raw=crc_raw, + crc_candidates=crc_candidates, zipped_file=zipped_file, declared_size=declared_size, ) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index c5b111ad..eca1b022 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -2028,7 +2028,8 @@ def _render_emulator_file( if fmd5: details.append(f"MD5: {_hash_spans(fmd5)}") if fcrc32: - details.append(f"CRC32: `{fcrc32}`") + crcs = fcrc32 if isinstance(fcrc32, list) else [fcrc32] + details.append("CRC32: " + ", ".join(f"`{crc}`" for crc in crcs)) if fsha256: details.append(f"SHA256: {_hash_spans(fsha256)}") if fadler32: diff --git a/tests/test_profile_checks.py b/tests/test_profile_checks.py index a0c87fe3..d4f93575 100644 --- a/tests/test_profile_checks.py +++ b/tests/test_profile_checks.py @@ -235,5 +235,20 @@ class HypseusArchives(unittest.TestCase): self.assertFalse(entry.get("validation")) +class DolphinForksIplDumps(_Probe): + """Load_BS2 compares the IPL's CRC32 with six Redump dumps and only + raises "The IPL file is not a known good dump" before booting it + (Source/Core/Core/Boot/Boot.cpp:296-337 at Dolphin-MMJR 1b095b6, + 408-449 at Dolphin-MMJR2 4d0fdb8). The profiles declared the check with + no value, so nothing told a known dump from any other.""" + + def test_an_unknown_ipl_only_warns(self): + for emulator in ("dolphin-mmjr", "dolphin-mmjr2"): + with self.subTest(emulator=emulator): + data = b"\0" * 2097152 + self.assertTrue(self.accepts(emulator, "IPL.bin", data)) + self.assertFalse(self.accepts(emulator, "IPL.bin", data, strict=True)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_resolution_evidence.py b/tests/test_resolution_evidence.py index beb95195..d7f542db 100644 --- a/tests/test_resolution_evidence.py +++ b/tests/test_resolution_evidence.py @@ -52,7 +52,7 @@ class DeclaredHashVerdict(unittest.TestCase): def verdict(self, path, **declared): base = dict( has_strong_hash=True, sha1_candidates=set(), sha256_candidates=set(), - md5_list=[], crc_raw="", zipped_file=None, declared_size=None, + md5_list=[], crc_candidates=set(), zipped_file=None, declared_size=None, ) base.update(declared) return common.declared_hash_verdict(str(path), **base) @@ -82,7 +82,7 @@ class DeclaredHashVerdict(unittest.TestCase): def test_crc32_is_checked(self): self.assertEqual( - self.verdict(self.wrong, crc_raw=self.d["crc32"]), "hash_mismatch" + self.verdict(self.wrong, crc_candidates={self.d["crc32"]}), "hash_mismatch" ) def test_a_truncated_md5_still_decides(self): @@ -102,7 +102,7 @@ class DeclaredHashVerdict(unittest.TestCase): self.verdict( self.right, sha1_candidates={self.d["sha1"]}, - crc_raw="deadbeef", + crc_candidates={"deadbeef"}, ), "hash_mismatch", ) @@ -110,13 +110,13 @@ class DeclaredHashVerdict(unittest.TestCase): def test_a_size_beside_a_crc32_is_enforced(self): self.assertEqual( self.verdict( - self.right, crc_raw=self.d["crc32"], declared_size=len(PAYLOAD) + self.right, crc_candidates={self.d["crc32"]}, declared_size=len(PAYLOAD) ), "data_dir_hash_exact", ) self.assertEqual( self.verdict( - self.right, crc_raw=self.d["crc32"], declared_size=len(PAYLOAD) + 1 + self.right, crc_candidates={self.d["crc32"]}, declared_size=len(PAYLOAD) + 1 ), "hash_mismatch", ) @@ -125,7 +125,7 @@ class DeclaredHashVerdict(unittest.TestCase): self.assertEqual( self.verdict( self.right, - crc_raw=self.d["crc32"], + crc_candidates={self.d["crc32"]}, declared_size=[1, len(PAYLOAD)], ), "data_dir_hash_exact",