feat: export md5 lists the checker reads

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 08:27:27 +02:00
commit b657cff327
8 files changed
+424 -25

No files matched your search

+42 -5
View File
@@ -483,6 +483,15 @@ class LandedChanges:
def requirements(self) -> int: def requirements(self) -> int:
return sum(r["kind"] == "requirement_corrected" for r in self.records) 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( def _change(
kind: str, entry: object, field_name: str | None, old: object, new: object 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: def _platform_value(entry: object, field_name: str) -> str | list[str]:
return ",".join(_hash_values(entry.platform or {}, field_name)) 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: def _added_fields(exporter: object, entry: object) -> dict:
@@ -508,7 +527,7 @@ def _added_fields(exporter: object, entry: object) -> dict:
written: dict[str, object] = { written: dict[str, object] = {
name: value name: value
for name in HASH_FIELDS 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: if "size" in carried and entry.size() is not None:
written["size"] = entry.size() written["size"] = entry.size()
@@ -560,11 +579,27 @@ def landed_changes(exporter: object, systems: dict) -> LandedChanges:
records.append(_change( records.append(_change(
"hash_corrected", entry, field_name, "hash_corrected", entry, field_name,
_platform_value(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( records.extend(
_change("hash_filled", entry, field_name, None, _change("hash_filled", entry, field_name, None,
exporter.written(entry, field_name)) _written_value(exporter, entry, field_name))
for field_name in entry.filled for field_name in entry.filled
if exporter.fills(entry, field_name) if exporter.fills(entry, field_name)
) )
@@ -671,6 +706,7 @@ def export_platform(
resized, cleared = changes.resized, changes.cleared resized, cleared = changes.resized, changes.cleared
unsized = changes.unsized unsized = changes.unsized
requirements = changes.requirements requirements = changes.requirements
lists = changes.lists
landed = 0 landed = 0
refused = 0 refused = 0
@@ -703,6 +739,7 @@ def export_platform(
(len(unsized), "size", "", "removed"), (len(unsized), "size", "", "removed"),
(len(cleared), "hash", "", "removed"), (len(cleared), "hash", "", "removed"),
(lost, "entry", "entries", "removed, the format cannot state them"), (lost, "entry", "entries", "removed, the format cannot state them"),
(lists, "md5 list", "", "written, any listed value passes"),
) )
parts += [ parts += [
f"{_count(number, noun, plural)} {verb}" 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 __future__ import annotations
from .base_exporter import BaseExporter
from .baseline import NativeFile from .baseline import NativeFile
from .batocera_exporter import Exporter as BatoceraExporter from .batocera_exporter import Exporter as BatoceraExporter
@@ -24,10 +23,8 @@ class Exporter(BatoceraExporter):
PATH_PREFIX = "" PATH_PREFIX = ""
@classmethod # The list gates nothing on a runner, so an addition needs none.
def writable(cls, fe: NativeFile, require: str = "") -> bool: RUNNER_GATED = False
# The list gates nothing on a runner, so an addition needs none.
return BaseExporter.writable.__func__(cls, fe, require)
def _addition_keys(self, fe: NativeFile) -> list[tuple[str, str | None]] | None: def _addition_keys(self, fe: NativeFile) -> list[tuple[str, str | None]] | None:
return [] return []
+12 -1
View File
@@ -171,11 +171,16 @@ class BaseExporter(ABC):
hands back a file poorer than the one it corrects. The requirement hands back a file poorer than the one it corrects. The requirement
only gates what we would be adding. 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: if fe.platform is not None:
return True return True
if not cls.can_add() or (cls.keys_by_system() and fe.foreign_system): if not cls.can_add() or (cls.keys_by_system() and fe.foreign_system):
return False return False
single = cls.SINGLE_HASH_FIELD
if single and len(set(fe.hashes(single))) > 1: if single and len(set(fe.hashes(single))) > 1:
# One value checked: written with the first, the addition # One value checked: written with the first, the addition
# refuses every other dump the core accepts. # refuses every other dump the core accepts.
@@ -207,6 +212,12 @@ class BaseExporter(ABC):
return self.single_value(fe, field_name) return self.single_value(fe, field_name)
return ",".join(fe.hashes(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: def required_value(self, fe: NativeFile) -> bool:
"""The requirement the render writes for an entry. """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 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: 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 """An md5 as the script writes it: a quoted string, or a list of them
with Batocera's spacing inside the brackets (`[ "a", "b" ]`).""" 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 # 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 # a zippedFile: retrodream's boot.bin written with the first of four
# md5 refused the three other dumps the core accepts. An item the # 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" 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 # checkBios reads emulator names from es_systems, and Batocera's
# standalone_cores are those names. ROCKNIX's list holds package names # standalone_cores are those names. ROCKNIX's list holds package names
# (duckstation-sa), which its checker does not know. # (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 # The emulator and core each system entry of the original is gated
# on, read by render. # on, read by render.
self._gates: dict[str, tuple[str, str] | None] = {} 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 @staticmethod
def platform_name() -> str: def platform_name() -> str:
@@ -121,15 +203,15 @@ class Exporter(BaseExporter):
def carries() -> frozenset[str]: def carries() -> frozenset[str]:
return frozenset({"md5"}) return frozenset({"md5"})
@classmethod def writable(self, fe: NativeFile, require: str = "") -> bool:
def writable(cls, fe: NativeFile, require: str = "") -> bool:
"""An addition the format can state whole.""" """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 # Written without its core's emulator and core, checkBios asks
# every build for it (the defect _runner_keys repairs for the # every build for it (the defect _runner_keys repairs for the
# platform's own items). # platform's own items).
return False 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 @classmethod
def _usable_runners(cls, fe: NativeFile) -> list[tuple[bool, str]]: def _usable_runners(cls, fe: NativeFile) -> list[tuple[bool, str]]:
@@ -178,11 +260,33 @@ class Exporter(BaseExporter):
return False return False
return fe.platform is not None or self._addition_keys(fe) is not None return fe.platform is not None or self._addition_keys(fe) is not None
@staticmethod def md5_list(self, fe: NativeFile) -> bool:
def md5_list(fe: NativeFile) -> bool: """Whether this item's md5 is written as a list: the platform wrote
"""Whether the platform writes this item's md5 as a list. Its it so, or the truth accepts several and the checker reads a list."""
checker then accepts any listed value, so every one is written.""" if fe.platform is not None and fe.platform.get("md5_list"):
return fe.platform is not None and bool(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]: def md5_value(self, fe: NativeFile) -> str | list[str]:
"""The md5 an item is written with, in the form the platform used.""" """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" "script, and the rest of it is the checker"
) )
self._md5_lists = md5_list_support(original)
head, body, tail = self._split_original(original) head, body, tail = self._split_original(original)
spans = self._entry_spans(body) spans = self._entry_spans(body)
for key, (start, end) in spans.items(): for key, (start, end) in spans.items():
+3
View File
@@ -40,6 +40,9 @@ CHANGE_RECORDS = {
def _value(value: object) -> str: 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}`" 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 common import resolve_local_file # noqa: E402
from exporter import discover_exporters # 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 import rocknix_scraper # noqa: E402
from scraper.base_scraper import md5_field, requirement_entry # noqa: E402 from scraper.base_scraper import md5_field, requirement_entry # noqa: E402
from scraper.batocera_scraper import ( # noqa: E402 from scraper.batocera_scraper import ( # noqa: E402
@@ -30,6 +32,7 @@ from scraper.batocera_scraper import ( # noqa: E402
platform_systems, platform_systems,
requirements_from_systems, requirements_from_systems,
) )
from siteexports import describe_change # noqa: E402
from verify import Status, verify_entry_md5 # noqa: E402 from verify import Status, verify_entry_md5 # noqa: E402
A = "dc2b9bf8da62ec93e868cfd29f0d067d" A = "dc2b9bf8da62ec93e868cfd29f0d067d"
@@ -204,5 +207,235 @@ class VerifyAcceptsAnyListedMd5(unittest.TestCase):
self.assertEqual(verify_entry_md5(entry, str(archive))["status"], Status.OK) 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__": if __name__ == "__main__":
unittest.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): def test_an_addition_with_several_md5_is_refused(self):
from exporter.batocera_exporter import Exporter as Batocera # noqa: PLC0415 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}, single = NativeFile("boot2.bin", "boot2.bin", "dc", truth={"md5": A},
runners=[(False, "flycast")]) runners=[(False, "flycast")])
self.assertFalse(Batocera.writable(several)) # A checker that compares one md5, as every cached pin does.
self.assertTrue(Batocera.writable(single)) self.assertFalse(Batocera().writable(several))
self.assertTrue(Batocera().writable(single))
def test_retrobat_refuses_it_too(self): def test_retrobat_refuses_it_too(self):
# RetroBat's batocera-systems.json holds one md5 per entry: the # 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# | | `bizhawk_exporter` | `FirmwareDatabase.cs`, BizHawk's own C# |
| `misterfpga_exporter` | hash and size in MiSTer's BiosDB (`bios_db.json`) | | `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 | | `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.