diff --git a/scripts/validation.py b/scripts/validation.py index 544451f90..5a4272dd5 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -16,6 +16,7 @@ from common import ( adler32_of, archive_members, compute_hashes, + entry_system_ids, name_match_size_ok, sanitize_pack_path, size_fits, @@ -145,6 +146,72 @@ def _rule_failure( return None +def _found_by_content(entry: dict, profile: dict) -> bool: + """Whether every build of the core identifies this entry by content.""" + return not opens_by_name(entry, profile) and not opens_by_name( + entry, profile, standalone=True + ) + + +def _regions(entry: dict) -> set[str]: + value = entry.get("region") + return set(value) if isinstance(value, list) else {value} if value else set() + + +def content_alternatives(entry: dict, profile: dict) -> list[dict]: + """The labels a content-found core takes in place of this one. + + rustation scans its folder by sha256 (found_by: content): its + scph1001.bin is a label for v2.2, and the v2.0 dump ROCKNIX ships under + that name boots the same North American console, labelled + scph1001_v20.bin. Alternatives share the entry's system and declared + region, the slot slot.py groups by: openMSX also finds its ROMs by + sha1, but each one serves its own machine and declares no region. + """ + if not _found_by_content(entry, profile): + return [] + regions = _regions(entry) + if not regions: + return [] + systems = set(entry_system_ids(entry, profile)) + return [ + other for other in profile.get("files") or [] + if isinstance(other, dict) + and _found_by_content(other, profile) + and regions & _regions(other) + and systems == set(entry_system_ids(other, profile)) + ] + + +def _labels_widened(profile: dict) -> list[dict]: + """The profile's entries, each content-found label accepting the dumps + of its alternatives (content_alternatives).""" + widened = [] + for f in profile.get("files") or []: + if not isinstance(f, dict): + continue + siblings = content_alternatives(f, profile) + if len(siblings) < 2: + widened.append(f) + continue + merged = dict(f) + for field_name in ("crc32", "md5", "sha1", "sha256"): + if f.get(field_name): + merged[field_name] = list(dict.fromkeys( + value for other in siblings + for value in parse_hash_list(other.get(field_name)) + )) + if f.get("size") is not None: + merged["size"] = sorted({ + size for other in siblings + for size in (other["size"] if isinstance(other.get("size"), list) + else [other.get("size")]) + if size is not None + }) + widened.append(merged) + return widened + + def _build_validation_index(profiles: dict) -> dict[str, dict]: """Build per-filename validation rules from emulator profiles. @@ -170,7 +237,7 @@ def _build_validation_index(profiles: dict) -> dict[str, dict]: for emu_name, profile in profiles.items(): if profile.get("type") in ("launcher", "alias"): continue - for f in profile.get("files", []): + for f in _labels_widened(profile): fname = f.get("name", "") if not fname: continue diff --git a/tests/test_validation_scope.py b/tests/test_validation_scope.py index 87399f979..30c8946b4 100644 --- a/tests/test_validation_scope.py +++ b/tests/test_validation_scope.py @@ -47,5 +47,44 @@ class HleIndexScope(unittest.TestCase): self.assertIn("for profile in platform_profiles.values()", block) self.assertNotIn("profiles.values()", block.replace("platform_profiles.values()", "")) + +class ContentFoundLabelsTakeTheirRegionSlot(unittest.TestCase): + """rustation scans its folder by sha256, so its scph1001.bin entry is a + label. ROCKNIX ships the v2.0 dump under that name, which rustation + boots (labelled scph1001_v20.bin), and verify called it a mismatch.""" + + def test_an_alternative_dump_passes_under_the_label(self): + import hashlib + import sys + import tempfile + + sys.path.insert(0, str(REPO_ROOT / "scripts")) + from validation import _build_validation_index, check_file_validation + + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "scph1001.bin" + path.write_bytes(b"v2.0") + v20 = hashlib.sha256(b"v2.0").hexdigest() + profile = { + "found_by": "content", + "systems": ["sony-playstation"], + "files": [ + {"name": "scph1001.bin", "region": ["north-america"], + "validation": ["sha256"], "sha256": "a" * 64}, + {"name": "scph1001_v20.bin", "region": ["north-america"], + "validation": ["sha256"], "sha256": v20}, + {"name": "scph5500.bin", "region": ["japan"], + "validation": ["sha256"], "sha256": "b" * 64}, + ], + } + index = _build_validation_index({"rustation": profile}) + self.assertIsNone(check_file_validation(str(path), "scph1001.bin", index)) + # Another region is another slot. + self.assertIsNotNone(check_file_validation(str(path), "scph5500.bin", index)) + # A core that opens the file by name rejects it. + index = _build_validation_index({"rustation": {**profile, "found_by": "name"}}) + self.assertIsNotNone(check_file_validation(str(path), "scph1001.bin", index)) + + if __name__ == "__main__": unittest.main()