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}`"