fix: compare pack paths, not only counts

Step 5 compared (ok, total) and the core file count. RetroArch, Lakka
and RetroPie read 526/526 on both sides while verify credited four paths
below SGB1.sfc and SGB2.sfc that the pack never writes: a declared file
blocks them as a directory. verify now places the platform files in the
builder's order with the same path rules, and a user_provided or
external declaration keeps its slot on both sides, so no core file takes
it in the report only.

verify --destinations writes the paths each pack holds by the report,
and step 5 compares them with the archive members (notes and data tree
members set aside). Every emulator profile, in each build it has, is
built in memory (PackRecord, nothing read or written) and compared with
verify --emulator path by path. Cost measured here: about 1 s per
platform archive and 11 s for the 930 emulator packs.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 05:18:02 +02:00
1 parent fb639647e6
commit 38af19091d
8 files changed
+509 -25

No files matched your search

+90 -5
View File
@@ -30,6 +30,7 @@ import json
import os
import sys
from collections.abc import Mapping
from pathlib import Path
import slots
@@ -859,7 +860,7 @@ def _mark_hidden(
emulators_dir: str,
db: dict,
emu_profiles: dict | None,
shipped: dict[str, str],
shipped: dict[str, str | None],
target_cores: set[str] | None,
region_drops: set[str] | None = None,
report: list[dict] | None = None,
@@ -891,6 +892,39 @@ def _mark_hidden(
return placed
def _builder_placement(
packed_order: list[tuple[str, str, str | None]], case_insensitive: bool,
) -> tuple[dict[str, str], dict[str, str | None]]:
"""The platform files the builder writes, and every path it keeps.
The builder walks the systems in name order and skips a path that a
written file blocks as a directory, or the reverse: RetroArch declares
SGB1.sfc and SGB1.sfc/program.rom, and only the first is in the pack.
Returns (path -> local copy written there, path -> copy or None for each
slot kept, a user_provided or external one included).
"""
from packpaths import _has_path_conflict, _register_path
shipped: dict[str, str] = {}
occupied: dict[str, str | None] = {}
seen: set[str] = set()
folded: set[str] = set()
parents: set[str] = set()
for _sys_id, full, local in sorted(packed_order, key=lambda item: item[0]):
if (
full in occupied
or (case_insensitive and full.lower() in folded)
or _has_path_conflict(full, seen, parents)
):
continue
_register_path(full, seen, parents)
folded.add(full.lower())
occupied[full] = local
if local is not None:
shipped[full] = local
return shipped, occupied
def _dest_key(file_entry: dict, sys_id: str) -> str:
"""The path a declaration occupies, as the builder keys its pack."""
dest = sanitize_pack_path(file_entry.get("destination", file_entry.get("name", "")))
@@ -1111,7 +1145,10 @@ def verify_platform(
verify_systems, db, base_dest, zip_contents
)
shipped: dict[str, str] = {}
# (system, full destination, local copy) of each declaration the builder
# writes, and None for one whose slot it keeps without a file from the
# collection (user_provided, external). Placed below in the builder's order.
packed_order: list[tuple[str, str, str | None]] = []
shipped_keys: set[str] = set()
results_at: dict[str, list[dict]] = {}
for sys_id, system in verify_systems.items():
@@ -1135,6 +1172,12 @@ def verify_platform(
if stored is not None:
result = stored
local_path = None
# The builder keeps the slot: instructions for a user_provided
# file, the download for an external one, whether it arrives
# or not. No core file goes there.
packed_order.append((
sys_id, f"{base_dest}/{destination}" if base_dest else destination, None,
))
elif not reads_file_contents(mode):
result = verify_entry_existence(
file_entry,
@@ -1179,10 +1222,11 @@ def verify_platform(
file_entry, local_path, resolve_status, mode, config.get("hash_rule", "")
)[1] is None
):
shipped.setdefault(
packed_order.append((
sys_id,
f"{base_dest}/{destination}" if base_dest else destination,
local_path,
)
))
shipped_keys.add(_dest_key(file_entry, sys_id))
if _twin_unmet(result, held_by):
continue
@@ -1212,6 +1256,10 @@ def verify_platform(
):
file_severity[dest] = sev
shipped, occupied = _builder_placement(
packed_order, bool(config.get("case_insensitive_fs", False))
)
# Cross-reference undeclared files, called exactly as the builder calls
# it: its declarations, not the enriched ones (a core file the platform
# also declares under another name is packed at the core's own path, 21
@@ -1243,7 +1291,7 @@ def verify_platform(
placed_by: dict[str, tuple[str, str, str]] = {}
placed = _mark_hidden(
config, undeclared, emulators_dir, db, emu_profiles, shipped, target_cores,
config, undeclared, emulators_dir, db, emu_profiles, occupied, target_cores,
region_drops, report_for_builder,
ExtraResolver(
db, DEFAULT_BIOS_DIR, zip_contents, data_dir_registry, validation_index,
@@ -1323,10 +1371,23 @@ def verify_platform(
)
total = len(file_status)
# The paths a pack built from this report holds, as written in the ZIP
# (base_destination stripped, as the builder flattens it). The pipeline
# compares this set with the archive: equal counts can hide one file
# credited here and another written there.
from packpaths import _flat
pack_destinations = sorted(
{_flat(dest, base_dest, True) for dest in shipped} | {
_flat(dest, base_dest, True) for dest in placed
}
)
return {
"platform": platform,
"verification_mode": mode,
"total_files": total,
"pack_destinations": pack_destinations,
"severity_counts": counts,
"status_counts": status_counts,
"excluded_files": excluded,
@@ -2091,6 +2152,11 @@ def verify_emulator(
"emulators": [n for n, _ in selected],
"verification_mode": label,
"total_files": total,
# The paths the emulator pack fills with the profile's files, for the
# pipeline's comparison with the pack built in memory.
"pack_destinations": sorted(
dest for dest, status in file_status.items() if status == Status.OK
),
"severity_counts": counts,
"status_counts": status_counts,
"details": details,
@@ -2271,6 +2337,7 @@ def _refuse_listing_narrowings(
("--json", args.json),
("--strict", args.strict),
("--verbose", args.verbose),
("--destinations", args.destinations),
):
if on:
parser.error(f"{flag} is incompatible with {listing}")
@@ -2286,6 +2353,10 @@ def _refuse_mode_flags(args: argparse.Namespace, parser: argparse.ArgumentParser
"--target requires --platform or --all"),
(args.target and (args.emulator or args.system),
"--target is incompatible with --emulator and --system"),
(args.destinations and not (args.platform or args.all),
"--destinations requires --platform or --all"),
(args.destinations and getattr(args, "region", None),
"--destinations describes the full pack and is incompatible with --region"),
):
if refused:
parser.error(message)
@@ -2363,6 +2434,11 @@ def _main(held: contextlib.ExitStack) -> None:
help="Show emulator ground truth details",
)
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument(
"--destinations", metavar="FILE",
help="With --platform or --all, also write the paths each pack holds "
"according to this report to FILE (JSON), for comparison with the archive",
)
parser.add_argument(
"--strict",
action="store_true",
@@ -2494,6 +2570,15 @@ def _main(held: contextlib.ExitStack) -> None:
if len(group_results) > 1:
print(_overall_summary(group_results))
if args.destinations:
held = {}
for (result, names), (group, _rep) in zip(group_results, groups):
for name, key in zip(names, group):
held[name] = {
"platform": key, "pack_destinations": result["pack_destinations"],
}
Path(args.destinations).write_text(json.dumps(held, indent=1, sort_keys=True))
if args.json:
for r in all_results.values():
r["details"] = _json_details(r["details"], args.verbose)