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.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 08:06:58 +02:00
1 parent bb561f2f12
commit 5b083c45b5
8 files changed
+424 -25

No files matched your search

+42 -5
View File
@@ -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}"
+2 -5
View File
@@ -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 []
+12 -1
View File
@@ -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.
+115 -10
View File
@@ -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(<parameter>, 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():
+3
View File
@@ -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}`"
+234 -1
View File
@@ -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()
+5 -3
View File
@@ -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
+11
View File
@@ -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.