From 5b083c45b5f11f617966fa8d757f859d9d81fd1e Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:06:58 +0200 Subject: [PATCH] feat: export md5 lists the pinned checker reads The script exporters (Batocera, ROCKNIX, KNULLI, AmberELEC, EmuELEC) read the checker of the pinned original: when checkBios hands an item's md5 to a function that tests it with isinstance(..., list), as md5Matches does since batocera.linux 6b08b873, an entry whose emulator code accepts several dumps is written with all of them, and an addition that needed several becomes writable. Items with altmd5 keep a string, since checkBios wraps them in a list of its own. No cached original reads lists today, so every export is unchanged. The report records a list as a list, counts the lists written, and records a list the truth extends without contradicting it. --- scripts/export_native.py | 47 ++++- scripts/exporter/amberelec_exporter.py | 7 +- scripts/exporter/base_exporter.py | 13 +- scripts/exporter/batocera_exporter.py | 125 +++++++++++-- scripts/siteexports.py | 3 + tests/test_batocera_md5_list.py | 235 ++++++++++++++++++++++++- tests/test_export_counts.py | 8 +- wiki/tools.md | 11 ++ 8 files changed, 424 insertions(+), 25 deletions(-) diff --git a/scripts/export_native.py b/scripts/export_native.py index 79e90cdd..b198dd10 100644 --- a/scripts/export_native.py +++ b/scripts/export_native.py @@ -483,6 +483,15 @@ class LandedChanges: def requirements(self) -> int: return sum(r["kind"] == "requirement_corrected" for r in self.records) + @property + def lists(self) -> int: + """Entries whose md5 is written as a list of accepted values.""" + return sum( + isinstance(r["new"], list) + or (isinstance(r["new"], dict) and isinstance(r["new"].get("md5"), list)) + for r in self.records + ) + def _change( kind: str, entry: object, field_name: str | None, old: object, new: object @@ -498,8 +507,18 @@ def _change( } -def _platform_value(entry: object, field_name: str) -> str: - return ",".join(_hash_values(entry.platform or {}, field_name)) +def _platform_value(entry: object, field_name: str) -> str | list[str]: + values = _hash_values(entry.platform or {}, field_name) + if (entry.platform or {}).get(f"{field_name}_list"): + return values + return ",".join(values) + + +def _written_value(exporter: object, entry: object, field_name: str) -> str | list[str]: + """A hash field as the render writes it: a list where the checker reads + one, so the report never shows several values as one.""" + listed = exporter.written_list(entry, field_name) + return listed if listed is not None else exporter.written(entry, field_name) def _added_fields(exporter: object, entry: object) -> dict: @@ -508,7 +527,7 @@ def _added_fields(exporter: object, entry: object) -> dict: written: dict[str, object] = { name: value for name in HASH_FIELDS - if name in carried and (value := exporter.written(entry, name)) + if name in carried and (value := _written_value(exporter, entry, name)) } if "size" in carried and entry.size() is not None: written["size"] = entry.size() @@ -560,11 +579,27 @@ def landed_changes(exporter: object, systems: dict) -> LandedChanges: records.append(_change( "hash_corrected", entry, field_name, _platform_value(entry, field_name), - exporter.written(entry, field_name), + _written_value(exporter, entry, field_name), )) + # A list the truth extends with values the platform's own lacks: + # no contradiction, so no correction in the model, and still a + # new value in the file. + for field_name in HASH_FIELDS: + listed = exporter.written_list(entry, field_name) + if ( + listed is not None + and field_name not in entry.corrections + and field_name not in entry.filled + and listed != _hash_values(entry.platform, field_name) + and exporter.states(entry, field_name) + ): + records.append(_change( + "hash_corrected", entry, field_name, + _platform_value(entry, field_name), listed, + )) records.extend( _change("hash_filled", entry, field_name, None, - exporter.written(entry, field_name)) + _written_value(exporter, entry, field_name)) for field_name in entry.filled if exporter.fills(entry, field_name) ) @@ -671,6 +706,7 @@ def export_platform( resized, cleared = changes.resized, changes.cleared unsized = changes.unsized requirements = changes.requirements + lists = changes.lists landed = 0 refused = 0 @@ -703,6 +739,7 @@ def export_platform( (len(unsized), "size", "", "removed"), (len(cleared), "hash", "", "removed"), (lost, "entry", "entries", "removed, the format cannot state them"), + (lists, "md5 list", "", "written, any listed value passes"), ) parts += [ f"{_count(number, noun, plural)} {verb}" diff --git a/scripts/exporter/amberelec_exporter.py b/scripts/exporter/amberelec_exporter.py index 67deba77..23989cdd 100644 --- a/scripts/exporter/amberelec_exporter.py +++ b/scripts/exporter/amberelec_exporter.py @@ -9,7 +9,6 @@ every build, and an addition is written the same way. from __future__ import annotations -from .base_exporter import BaseExporter from .baseline import NativeFile from .batocera_exporter import Exporter as BatoceraExporter @@ -24,10 +23,8 @@ class Exporter(BatoceraExporter): PATH_PREFIX = "" - @classmethod - def writable(cls, fe: NativeFile, require: str = "") -> bool: - # The list gates nothing on a runner, so an addition needs none. - return BaseExporter.writable.__func__(cls, fe, require) + # The list gates nothing on a runner, so an addition needs none. + RUNNER_GATED = False def _addition_keys(self, fe: NativeFile) -> list[tuple[str, str | None]] | None: return [] diff --git a/scripts/exporter/base_exporter.py b/scripts/exporter/base_exporter.py index b08f9866..e2e3534a 100644 --- a/scripts/exporter/base_exporter.py +++ b/scripts/exporter/base_exporter.py @@ -171,11 +171,16 @@ class BaseExporter(ABC): hands back a file poorer than the one it corrects. The requirement only gates what we would be adding. """ + return cls._carried(fe, require, cls.SINGLE_HASH_FIELD) + + @classmethod + def _carried(cls, fe: NativeFile, require: str, single: str | None) -> bool: + """writable(), with the field held to one value named by the caller: + a checker that reads a list for an entry holds it to none.""" if fe.platform is not None: return True if not cls.can_add() or (cls.keys_by_system() and fe.foreign_system): return False - single = cls.SINGLE_HASH_FIELD if single and len(set(fe.hashes(single))) > 1: # One value checked: written with the first, the addition # refuses every other dump the core accepts. @@ -207,6 +212,12 @@ class BaseExporter(ABC): return self.single_value(fe, field_name) return ",".join(fe.hashes(field_name)) + def written_list(self, fe: NativeFile, field_name: str) -> list[str] | None: + """The values of a hash field the render writes as a list, None when + it writes one string. The report shows a list as a list: joined, it + reads as one value the checker never compares.""" + return None + def required_value(self, fe: NativeFile) -> bool: """The requirement the render writes for an entry. diff --git a/scripts/exporter/batocera_exporter.py b/scripts/exporter/batocera_exporter.py index 5a85c658..6d877263 100644 --- a/scripts/exporter/batocera_exporter.py +++ b/scripts/exporter/batocera_exporter.py @@ -58,6 +58,80 @@ def _closing_line(text: str, start: int) -> int | None: return None +def _md5_subscript(node: ast.expr) -> bool: + """`file["md5"]`: an item's md5 as checkBios reads it.""" + return ( + isinstance(node, ast.Subscript) + and isinstance(node.slice, ast.Constant) + and node.slice.value == "md5" + ) + + +def _names_member(call: ast.Call) -> bool: + """Whether a call also passes the item's zippedFile.""" + return any( + isinstance(arg, ast.Subscript) + and isinstance(arg.slice, ast.Constant) + and arg.slice.value == "zippedFile" + for arg in call.args + ) + + +def _list_parameters(function: ast.FunctionDef) -> set[str]: + """Parameters the function tests with `isinstance(, list)`.""" + names = {arg.arg for arg in function.args.args} + return { + node.args[0].id + for node in ast.walk(function) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "isinstance" + and len(node.args) == 2 + and isinstance(node.args[0], ast.Name) + and node.args[0].id in names + and isinstance(node.args[1], ast.Name) + and node.args[1].id == "list" + } + + +def md5_list_support(script: str) -> frozenset[str]: + """The items whose md5 the script's checker reads as a list. + + "file" when checkBios hands a plain file's md5 to a function that takes + a list (batocera.linux 6b08b873: `md5Matches(md5, file["md5"])`), + "zippedFile" when it does so for a member of an archive + (`checkInsideZip(filepath, file["zippedFile"], file["md5"])`). Read from + the parsed checker, never executed and never from a version number: + a fork can take the change at any revision, or never. + """ + try: + tree = ast.parse(script) + except SyntaxError: + return frozenset() + functions = { + node.name: node for node in tree.body if isinstance(node, ast.FunctionDef) + } + check = functions.get("checkBios") + if check is None: + return frozenset() + support: set[str] = set() + for call in ast.walk(check): + if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)): + continue + callee = functions.get(call.func.id) + if callee is None: + continue + takes_list = _list_parameters(callee) + parameters = [arg.arg for arg in callee.args.args] + if any( + _md5_subscript(arg) and index < len(parameters) + and parameters[index] in takes_list + for index, arg in enumerate(call.args) + ): + support.add("zippedFile" if _names_member(call) else "file") + return frozenset(support) + + def _md5_literal(value: str | list[str], spaced: bool = True) -> str: """An md5 as the script writes it: a quoted string, or a list of them with Batocera's spacing inside the brackets (`[ "a", "b" ]`).""" @@ -82,9 +156,14 @@ class Exporter(BaseExporter): # checkBios compares one md5 for a plain file. altmd5 is read only for # a zippedFile: retrodream's boot.bin written with the first of four # md5 refused the three other dumps the core accepts. An item the - # platform writes with a list of md5 keeps its list (md5_list). + # platform writes with a list of md5 keeps its list (md5_list), and a + # checker that reads lists (md5_list_support) gets every accepted md5. SINGLE_HASH_FIELD = "md5" + # Whether an addition must name the emulator and core checkBios gates + # it on. + RUNNER_GATED = True + # checkBios reads emulator names from es_systems, and Batocera's # standalone_cores are those names. ROCKNIX's list holds package names # (duckstation-sa), which its checker does not know. @@ -100,6 +179,9 @@ class Exporter(BaseExporter): # The emulator and core each system entry of the original is gated # on, read by render. self._gates: dict[str, tuple[str, str] | None] = {} + # The items the pinned checker reads an md5 list for, read by + # render from the platform's own file. + self._md5_lists: frozenset[str] = frozenset() @staticmethod def platform_name() -> str: @@ -121,15 +203,15 @@ class Exporter(BaseExporter): def carries() -> frozenset[str]: return frozenset({"md5"}) - @classmethod - def writable(cls, fe: NativeFile, require: str = "") -> bool: + def writable(self, fe: NativeFile, require: str = "") -> bool: """An addition the format can state whole.""" - if fe.platform is None and not cls._usable_runners(fe): + if fe.platform is None and self.RUNNER_GATED and not self._usable_runners(fe): # Written without its core's emulator and core, checkBios asks # every build for it (the defect _runner_keys repairs for the # platform's own items). return False - return super().writable(fe, require) + single = None if self.lists_accepted(fe) else self.SINGLE_HASH_FIELD + return self._carried(fe, require, single) @classmethod def _usable_runners(cls, fe: NativeFile) -> list[tuple[bool, str]]: @@ -178,11 +260,33 @@ class Exporter(BaseExporter): return False return fe.platform is not None or self._addition_keys(fe) is not None - @staticmethod - def md5_list(fe: NativeFile) -> bool: - """Whether the platform writes this item's md5 as a list. Its - checker then accepts any listed value, so every one is written.""" - return fe.platform is not None and bool(fe.platform.get("md5_list")) + def md5_list(self, fe: NativeFile) -> bool: + """Whether this item's md5 is written as a list: the platform wrote + it so, or the truth accepts several and the checker reads a list.""" + if fe.platform is not None and fe.platform.get("md5_list"): + return True + return self.lists_accepted(fe) + + def lists_accepted(self, fe: NativeFile) -> bool: + """Whether the pinned checker reads a list for this item and the + truth accepts more than one md5 for it. + + A single md5 stays a string, as the maintainers write it. An item + with altmd5 stays one string: checkBios hands + `[file["md5"], file["altmd5"]]` to checkInsideZip, and a list there + nests a list no digest equals. + """ + kind = "zippedFile" if fe.native("zipped_file", "") else "file" + return ( + kind in self._md5_lists + and not fe.native("alt_md5", "") + and len(set(fe.hashes("md5"))) > 1 + ) + + def written_list(self, fe: NativeFile, field_name: str) -> list[str] | None: + if field_name == "md5" and self.md5_list(fe): + return fe.hashes("md5") + return None def md5_value(self, fe: NativeFile) -> str | list[str]: """The md5 an item is written with, in the form the platform used.""" @@ -525,6 +629,7 @@ class Exporter(BaseExporter): "script, and the rest of it is the checker" ) + self._md5_lists = md5_list_support(original) head, body, tail = self._split_original(original) spans = self._entry_spans(body) for key, (start, end) in spans.items(): diff --git a/scripts/siteexports.py b/scripts/siteexports.py index 2e1f691a..f8c7a7c2 100644 --- a/scripts/siteexports.py +++ b/scripts/siteexports.py @@ -40,6 +40,9 @@ CHANGE_RECORDS = { def _value(value: object) -> str: + """One value in code, or a list the checker reads as any of its values.""" + if isinstance(value, list): + return " or ".join(f"`{v}`" for v in value) if value else "`[]`" return f"`{value}`" diff --git a/tests/test_batocera_md5_list.py b/tests/test_batocera_md5_list.py index fcd2eeb0..223d2add 100644 --- a/tests/test_batocera_md5_list.py +++ b/tests/test_batocera_md5_list.py @@ -21,7 +21,9 @@ sys.path.insert(0, str(REPO_ROOT / "scripts")) from common import resolve_local_file # noqa: E402 from exporter import discover_exporters # noqa: E402 -from exporter.baseline import build_native_model # noqa: E402 +from export_native import landed_changes # noqa: E402 +from exporter.baseline import NativeFile, build_native_model # noqa: E402 +from exporter.batocera_exporter import md5_list_support # noqa: E402 from scraper import rocknix_scraper # noqa: E402 from scraper.base_scraper import md5_field, requirement_entry # noqa: E402 from scraper.batocera_scraper import ( # noqa: E402 @@ -30,6 +32,7 @@ from scraper.batocera_scraper import ( # noqa: E402 platform_systems, requirements_from_systems, ) +from siteexports import describe_change # noqa: E402 from verify import Status, verify_entry_md5 # noqa: E402 A = "dc2b9bf8da62ec93e868cfd29f0d067d" @@ -204,5 +207,235 @@ class VerifyAcceptsAnyListedMd5(unittest.TestCase): self.assertEqual(verify_entry_md5(entry, str(archive))["status"], Status.OK) +# checkBios as batocera.linux 6b08b873 writes it: a plain file's md5 goes +# through md5Matches, a member's through checkInsideZip, both reading lists. +LIST_CHECKER = """def md5Matches(computed, expected): + if isinstance(expected, list): + expectedList = expected + else: + expectedList = [expected] + return computed in expectedList + +def checkInsideZip(container, fileName, md5sums): + if not isinstance(md5sums, list): + md5sums = [md5sums] + import zipfile + from hashlib import md5 + with zipfile.ZipFile(container) as archive: + for fname in archive.namelist(): + if fname.casefold() == fileName.casefold(): + if md5sums == [""] or md5sums == []: + return True + return md5(archive.read(fname)).hexdigest() in md5sums + return False + +def checkBios(systems, prefix, filterROMs): + from hashlib import md5 + from os.path import isfile + missingBios = {} + for system in systems.keys(): + for file in systems[system]["biosFiles"]: + filepath = prefix + "/" + file["file"] + if not isfile(filepath): + missingBios.setdefault(system, {})[file["file"]] = "MISSING" + elif not "zippedFile" in file: + with open(filepath, "rb") as handle: + digest = md5(handle.read()).hexdigest() + if not md5Matches(digest, file["md5"]) and file["md5"] != "" and file["md5"] != []: + missingBios.setdefault(system, {})[file["file"]] = "UNTESTED" + elif 'altmd5' in file: + if not checkInsideZip(filepath, file["zippedFile"], [file["md5"], file["altmd5"]]): + missingBios.setdefault(system, {})[file["file"]] = "UNTESTED" + elif not checkInsideZip(filepath, file["zippedFile"], file["md5"]): + missingBios.setdefault(system, {})[file["file"]] = "UNTESTED" + return missingBios +""" + +# batocera-43.1's checker: one md5, compared with `!=`. +STRING_CHECKER = """def checkInsideZip(container, fileName, md5sum): + return md5sum == "" + +def checkBios(systems, prefix, filterROMs): + missingBios = {} + for system in systems.keys(): + for file in systems[system]["biosFiles"]: + if "zippedFile" in file: + checkInsideZip(prefix, file["zippedFile"], file["md5"]) + elif "" != file["md5"]: + missingBios[system] = file["file"] + return missingBios +""" + +DATA = SCRIPT.split("def checkBios")[0] +LISTING = DATA + LIST_CHECKER +STRINGS = DATA + STRING_CHECKER +D = "e" * 32 +F = "f" * 32 +SECOND = hashlib.md5(b"second").hexdigest() + + +class ListSupportIsReadFromTheChecker(unittest.TestCase): + def test_the_list_checker_reads_lists_for_both_kinds(self): + self.assertEqual(md5_list_support(LISTING), {"file", "zippedFile"}) + + def test_the_string_checker_reads_none(self): + self.assertEqual(md5_list_support(STRINGS), frozenset()) + self.assertEqual(md5_list_support(SCRIPT), frozenset()) + + def test_a_list_test_on_another_parameter_is_not_support(self): + checker = LIST_CHECKER.replace( + "isinstance(expected, list)", "isinstance(computed, list)" + ) + self.assertEqual(md5_list_support(DATA + checker), {"zippedFile"}) + + def test_a_script_that_does_not_parse_reads_none(self): + self.assertEqual(md5_list_support(LISTING + "\ndef (\n"), frozenset()) + + def test_the_cached_originals(self): + """The pins cached today (batocera-43.1 and the forks) read one md5.""" + root = REPO_ROOT / ".cache" / "upstream-native" + scripts = [ + root / name / filename + for name, filename in ( + ("batocera", "batocera-systems"), ("rocknix", "rocknix-systems"), + ("amberelec", "batocera-systems"), ("emuelec", "batocera-systems"), + ("knulli", "knulli-systems"), + ) + if (root / name / filename).is_file() + ] + if not scripts: + self.skipTest(".cache/upstream-native/ is not filled") + for path in scripts: + text = path.read_text(encoding="utf-8", errors="replace") + expected = frozenset() if "md5Matches" not in text else {"file"} + with self.subTest(path.parent.name): + self.assertLessEqual(expected, md5_list_support(text)) + + +class SeveralAcceptedMd5BecomeAList(unittest.TestCase): + """Every md5 the truth accepts, once the pinned checker reads a list.""" + + CORRECTED = { + "name": "scph5500.bin", "path": "scph5500.bin", "md5": [C, D], + "_cores": ["pcsx_rearmed"], + } + ADDED = { + "name": "scph7001.bin", "path": "scph7001.bin", "md5": [D, F], + "_cores": ["pcsx_rearmed"], + } + + @staticmethod + def _export(truth_files: list[dict], script: str): + scraped = _scraped(script) + scraped["cores"] = ["pcsx_rearmed"] + systems, report = build_native_model( + {"systems": {"sony-playstation": {"files": truth_files}}}, scraped + ) + exporter = discover_exporters()["batocera"]() + produced = exporter.render( + systems, report, {"batocera-systems": script}, scraped + )["batocera-systems"] + files = {fe.name: fe for system in systems.values() for fe in system.files} + return produced, landed_changes(exporter, systems), exporter, files + + def test_a_string_item_with_several_accepted_becomes_a_list(self): + produced, changes, _, _ = self._export([self.CORRECTED], LISTING) + self.assertEqual( + produced, LISTING.replace(f'"md5": "{C}"', f'"md5": [ "{C}", "{D}" ]') + ) + record = next(r for r in changes.records if r["kind"] == "hash_corrected") + self.assertEqual((record["old"], record["new"]), (C, [C, D])) + self.assertEqual(changes.lists, 1) + + def test_an_addition_with_several_accepted_is_written_as_a_list(self): + produced, changes, exporter, files = self._export([self.ADDED], LISTING) + self.assertTrue(exporter.lands(files["scph7001.bin"])) + self.assertIn( + f'{{ "md5": [ "{D}", "{F}" ], "file": "bios/scph7001.bin", ' + '"emulator": "libretro", "core": "pcsx_rearmed" }', + produced, + ) + record = next(r for r in changes.records if r["kind"] == "added") + self.assertEqual(record["new"]["md5"], [D, F]) + self.assertEqual(changes.lists, 1) + + def test_an_empty_md5_is_filled_with_the_list(self): + """Left empty while one value was all the item could hold.""" + produced, changes, _, _ = self._export([self.CORRECTED], LISTING.replace(C, "")) + self.assertEqual( + produced, LISTING.replace(f'"md5": "{C}"', f'"md5": [ "{C}", "{D}" ]') + ) + record = next(r for r in changes.records if r["kind"] == "hash_filled") + self.assertEqual(record["new"], [C, D]) + strings = STRINGS.replace(C, "") + self.assertEqual(self._export([self.CORRECTED], strings)[0], strings) + + def test_one_accepted_md5_stays_a_string(self): + produced, changes, _, _ = self._export([dict(self.CORRECTED, md5=D)], LISTING) + self.assertEqual(produced, LISTING.replace(C, D)) + self.assertEqual(changes.lists, 0) + + def test_without_the_checker_the_export_is_unchanged(self): + """The string checker keeps the first md5 and refuses the addition, + as every export did before the checker read lists.""" + produced, changes, exporter, files = self._export( + [self.CORRECTED, self.ADDED], STRINGS + ) + self.assertEqual(produced, STRINGS) + self.assertFalse(exporter.lands(files["scph7001.bin"])) + self.assertEqual(changes.lists, 0) + + def test_the_written_list_passes_the_checker(self): + """Run the produced script: either listed md5 passes, another fails.""" + produced, _, _, _ = self._export([dict(self.CORRECTED, md5=[C, SECOND])], LISTING) + namespace: dict = {"__name__": "batocera_systems"} + exec(compile(produced, "batocera-systems", "exec"), namespace) # noqa: S102 + item = namespace["systems"]["psx"]["biosFiles"][0] + self.assertEqual(item["md5"], [C, SECOND]) + scratch = REPO_ROOT / "tmp" + with tempfile.TemporaryDirectory(dir=scratch if scratch.is_dir() else None) as tmp: + (Path(tmp) / "bios").mkdir() + systems = {"psx": {"name": "PSX", "biosFiles": [item]}} + for payload, missing in ( + (b"second", {}), + (b"other", {"psx": {"bios/scph5500.bin": "UNTESTED"}}), + ): + (Path(tmp) / "bios" / "scph5500.bin").write_bytes(payload) + with self.subTest(payload): + self.assertEqual(namespace["checkBios"](systems, tmp, False), missing) + + def test_an_item_with_altmd5_stays_a_string(self): + """checkBios nests [md5, altmd5]: a list there matches no digest.""" + exporter = discover_exporters()["batocera"]() + exporter._md5_lists = md5_list_support(LISTING) + platform = {"name": "pack.zip", "md5": A, "alt_md5": B, "zipped_file": "rom.bin"} + fe = NativeFile("pack.zip", "pack.zip", "psx", platform=platform, + truth={"md5": [A, B, D]}) + self.assertFalse(exporter.md5_list(fe)) + self.assertTrue(exporter.md5_list( + NativeFile("rom.zip", "rom.zip", "psx", truth={"md5": [A, B]}, + platform={"name": "rom.zip", "md5": A, "zipped_file": "rom.bin"}) + )) + + def test_the_formats_that_share_the_script_read_the_same_checker(self): + for name in ("rocknix", "amberelec", "emuelec", "knulli"): + fe = NativeFile("boot.bin", "boot.bin", "dc", truth={"md5": [A, B]}) + with self.subTest(name): + exporter = discover_exporters()[name]() + self.assertFalse(exporter.md5_list(fe)) + exporter._md5_lists = md5_list_support(LISTING) + self.assertTrue(exporter.md5_list(fe)) + self.assertEqual(exporter.written_list(fe, "md5"), [A, B]) + + +class SitePageShowsAList(unittest.TestCase): + def test_a_list_reads_as_any_of_its_values(self): + line = describe_change({ + "kind": "hash_corrected", "system": "psx", "name": "scph5500.bin", + "field": "md5", "old": C, "new": [C, D], + }) + self.assertEqual(line, f"`psx/scph5500.bin` md5 `{C}` becomes `{C}` or `{D}`") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_export_counts.py b/tests/test_export_counts.py index ae97db69..ac46526b 100644 --- a/tests/test_export_counts.py +++ b/tests/test_export_counts.py @@ -417,11 +417,13 @@ class OneMd5FormatsAddWhole(unittest.TestCase): def test_an_addition_with_several_md5_is_refused(self): from exporter.batocera_exporter import Exporter as Batocera # noqa: PLC0415 - several = NativeFile("boot.bin", "boot.bin", "dc", truth={"md5": [A, B, C]}) + several = NativeFile("boot.bin", "boot.bin", "dc", truth={"md5": [A, B, C]}, + runners=[(False, "flycast")]) single = NativeFile("boot2.bin", "boot2.bin", "dc", truth={"md5": A}, runners=[(False, "flycast")]) - self.assertFalse(Batocera.writable(several)) - self.assertTrue(Batocera.writable(single)) + # A checker that compares one md5, as every cached pin does. + self.assertFalse(Batocera().writable(several)) + self.assertTrue(Batocera().writable(single)) def test_retrobat_refuses_it_too(self): # RetroBat's batocera-systems.json holds one md5 per entry: the diff --git a/wiki/tools.md b/wiki/tools.md index aabf6b41..7b1f1967 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -1024,3 +1024,14 @@ Located in `scripts/exporter/`. Each inherits `BaseExporter`, defined in | `bizhawk_exporter` | `FirmwareDatabase.cs`, BizHawk's own C# | | `misterfpga_exporter` | hash and size in MiSTer's BiosDB (`bios_db.json`) | | `retropie_exporter` | the `rp_module_help` prose of each scriptmodule, by adding a file name the package's emulator reads | + +The five script formats (Batocera, ROCKNIX, KNULLI, AmberELEC, EmuELEC) write +an md5 as one string or, where the checker reads one, as a list. The export +reads the checker of the pinned file, never a version number: when `checkBios` +hands an item's md5 to a function that tests it with `isinstance(..., list)` +(`md5Matches` since batocera.linux `6b08b873`), an entry whose emulator code +accepts several dumps is written with all of them. Without that code, an entry +keeps the one md5 it can hold, and an addition that would need several is left +out and counted. An item with `altmd5` keeps a string, since the checker wraps +it in a list of its own. The report gives a list as a list, and the summary +counts the lists written.