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.