From f5a5d6f4bd9112099d3a7544844cbd41c6709ecc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:05:57 +0200 Subject: [PATCH] fix: judge a core file by the cores that read it --- install/batocera.json | 12 ++-- install/bizhawk.json | 6 +- install/emudeck.json | 12 ++-- install/lakka.json | 12 ++-- install/recalbox.json | 6 +- install/retroarch.json | 12 ++-- install/retrobat.json | 12 ++-- install/retrodeck.json | 12 ++-- install/retropie.json | 12 ++-- install/rocknix.json | 12 ++-- install/romm.json | 6 +- scripts/generate_pack.py | 18 ++++- scripts/validation.py | 124 +++++++++++++++++++++++++++++++-- tests/test_validated_choice.py | 117 ++++++++++++++++++++++++++++++- 14 files changed, 308 insertions(+), 65 deletions(-) diff --git a/install/batocera.json b/install/batocera.json index b69a14e9..99790a07 100644 --- a/install/batocera.json +++ b/install/batocera.json @@ -4837,10 +4837,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ] @@ -47899,10 +47899,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/bizhawk.json b/install/bizhawk.json index eac495bb..c0611b44 100644 --- a/install/bizhawk.json +++ b/install/bizhawk.json @@ -1840,10 +1840,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ], diff --git a/install/emudeck.json b/install/emudeck.json index 72b18ea5..f9364791 100644 --- a/install/emudeck.json +++ b/install/emudeck.json @@ -1036,10 +1036,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ], @@ -1270,10 +1270,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/lakka.json b/install/lakka.json index 3c3510a8..1b661012 100644 --- a/install/lakka.json +++ b/install/lakka.json @@ -6110,10 +6110,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ] @@ -61637,10 +61637,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/recalbox.json b/install/recalbox.json index 657d1670..b527ad6a 100644 --- a/install/recalbox.json +++ b/install/recalbox.json @@ -9824,10 +9824,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/retroarch.json b/install/retroarch.json index 3d12f1f0..cf1abd9b 100644 --- a/install/retroarch.json +++ b/install/retroarch.json @@ -6349,10 +6349,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ] @@ -61876,10 +61876,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/retrobat.json b/install/retrobat.json index e8e239e8..3bbcbb76 100644 --- a/install/retrobat.json +++ b/install/retrobat.json @@ -4813,10 +4813,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ] @@ -47905,10 +47905,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/retrodeck.json b/install/retrodeck.json index 04214e5f..66093f34 100644 --- a/install/retrodeck.json +++ b/install/retrodeck.json @@ -22689,10 +22689,10 @@ }, { "dest": "bios/sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ], @@ -25535,10 +25535,10 @@ }, { "dest": "bios/GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/retropie.json b/install/retropie.json index fd0a49ae..e04b0e36 100644 --- a/install/retropie.json +++ b/install/retropie.json @@ -6423,10 +6423,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ] @@ -101392,10 +101392,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/rocknix.json b/install/rocknix.json index 8536489c..49f5dd39 100644 --- a/install/rocknix.json +++ b/install/rocknix.json @@ -801,10 +801,10 @@ }, { "dest": "sysdata/otp.bin", - "sha1": "f0332b7252e9c01b30bf5d5a63ceb22dc47968aa", - "sha256": "93c2e20cc377f2cb0fa9a2ee58c39be22e00baa94b890545e585e53b3eb4039f", + "sha1": "49489245b400f62c62faf1736b7f0a70714d3c49", + "sha256": "e2f1810ceff3d1fd4f1c08ffc1f4d47aa8efca551edd24cd5686205c71a3b13b", "size": 256, - "repo_path": "bios/Nintendo/3DS/Citra/sysdata/otp.bin", + "repo_path": "bios/Nintendo/3DS/otp.bin", "cores": [ "azahar" ] @@ -2779,10 +2779,10 @@ }, { "dest": "GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/install/romm.json b/install/romm.json index 181c0a49..1d9e9be5 100644 --- a/install/romm.json +++ b/install/romm.json @@ -6472,10 +6472,10 @@ }, { "dest": "ngc/GC/dsp_rom.bin", - "sha1": "f4f683a49d7eb4155566f793f2c1c27e90159992", - "sha256": "7f33e6600722791ccee5fd15d4330af8d8f74165f7d1ded6738684c0294aaffe", + "sha1": "3c6cc6e04fdd0b2a392d7a6ed769455444846be7", + "sha256": "49d987ee1eab29a157425b82d54516957a81e1bac247c8834e494642605c3e8c", "size": 8192, - "repo_path": "bios/Nintendo/GameCube/Sys/GC/dsp_rom.bin", + "repo_path": "bios/Nintendo/GameCube/.variants/dsp_rom.bin.3c6cc6e0", "cores": [ "dolphin" ], diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 312e9ce2..7298bafc 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -1068,6 +1068,16 @@ def generate_pack( ) if status in ("not_found", "external", "user_provided") or not local_path: continue + # The emulator's own check picks the copy, as for a declared file + # and in the emulator pack: the name led RetroArch's GC/dsp_rom.bin + # to Dolphin's obsolete v0.3.1 free ROM, beside Nintendo's coef + # ROM, a pair Dolphin warns about at every start. + local_path, disagreement = validated_choice( + fe, local_path, db, validation_index, bios_dir, None, dest, + validation_owners, + ) + if disagreement: + core_discrepancies.append(f"{full_dest} -{disagreement}") if status == "hash_mismatch": # The core's declared hash comes from its source, the local dump # is what the collection holds. Shipping it keeps the emulator @@ -3100,6 +3110,8 @@ def _manifest_core_entries( pack_only_sizes: list[int], selection: SelectionIndex, required_only: bool = False, + validation_index: dict | None = None, + owners: dict | None = None, ) -> int: """Add the files a platform's cores need but its list does not name. @@ -3153,6 +3165,10 @@ def _manifest_core_entries( [source_emu] if source_emu else [], ) continue + # The copy the emulator accepts, as the pack ships it. + local_path, _disagreement = validated_choice( + fe, local_path, db, validation_index or {}, bios_dir, None, dest, owners, + ) source_emu = fe.get("source_profile") or fe.get("source_emulator", "") @@ -3479,7 +3495,7 @@ def generate_manifest( zip_contents, offline, region_drops, case_insensitive, seen_destinations, seen_lower, seen_parents, manifest_files, omitted_by_destination, record_omission, pack_only_sizes, selection, - required_only, + required_only, manifest_validation, manifest_owners, ) # Phase 3: data directories. The installer does not fetch them, so they diff --git a/scripts/validation.py b/scripts/validation.py index d129956b..b216b6e3 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -56,6 +56,84 @@ def _parse_validation(validation: list | dict | None) -> list[str]: return [] +def _entry_rule(emu_name: str, f: dict, checks: set[str]) -> dict: + """One profile entry's own checks, kept apart from the merged view. + + Merged by name, Azahar's crypto check on the 3DS otp.bin judged Cemu's + 1 KiB Wii U otp.bin, and the variant search handed Cemu the 3DS file. + """ + def values(field: str) -> set[str]: + raw = f.get(field) + if not raw or field not in checks: + return set() + items = raw if isinstance(raw, list) else str(raw).split(",") + out = set() + for item in items: + norm = str(item).strip().lower() + if field == "crc32" and norm.startswith("0x"): + norm = norm[2:] + if norm: + out.add(norm) + return out + + raw_size = f.get("size") if "size" in checks else None + adler = f.get("known_hash_adler32") or f.get("adler32") + adler_values = adler if isinstance(adler, list) else [adler] if adler else [] + return { + "emulator": emu_name, + "checks": set(checks), + "sizes": set(raw_size if isinstance(raw_size, list) else [raw_size]) + - {None}, + "min_size": f.get("min_size") if "size" in checks else None, + "max_size": f.get("max_size") if "size" in checks else None, + "crc32": values("crc32"), + "md5": values("md5"), + "sha1": values("sha1"), + "sha256": values("sha256"), + "adler32": { + str(v).lower().removeprefix("0x") for v in adler_values if v + }, + "adler32_byteswap": bool(f.get("adler32_byteswap")), + "crypto": {c for c in checks if c in _CRYPTO_CHECKS}, + } + + +def _rule_failure( + rule: dict, local_path: str, filename: str, bios_dir: str, hashes: dict, +) -> str | None: + """Why one entry's checks reject a file, or None when they accept it.""" + actual_size = os.path.getsize(local_path) + if rule["sizes"] and actual_size not in rule["sizes"]: + accepted = ",".join(str(s) for s in sorted(rule["sizes"])) + return f"size mismatch: got {actual_size}, accepted [{accepted}]" + if rule["min_size"] is not None and actual_size < rule["min_size"]: + return f"size too small: min {rule['min_size']}, got {actual_size}" + if rule["max_size"] is not None and actual_size > rule["max_size"]: + return f"size too large: max {rule['max_size']}, got {actual_size}" + for hash_type in ("crc32", "md5", "sha1", "sha256"): + if rule[hash_type]: + if not hashes: + hashes.update(compute_hashes(local_path)) + if hashes[hash_type].lower() not in rule[hash_type]: + accepted = ",".join(sorted(rule[hash_type])) + return f"{hash_type} mismatch: got {hashes[hash_type]}, accepted [{accepted}]" + if rule["adler32"]: + if rule["adler32_byteswap"]: + actual = _adler32_byteswapped(local_path) + else: + if not hashes: + hashes.update(compute_hashes(local_path)) + actual = hashes["adler32"].lower() + if actual not in rule["adler32"]: + accepted = ",".join(sorted(rule["adler32"])) + return f"adler32 mismatch: got 0x{actual}, accepted [{accepted}]" + if rule["crypto"]: + from crypto_verify import check_crypto_validation + + return check_crypto_validation(local_path, filename, bios_dir) + return None + + def _build_validation_index(profiles: dict) -> dict[str, dict]: """Build per-filename validation rules from emulator profiles. @@ -98,7 +176,9 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: "crypto_only": set(), "emulators": set(), "per_emulator": {}, + "rules": [], } + index[fname]["rules"].append(_entry_rule(emu_name, f, checks)) index[fname]["emulators"].add(emu_name) index[fname]["checks"].update(checks) # Track non-reproducible crypto checks @@ -143,8 +223,8 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: # Adler32 -stored as known_hash_adler32 field (not in validation: list # for Dolphin, but support it in both forms for future profiles) adler_val = f.get("known_hash_adler32") or f.get("adler32") - if adler_val: - norm = adler_val.lower() + for value in adler_val if isinstance(adler_val, list) else [adler_val] if adler_val else []: + norm = str(value).lower() if norm.startswith("0x"): norm = norm[2:] index[fname]["adler32"].add(norm) @@ -233,12 +313,18 @@ def check_file_validation( filename: str, validation_index: dict[str, dict], bios_dir: str = "bios", + emulators: set[str] | None = None, ) -> tuple[str, list[str]] | None: """Check emulator-level validation on a resolved file. Supports: size (exact/min/max), crc32, md5, sha1, adler32, signature (RSA-2048 PKCS1v15 SHA256), crypto (AES-128-CBC + SHA256). + With ``emulators``, the cores that read the file's destination, each of + them must accept it through one of its own entries for the name, the + way each runs its own check. Without, the checks of every core naming + the file are merged. + Returns None if all checks pass or no validation applies. Returns (reason, emulators) tuple on failure, where *emulators* lists only those cores whose check actually failed. @@ -246,6 +332,23 @@ def check_file_validation( entry = validation_index.get(filename) if not entry: return None + if emulators is not None: + hashes: dict = {} + failures: dict[str, str] = {} + for emu in sorted(emulators): + rules = [r for r in entry.get("rules", []) if r["emulator"] == emu] + if not rules: + continue + reasons = [ + _rule_failure(rule, local_path, filename, bios_dir, hashes) + for rule in rules + ] + if all(reasons): + failures[emu] = reasons[0] + if not failures: + return None + first = sorted(failures)[0] + return failures[first], sorted(failures) checks = entry["checks"] pe = entry.get("per_emulator", {}) @@ -366,6 +469,7 @@ def find_validated_variant( validation_index: dict, bios_dir: str = "bios", platform_digest: str | None = None, + emulators: set[str] | None = None, ) -> str | None: """A held file the emulator's own checks accept, in place of current_path. @@ -424,7 +528,9 @@ def find_validated_variant( seen.add(real) if accepted and str(entry.get(platform_digest, "")).lower() not in accepted: continue - if check_file_validation(path, fname, validation_index, bios_dir) is None: + if check_file_validation( + path, fname, validation_index, bios_dir, emulators + ) is None: return path return None @@ -575,18 +681,24 @@ def validated_choice( return local_path, None name = file_entry.get("name", "") rules = validation_index.get(name) + judges: set[str] | None = None if rules and owners and destination: owning = _owners_of(destination, name, owners) - if owning and not owning & set(rules["emulators"]): + if not owning & set(rules["emulators"]): + # No core that checks this name reads this destination: PC-98's + # bios.rom rules have no say over roms/ibmpcjr/bios.rom. return local_path, None + # The cores that read this destination judge it, each by its own + # entries. + judges = owning & set(rules["emulators"]) check = check_file_validation( - local_path, file_entry.get("name", ""), validation_index, bios_dir + local_path, file_entry.get("name", ""), validation_index, bios_dir, judges ) if not check: return local_path, None better = find_validated_variant( file_entry, db, local_path, validation_index, bios_dir, - platform_digest=platform_digest, + platform_digest=platform_digest, emulators=judges, ) if better: return better, None diff --git a/tests/test_validated_choice.py b/tests/test_validated_choice.py index 4a3a0bd5..f3ac1f38 100644 --- a/tests/test_validated_choice.py +++ b/tests/test_validated_choice.py @@ -97,8 +97,123 @@ class ValidatedChoice(unittest.TestCase): for call in ast.walk(node) ) } - self.assertLessEqual({"generate_pack", "generate_manifest"}, callers) + self.assertLessEqual( + {"generate_pack", "generate_manifest", "_manifest_core_entries"}, callers + ) + def test_core_extras_are_judged_too(self): + """The baseline loop judged a platform's own files; the core-extras loop + shipped whatever the name found. Both loops live in generate_pack.""" + tree = ast.parse((REPO_ROOT / "scripts" / "generate_pack.py").read_text(encoding="utf-8")) + builder = next( + node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == "generate_pack" + ) + calls = [ + call for call in ast.walk(builder) + if isinstance(call, ast.Call) and getattr(call.func, "id", None) == "validated_choice" + ] + self.assertGreaterEqual(len(calls), 2) + + +class ManifestsServeWhatTheCoreAccepts(unittest.TestCase): + """RetroArch's GC/dsp_rom.bin was Dolphin's obsolete v0.3.1 free ROM while + Nintendo's dump, which Dolphin's check accepts, sat in .variants/.""" + + def test_no_served_core_file_fails_its_check_beside_a_passing_variant(self): + import json # noqa: PLC0415 + + from common import load_emulator_profiles, load_platform_config # noqa: PLC0415 + from generate_pack import _platform_cores # noqa: PLC0415 + from validation import check_file_validation # noqa: PLC0415 + + manifest_path = REPO_ROOT / "install" / "retroarch.json" + if not (REPO_ROOT / "database.json").is_file() or not manifest_path.is_file(): + self.skipTest("database.json or the manifest is not built") + db = json.loads((REPO_ROOT / "database.json").read_text(encoding="utf-8")) + profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) + config = load_platform_config("retroarch", str(REPO_ROOT / "platforms")) + cores = {n: profiles[n] for n in _platform_cores(config, profiles)} + index = _build_validation_index(cores) + owners = destination_owners(cores) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + wrong = [] + for entry in manifest["files"]: + name = entry["dest"].rsplit("/", 1)[-1] + path = entry.get("repo_path") + if not entry.get("cores") or not path or name not in index: + continue + local = str(REPO_ROOT / path) + if not Path(local).is_file() or not check_file_validation(local, name, index): + continue + chosen, _ = validated_choice( + {"name": name}, local, db, index, str(REPO_ROOT / "bios"), None, + entry["dest"], owners, + ) + if chosen != local: + wrong.append(entry["dest"]) + self.assertEqual(wrong, []) + + + +class EachReaderJudgesWithItsOwnRules(unittest.TestCase): + """Merged by name, Azahar's 256-byte 3DS otp.bin rule judged Cemu's 1 KiB + Wii U otp.bin, and the variant search gave Cemu the 3DS file.""" + + def test_rules_of_another_emulator_do_not_judge(self): + from validation import check_file_validation # noqa: PLC0415 + + index = _build_validation_index({ + "azahar": {"files": [{"name": "otp.bin", "path": "sysdata/otp.bin", + "size": 256, "validation": ["size"]}]}, + "cemu": {"files": [{"name": "otp.bin", "path": "Cemu/otp.bin", + "size": 1024, "validation": ["size"]}]}, + }) + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + wiiu = Path(tmp) / "otp.bin" + wiiu.write_bytes(b"w" * 1024) + self.assertIsNone(check_file_validation(str(wiiu), "otp.bin", index, tmp, {"cemu"})) + self.assertIsNotNone(check_file_validation(str(wiiu), "otp.bin", index, tmp, {"azahar"})) + + def test_one_of_an_emulators_entries_is_enough(self): + from validation import check_file_validation # noqa: PLC0415 + + index = _build_validation_index({"raze": {"files": [ + {"name": "SW.GRP", "size": 100, "validation": ["size"]}, + {"name": "SW.GRP", "size": 200, "validation": ["size"]}, + ]}}) + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + grp = Path(tmp) / "SW.GRP" + grp.write_bytes(b"s" * 200) + self.assertIsNone(check_file_validation(str(grp), "SW.GRP", index, tmp, {"raze"})) + + +class DolphinForksJudgeTheirOwnDspTable(unittest.TestCase): + """VerifyRoms (Source/Core/Core/DSP/DSPCore.cpp) accepts, without a prompt, + Nintendo's pair and the newest free pair its table knows: v0.4 in + Dolphin, PrimeHack, MMJR2 and Triforce, v0.3.1 in MMJR and Ishiiruka, + whose table stops there. Adler-32 over the byte-swapped words.""" + + FREE_V04 = REPO_ROOT / "data" / "dolphin-sys" / "GC" / "dsp_rom.bin" + FREE_V031 = REPO_ROOT / "bios" / "Nintendo" / "GameCube" / "Sys" / "GC" / "dsp_rom.bin" + + def test_each_fork_accepts_its_own_free_rom(self): + from common import load_emulator_profiles # noqa: PLC0415 + from validation import check_file_validation # noqa: PLC0415 + + if not self.FREE_V04.is_file() or not self.FREE_V031.is_file(): + self.skipTest("free DSP ROMs not present") + profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) + expect = {"dolphin": "v04", "primehack": "v04", "dolphin-mmjr2": "v04", + "triforce": "v04", "dolphin-mmjr": "v031", "ishiiruka": "v031"} + for name, accepted in expect.items(): + index = _build_validation_index({name: profiles[name]}) + with self.subTest(emulator=name): + for label, path in (("v04", self.FREE_V04), ("v031", self.FREE_V031)): + verdict = check_file_validation( + str(path), "dsp_rom.bin", index, "bios", {name} + ) + self.assertEqual(verdict is None, label == accepted, label) if __name__ == "__main__": unittest.main()