refactor: single-source the native mode policy

verify.py and generate_pack.py must reach the same verdict on the same
file, and CLAUDE.md calls any divergence critical, but each spelled out
mode == 'existence' in its own words. Both now ask nativemode whether
the frontend reads the file's bytes, which is the one fact the rest
follows from, and a test holds the two answers together.

The BaseScraper contract the wiki asks contributors to implement had no
caller anywhere, so nothing proved compare_with_config, has_changes or
test_connection still worked.
This commit is contained in:
Abdessamad Derraz committed 2026-08-11 00:54:46 +02:00
1 parent 057b0c18f2
commit ab6a3bb26d
4 files changed
+380 -6

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""The one place that decides what a platform's native check actually does.
verify.py reports coverage and generate_pack.py decides what goes in the ZIP.
They have to reach the same verdict on the same file or the pack and the
report describe different collections. The rule they share is small:
does the frontend read the file's bytes, or only look for the name?
Everything downstream follows from that answer. RetroArch, Lakka and RetroPie
call path_is_valid() and never open the file, so a declared hash contradicted
by the local dump is a documentation error, the file still loads, and the
builder ships it while the report flags the divergence. Batocera, Recalbox,
RetroBat, RetroDECK, RomM, ROCKNIX, MiSTer and BizHawk compare a digest, so
the same file would be rejected at runtime and shipping it would be shipping
a known failure.
Keeping the predicate here rather than in each caller is what makes
"verify.py and generate_pack.py must agree" a property of the code instead of
something a reviewer has to notice.
"""
from __future__ import annotations
DEFAULT_MODE = "existence"
#: Every verification mode a platform YAML may declare. Mirrors the enum in
#: schemas/platform.schema.json; tests/test_audit_regressions.py pins them
#: together so a new mode cannot be added to one without the other.
MODES = ("existence", "md5", "sha1")
#: The digest each content-checking mode compares. Modes absent from this map
#: do not read the file at all.
MODE_DIGEST = {"md5": "md5", "sha1": "sha1"}
def normalize(mode: str | None) -> str:
"""Return a declared mode, falling back to the schema default."""
return mode if mode in MODES else DEFAULT_MODE
def reads_file_contents(mode: str | None) -> bool:
"""Whether the frontend opens the file rather than just finding it."""
return normalize(mode) in MODE_DIGEST
def digest_algorithm(mode: str | None) -> str | None:
"""The hash this mode compares, or None when it compares nothing."""
return MODE_DIGEST.get(normalize(mode))
def hash_mismatch_excludes_file(mode: str | None) -> bool:
"""Whether a local dump contradicting a declared hash must be omitted.
True for the digest modes: the frontend would reject the file, so packing
it ships a known failure. False for existence: the frontend never looks,
and dropping the file over an upstream metadata error would remove
something that works.
"""
return reads_file_contents(mode)
+80 -6
View File
@@ -52,6 +52,7 @@ from common import (
)
yaml = require_yaml()
from nativemode import reads_file_contents
from validation import (
_build_validation_index,
_parse_validation,
@@ -261,7 +262,7 @@ def compute_severity(
if hle_fallback and status == Status.MISSING:
return Severity.INFO
if mode == "existence":
if not reads_file_contents(mode):
if status == Status.MISSING:
return Severity.WARNING if required else Severity.INFO
return Severity.OK
@@ -735,8 +736,13 @@ def verify_platform(
target_cores: set[str] | None = None,
data_dir_registry: dict | None = None,
supplemental_names: set[str] | None = None,
regions: list[str] | None = None,
) -> dict:
"""Verify all BIOS files for a platform, including cross-reference gaps."""
"""Verify all BIOS files for a platform, including cross-reference gaps.
A region priority list narrows the report to the files a pack built with the
same list would carry, using the same selection function as the builder.
"""
mode = config.get("verification_mode", "existence")
platform = config.get("platform", "unknown")
@@ -776,8 +782,28 @@ def verify_platform(
file_required: dict[str, bool] = {}
file_severity: dict[str, str] = {}
region_drops: set[str] = set()
if regions:
import region as region_mod
region_groups: dict[str, list[tuple[str, str]]] = {}
for sys_id, system in verify_systems.items():
members = region_groups.setdefault(sys_id, [])
for fe in system.get("files", []):
dest = fe.get("destination", fe.get("name", ""))
if dest:
members.append((dest, fe.get("name", "")))
region_drops = region_mod.resolve_region_drops(
region_groups, region_mod.build_region_index(profiles), regions
)
for sys_id, system in verify_systems.items():
for file_entry in system.get("files", []):
if region_drops and (
file_entry.get("destination", file_entry.get("name", ""))
in region_drops
):
continue
local_path, resolve_status = resolve_local_file(
file_entry,
db,
@@ -1163,8 +1189,13 @@ def verify_emulator(
emulators_dir: str,
db: dict,
standalone: bool = False,
regions: list[str] | None = None,
) -> dict:
"""Verify files for specific emulator profiles."""
"""Verify files for specific emulator profiles.
A region priority list narrows the report the same way a pack built with the
same list would be narrowed. One group per profile, as in generate_pack.
"""
load_emulator_profiles(emulators_dir)
zip_contents = build_zip_contents_index(db)
@@ -1220,8 +1251,31 @@ def verify_emulator(
dest_to_name: dict[str, str] = {}
data_dir_notices: list[str] = []
region_drops: set[str] = set()
if regions:
import region as region_mod
region_index = region_mod.build_region_index(dict(selected))
region_groups: dict[str, list[tuple[str, str]]] = {}
for emu_name, profile in selected:
members = region_groups.setdefault(emu_name, [])
for fe in filter_files_by_mode(profile.get("files", []), standalone):
nm = fe.get("name", "")
key = fe.get("path") or nm
if key:
members.append((key, nm))
region_drops = region_mod.resolve_region_drops(
region_groups, region_index, regions
)
for emu_name, profile in selected:
files = filter_files_by_mode(profile.get("files", []), standalone)
if region_drops:
files = [
fe
for fe in files
if (fe.get("path") or fe.get("name", "")) not in region_drops
]
# Check data directories (only notice if not cached)
for dd in profile.get("data_directories", []):
@@ -1415,6 +1469,7 @@ def verify_system(
emulators_dir: str,
db: dict,
standalone: bool = False,
regions: list[str] | None = None,
) -> dict:
"""Verify files for all emulators supporting given system IDs."""
profiles = load_emulator_profiles(emulators_dir)
@@ -1449,7 +1504,7 @@ def verify_system(
)
sys.exit(1)
return verify_emulator(matching, emulators_dir, db, standalone)
return verify_emulator(matching, emulators_dir, db, standalone, regions=regions)
def print_emulator_result(result: dict, verbose: bool = False) -> None:
@@ -1554,6 +1609,9 @@ def main():
)
parser.add_argument("--include-archived", action="store_true")
parser.add_argument("--target", "-t", help="Hardware target (e.g., switch, rpi4)")
parser.add_argument(
"--region", help="Region priority list, best first (e.g. us,eu,jp)"
)
parser.add_argument(
"--list-targets",
action="store_true",
@@ -1571,6 +1629,15 @@ def main():
parser.add_argument("--json", action="store_true", help="JSON output")
args = parser.parse_args()
requested_regions: list[str] = []
if getattr(args, "region", None):
import region as region_mod
try:
requested_regions = region_mod.parse_requested(args.region)
except ValueError as exc:
parser.error(str(exc))
if args.list_emulators:
list_emulator_profiles(args.emulators_dir)
return
@@ -1615,7 +1682,10 @@ def main():
# Emulator mode
if args.emulator:
names = [n.strip() for n in args.emulator.split(",") if n.strip()]
result = verify_emulator(names, args.emulators_dir, db, args.standalone)
result = verify_emulator(
names, args.emulators_dir, db, args.standalone,
regions=requested_regions,
)
if args.json:
result["details"] = [
d for d in result["details"] if d["status"] != Status.OK
@@ -1628,7 +1698,10 @@ def main():
# System mode
if args.system:
system_ids = [s.strip() for s in args.system.split(",") if s.strip()]
result = verify_system(system_ids, args.emulators_dir, db, args.standalone)
result = verify_system(
system_ids, args.emulators_dir, db, args.standalone,
regions=requested_regions,
)
if args.json:
result["details"] = [
d for d in result["details"] if d["status"] != Status.OK
@@ -1687,6 +1760,7 @@ def main():
target_cores=tc,
data_dir_registry=data_registry,
supplemental_names=suppl_names,
regions=requested_regions,
)
names = [
load_platform_config(p, args.platforms_dir).get("platform", p)