From 8fb65b8e97c726d21a8d44715788c1b001382dc4 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:45:57 +0200 Subject: [PATCH] fix: exporters keep what the maintainer wrote --- scripts/exporter/batocera_exporter.py | 53 +++++++++++++++---- scripts/exporter/bizhawk_exporter.py | 11 +++- scripts/exporter/retrodeck_exporter.py | 18 ++++--- tests/test_export_counts.py | 71 ++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 20 deletions(-) diff --git a/scripts/exporter/batocera_exporter.py b/scripts/exporter/batocera_exporter.py index 037f1d8b..21902a3c 100644 --- a/scripts/exporter/batocera_exporter.py +++ b/scripts/exporter/batocera_exporter.py @@ -62,10 +62,29 @@ class Exporter(BaseExporter): # The data block is a fraction of the file; the rest is the checker. return True - def _bios_files(self, files: list[NativeFile]) -> str: - return ", ".join(self._bios_items(files)) + def _bios_files(self, files: list[NativeFile], before: dict | None = None) -> str: + return ", ".join(self._bios_items(files, before)) - def _bios_items(self, files: list[NativeFile]) -> list[str]: + @staticmethod + def _runner_keys(item: dict | None) -> list[str]: + """The emulator and core checkBios reads through coreExists(). + + Not in our model, so they travel from the maintainer's own entry: a + rewritten item without them made the checker treat the BIOS as + wanted by every build (vectrex, tvc, macintosh, bk). + """ + return [ + f'"{key}": "{item[key]}"' + for key in ("emulator", "core") + if item and isinstance(item.get(key), str) + ] + + def _bios_items(self, files: list[NativeFile], before: dict | None = None) -> list[str]: + originals = { + str(item.get("file", "")): item + for item in (before or {}).get("biosFiles", []) + if isinstance(item, dict) + } parts: list[str] = [] for fe in files: # The platform states an unhashed file as an empty md5 rather @@ -80,14 +99,25 @@ class Exporter(BaseExporter): zipped = fe.native("zipped_file", "") if zipped: item.append(f'"zippedFile": "{zipped}"') + item.extend(self._runner_keys(originals.get(path))) parts.append("{ " + ", ".join(item) + " }") return parts - def _entry_line(self, system: NativeSystem, files: list[NativeFile]) -> str: + @staticmethod + def _own_entry(system: NativeSystem, before: dict | None) -> dict: + """The maintainer's entry for this system, out of the parsed pair.""" + inner = (before or {}).get(system.native_id) + return inner if isinstance(inner, dict) else {} + + def _entry_line( + self, system: NativeSystem, files: list[NativeFile], before: dict | None = None + ) -> str: + own = self._own_entry(system, before) + runner = "".join(f"{key}, " for key in self._runner_keys(own)) return ( f'{_INDENT}"{system.native_id}": ' - f'{{ "name": "{self.display_name(system)}", ' - f'"biosFiles": [ {self._bios_files(files)} ] }},' + f'{{ "name": "{self.display_name(system)}", {runner}' + f'"biosFiles": [ {self._bios_files(files, own)} ] }},' ) @staticmethod @@ -127,14 +157,17 @@ class Exporter(BaseExporter): ) -> list[str]: """One entry, keeping the layout and the notes it was written with.""" trailing, leading = self._item_notes(original) - items = self._bios_items(files) + before = self._parse_entry(original) + own = self._own_entry(system, before) + items = self._bios_items(files, own) one_line = len(original) == 1 and not trailing and not leading if one_line: - return [self._entry_line(system, files)] + return [self._entry_line(system, files, before)] + runner = "".join(f"{key}, " for key in self._runner_keys(own)) head = ( f'{_INDENT}"{system.native_id}": ' - f'{{ "name": "{self.display_name(system)}", "biosFiles": [' + f'{{ "name": "{self.display_name(system)}", {runner}"biosFiles": [' ) pad = " " * (len(_INDENT) + 4) lines = [head] @@ -244,8 +277,8 @@ class Exporter(BaseExporter): continue written.add(key) original_lines = body[start:end + 1] - replacement = self._entry_line(*pair) before = self._parse_entry(original_lines) + replacement = self._entry_line(*pair, before) after = self._parse_entry([replacement]) if before is not None and before == after: # Nothing changed: keep the maintainer's own lines, comments diff --git a/scripts/exporter/bizhawk_exporter.py b/scripts/exporter/bizhawk_exporter.py index f1f9c756..5b369dcc 100644 --- a/scripts/exporter/bizhawk_exporter.py +++ b/scripts/exporter/bizhawk_exporter.py @@ -82,8 +82,15 @@ class Exporter(BaseExporter): for fe in system.files: # A SHA1 BizHawk declares is BizHawk's own code, the authority # for its cores: the truth's MSX.rom came from FBNeo and would - # have made MSXHawk refuse the dump it accepts. - if fe.hash("sha1") and not _hash_values(fe.platform or {}, "sha1"): + # have made MSXHawk refuse the dump it accepts. An entry the + # database never declared (can_add refuses it) names no call + # of its own either: by name it would rewrite the homonym of + # another system. + if ( + fe.platform is not None + and fe.hash("sha1") + and not _hash_values(fe.platform, "sha1") + ): seen.setdefault(fe.name.casefold(), []).append(fe) resolved: dict[str, NativeFile] = {} for name, entries in seen.items(): diff --git a/scripts/exporter/retrodeck_exporter.py b/scripts/exporter/retrodeck_exporter.py index c6d937ae..2d44ef9a 100644 --- a/scripts/exporter/retrodeck_exporter.py +++ b/scripts/exporter/retrodeck_exporter.py @@ -176,8 +176,8 @@ class Exporter(BaseExporter): return nested, "bios" return None - @staticmethod - def _merge(existing: object, ours: list[OrderedDict]) -> list[OrderedDict]: + @classmethod + def _merge(cls, existing: object, ours: list[OrderedDict]) -> list[OrderedDict]: """Correct the component's own list; never replace it. Assigning our entries wholesale dropped every file RetroDECK declares @@ -206,6 +206,9 @@ class Exporter(BaseExporter): for system in _systems_of(entry): key = (str(entry.get("filename", "")), system) seen_keys[key] = seen_keys.get(key, 0) + 1 + # A hash no revision shares is a revision the platform does not + # know: appended, not counted corrected against entries kept as + # they were (the 64DD IPL declared twice, both contradicted). for entry in entries: keys = _matching_keys(entry, by_key) other_revision = ( @@ -213,18 +216,17 @@ class Exporter(BaseExporter): and seen_keys.get(keys[0], 0) > 1 and not _md5_set(entry.get("md5")) & _md5_set(by_key[keys[0]].get("md5")) ) - if other_revision: - merged.append(OrderedDict(entry)) - corrected.update(keys) - continue - if not keys: + if other_revision or not keys: merged.append(OrderedDict(entry)) continue declared = entry.get("system") combined = OrderedDict(entry) + # Only what the format says it corrects: the maintainer's + # description and search paths are theirs (neogeo.zip lost its + # four roms directories to a path that does not exist). combined.update( (field, value) for field, value in by_key[keys[0]].items() - if not (field == "system" and declared) + if field in cls.carries() and not (field == "system" and declared) ) merged.append(combined) corrected.update(keys) diff --git a/tests/test_export_counts.py b/tests/test_export_counts.py index c366e69f..81d1d09f 100644 --- a/tests/test_export_counts.py +++ b/tests/test_export_counts.py @@ -355,5 +355,76 @@ class RetroPieProposals(unittest.TestCase): self.assertIsNone(RetroPie._insertion_point(alternatives)) self.assertIsNotNone(RetroPie._insertion_point(enumeration)) + +class BizHawkRewritesOnlyWhatItDeclares(unittest.TestCase): + def test_a_truth_only_homonym_does_not_rewrite_a_declared_call(self): + """opera's panafz10-norsa.bin, absent from BizHawk's list, would have + rewritten the 3DO call of the same name by index on the name alone.""" + sha_bizhawk, sha_other = "4" * 40, "e" * 40 + declared = NativeFile("bios.bin", "bios.bin", "SYSB", platform={"sha1": sha_bizhawk}) + truth_only = NativeFile("bios.bin", "bios.bin", "sys-a", truth={"sha1": sha_other}) + exporter = BizHawk() + source = f'File("{sha_bizhawk.upper()}", 10, "bios.bin")\n' + produced = exporter.render( + {"SYSB": NativeSystem("SYSB", files=[declared]), + "sys-a": NativeSystem("sys-a", files=[truth_only])}, + None, {exporter.native_filename(): source}, + ) + self.assertIn(sha_bizhawk.upper(), produced[exporter.native_filename()]) + self.assertNotIn(sha_other.upper(), produced[exporter.native_filename()]) + + +class RetroDeckCorrectsOnlyWhatItCarries(unittest.TestCase): + def test_paths_and_description_stay_the_maintainers(self): + """neogeo.zip lost its four search directories to a path that does + not exist, and 26 descriptions were rewritten.""" + from collections import OrderedDict # noqa: PLC0415 + + existing = [{"filename": "neogeo.zip", "system": "neogeo", "md5": A, + "paths": ["$roms_path/neogeo", "$roms_path/fbneo", "$bios_path"], + "description": "Neo Geo BIOS"}] + ours = [OrderedDict([("filename", "neogeo.zip"), ("md5", B), ("system", "neogeo"), + ("description", "generated"), ("paths", "$bios_path/roms/neogeo")])] + merged = RetroDeck._merge(existing, ours) + self.assertEqual(merged[0]["md5"], B) + self.assertEqual(merged[0]["paths"], ["$roms_path/neogeo", "$roms_path/fbneo", "$bios_path"]) + self.assertEqual(merged[0]["description"], "Neo Geo BIOS") + + def test_a_hash_no_revision_shares_is_appended(self): + """IPL.n64 declared twice, both contradicted: counted corrected, + written nowhere.""" + from collections import OrderedDict # noqa: PLC0415 + + existing = [{"filename": "IPL.n64", "system": "n64dd", "md5": A}, + {"filename": "IPL.n64", "system": "n64dd", "md5": B}] + ours = [OrderedDict([("filename", "IPL.n64"), ("md5", C), ("system", "n64dd")])] + merged = RetroDeck._merge(existing, ours) + self.assertEqual([e["md5"] for e in merged], [A, B, C]) + + +class BatoceraKeepsTheRunnerKeys(unittest.TestCase): + def test_emulator_and_core_travel_with_a_rewritten_entry(self): + """checkBios skips a BIOS whose emulator or core is not installed; + rewritten without the keys, vectrex's MAME BIOS was wanted by every + build.""" + from exporter.batocera_exporter import Exporter as Batocera # noqa: PLC0415 + + fe = NativeFile("bios.bin", "bios.bin", "vectrex", platform={"md5": A}, + truth={"md5": B}, corrections=["md5"]) + fe.native_data = {"native_path": "bios/bios.bin"} + system = NativeSystem("vectrex", files=[fe]) + original = ( + ' "vectrex": { "name": "Vectrex", "emulator": "libretro", "core": "mame", ' + '"biosFiles": [ { "md5": "' + A + '", "file": "bios/bios.bin", ' + '"emulator": "libretro", "core": "mame" } ] },' + ) + exporter = Batocera() + line = exporter._entry_line(system, [fe], exporter._parse_entry([original])) + parsed = exporter._parse_entry([line])["vectrex"] + self.assertEqual((parsed["emulator"], parsed["core"]), ("libretro", "mame")) + self.assertEqual(parsed["biosFiles"][0]["core"], "mame") + self.assertEqual(parsed["biosFiles"][0]["md5"], B) + + if __name__ == "__main__": unittest.main()