diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index d77c650d..34855165 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -44,7 +44,7 @@ from common import ( select_emulator_profiles, ) from hashing import parse_hash_list -from validation import declaration_settles, outside_gap_scope +from validation import case_folded_slots, declaration_settles, outside_gap_scope yaml = require_yaml() @@ -105,6 +105,25 @@ def declared_names( ) +def folded_declared_slots( + platforms_dir: str, platforms: list[str] | None, db: dict +) -> frozenset[str]: + """The slots platforms whose filesystem folds case fill, any spelling. + + RetroBat's 32X_G_BIOS.BIN is the file BlastEm opens as 32X_G_BIOS.bin, + and the exact-case name test called it undeclared. + """ + from common import platform_declarations + + slots: set[str] = set() + for platform_name in platforms or list_registered_platforms( + platforms_dir, include_archived=True + ): + config = load_platform_config(platform_name, platforms_dir) + slots |= case_folded_slots(config, platform_declarations(config, db)) + return frozenset(slots) + + def _build_supplemental_index( data_root: str = "data", bios_root: str = "bios" ) -> set[str]: @@ -631,7 +650,9 @@ def _cross_reference_profile( # Check platform declaration: by name, by an alias the core answers # to (RetroArch lists amiberry's cd32.rom as kick40060.CD32), or by # archive. - in_platform = declaration_settles(f, fname, platform_names, effective_path) + in_platform = declaration_settles( + f, fname, platform_names, effective_path, index.get("folded_slots", frozenset()) + ) if in_platform: seen_files.add(seen_key) @@ -738,6 +759,7 @@ def cross_reference( data_names: set[str] | None = None, all_declared: dict[str, list[tuple[int | None, str]]] | None = None, standalone_cores: set[str] | None = None, + folded_slots: frozenset[str] = frozenset(), ) -> dict: """Compare emulator profiles against platform declarations. @@ -774,6 +796,7 @@ def cross_reference( "db_files": db_files, "data_names": data_names, "all_declared": all_declared, + "folded_slots": folded_slots, "bios_dir": db.get("bios_dir"), } for emu_name, profile in profiles.items(): @@ -958,6 +981,7 @@ def main() -> None: profiles, declared, db, plat_data_dirs, data_names, all_declared=declared_names(args.platforms_dir, selected, db), standalone_cores=standalone_cores, + folded_slots=folded_declared_slots(args.platforms_dir, selected, db), ) if args.json: diff --git a/scripts/validation.py b/scripts/validation.py index 8aa7e54b..d4529bf5 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -465,11 +465,25 @@ def outside_gap_scope(file_entry: dict, is_standalone: bool) -> bool: ) +def case_folded_slots( + config: dict, declarations: Mapping[str, list[tuple[int | None, str]]], +) -> frozenset[str]: + """The slots a platform whose filesystem folds case fills, any spelling. + + On RetroBat the 32X_G_BIOS.BIN it declares is the file BlastEm opens as + 32X_G_BIOS.bin. On Lakka bios.col and BIOS.col are two files: nothing. + """ + if not config.get("case_insensitive_fs", False): + return frozenset() + return frozenset(where for places in declarations.values() for _size, where in places) + + def declaration_settles( file_entry: dict, fname: str, declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str], dest: str = "", + folded_slots: frozenset[str] = frozenset(), ) -> bool: """Whether a platform declaration meets a core's requirement. @@ -486,12 +500,16 @@ def declaration_settles( carries no slot or size and answers by name. Read by verify and by the gap report, which settled on the name alone - and left DOSBox's SC-55 ROMs off the gaps page. + and left DOSBox's SC-55 ROMs off the gaps page. ``folded_slots`` + (case_folded_slots) are slots a declaration fills in another spelling on + a filesystem that folds case: the core opens that file there. """ if file_entry.get("archive") in declared_names: return True slot = sanitize_pack_path(dest or fname).lower() directory = slot if dest.endswith("/") else slot.rpartition("/")[0] + if not dest.endswith("/") and slot in folded_slots: + return True for name in (fname, *file_entry.get("aliases", [])): if name not in declared_names: continue diff --git a/scripts/verify.py b/scripts/verify.py index a309bbe2..1721800a 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -86,6 +86,7 @@ from nativemode import ( ) from validation import ( baseline_verdict, + case_folded_slots, declaration_settles, frontend_digest_matches, existence_discrepancy, @@ -420,6 +421,7 @@ def _candidate_verdict( include_all: bool, declared_names: Mapping[str, list[tuple[int | None, str]]] | set[str], dest: str = "", + folded_slots: frozenset[str] = frozenset(), ) -> str: """Whether a profile entry can be a gap, and whether it is settled. @@ -435,7 +437,7 @@ def _candidate_verdict( if outside_gap_scope(file_entry, is_standalone): return "skip" if not include_all and declaration_settles( - file_entry, fname, declared_names, dest + file_entry, fname, declared_names, dest, folded_slots ): return "settled" return "keep" @@ -479,6 +481,7 @@ def find_undeclared_files( """ if declared_names is None: declared_names = platform_declarations(config, db) + folded_slots = case_folded_slots(config, declared_names) # Whether the builder drops a file whose local copy contradicts its # declared hash, which decides if such a copy counts as held here. @@ -568,7 +571,8 @@ def find_undeclared_files( if duplicate and (first is None or first["profile"] == emu_name): continue verdict = _candidate_verdict( - f, fname, is_standalone, include_all, declared_names, dest + f, fname, is_standalone, include_all, declared_names, dest, + folded_slots, ) if verdict == "settled": seen_files.add(seen_key) diff --git a/tests/test_gap_settlement.py b/tests/test_gap_settlement.py index 0646afd9..d86c4c4a 100644 --- a/tests/test_gap_settlement.py +++ b/tests/test_gap_settlement.py @@ -15,7 +15,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "scripts")) from cross_reference import cross_reference # noqa: E402 -from validation import declaration_settles # noqa: E402 +from validation import case_folded_slots, declaration_settles # noqa: E402 SC55 = {"name": "ROM1.BIN", "path": "SC-55/ROM1.BIN", "size": 32768, "validation": ["size"], "required": True} @@ -45,6 +45,33 @@ class OneRule(unittest.TestCase): self.assertIn("declaration_settles(", source, module) +class ACaseFoldingFilesystem(unittest.TestCase): + """RetroBat declares 32X_G_BIOS.BIN, BlastEm opens 32X_G_BIOS.bin at the + same place. The exact-case name test reported it undeclared before the + slot was ever compared.""" + + declared = {"32X_G_BIOS.BIN": [(256, "32x_g_bios.bin")]} + entry = {"name": "32X_G_BIOS.bin"} + + def test_the_slot_settles_it_where_case_folds(self): + folded = case_folded_slots({"case_insensitive_fs": True}, self.declared) + self.assertTrue( + declaration_settles(self.entry, "32X_G_BIOS.bin", self.declared, "", folded) + ) + + def test_two_spellings_are_two_files_elsewhere(self): + folded = case_folded_slots({"case_insensitive_fs": False}, self.declared) + self.assertFalse( + declaration_settles(self.entry, "32X_G_BIOS.bin", self.declared, "", folded) + ) + + def test_another_place_settles_nothing(self): + folded = case_folded_slots({"case_insensitive_fs": True}, self.declared) + self.assertFalse(declaration_settles( + self.entry, "32X_G_BIOS.bin", self.declared, "blastem/32X_G_BIOS.bin", folded + )) + + class TheRunningBuildPlacesTheGap(unittest.TestCase): """Batocera runs Cemu standalone, which reads keys.txt at its standalone_path. The gap report gave the libretro Cemu/keys.txt while diff --git a/tests/test_hidden_extras.py b/tests/test_hidden_extras.py index 66a92d33..222b018f 100644 --- a/tests/test_hidden_extras.py +++ b/tests/test_hidden_extras.py @@ -98,7 +98,7 @@ class TheReportSaysSo(unittest.TestCase): name: verify_platform( load_platform_config(name, "platforms"), db, "emulators", profiles ) - for name in ("retroarch", "retrobat") + for name in ("retroarch", "retrobat", "lakka") } finally: os.chdir(previous) @@ -109,16 +109,28 @@ class TheReportSaysSo(unittest.TestCase): return u self.skipTest(f"{emulator} {path} is not in the cross-reference") - def test_a_case_twin_with_other_bytes_is_left_out(self): - entry = self._entry("NP2kai", "np2kai/bios.rom", "retrobat") - self.assertIs(entry.get("in_pack"), False) - self.assertIn("another case", entry.get("omitted", "")) + def _listed(self, emulator: str, path: str, platform: str) -> bool: + return any( + u.get("emulator") == emulator and (u.get("path") or u.get("name")) == path + for u in self.results[platform]["undeclared_files"] + ) - def test_a_twin_with_the_same_bytes_serves_the_core(self): - # MesenCE opens bios.col, and RetroArch's pack folds it onto BIOS.col, - # the same bytes, which serves the core on a folding filesystem. - entry = self._entry("MesenCE", "bios.col") - self.assertIsNot(entry.get("in_pack"), False) + def test_a_declaration_in_another_case_settles_the_slot(self): + """On a filesystem that folds case the platform's file at the slot is + the one the core opens, whatever its spelling, and the slot + arbitration judges its bytes. Listed as a core file, RetroBat's + 32X_G_BIOS.BIN counted BlastEm's 32X_G_BIOS.bin in the pack twice.""" + for platform, emulator, path in ( + ("retrobat", "NP2kai", "np2kai/bios.rom"), + ("retrobat", "BlastEm", "32X_G_BIOS.bin"), + ("retroarch", "MesenCE", "bios.col"), + ): + with self.subTest(platform=platform, path=path): + self.assertFalse(self._listed(emulator, path, platform)) + + def test_a_filesystem_keeping_both_names_reports_the_other(self): + """Lakka runs on Linux, where bios.col and BIOS.col are two files.""" + self.assertTrue(self._listed("MesenCE", "bios.col", "lakka")) def test_a_directory_under_a_packed_file_is_left_out(self): entry = self._entry("nSide (SFC Balanced)", "SGB1.sfc/lr35902.boot.rom")