chore: merge audit fixes (packrules)

# Conflicts:
#	scripts/packextras.py
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 11:52:28 +02:00
commit d6a0639330
23 files changed
+1074 -84

No files matched your search

+2 -1
View File
@@ -375,7 +375,8 @@ def current_truth(platform: str, config: dict, platforms_dir: str) -> dict:
"""The truth model generate_truth would write now, as YAML reads it back."""
registry, profiles, db = _truth_sources(platforms_dir)
model = generate_platform_truth(
platform, config, registry.get(platform, {}), profiles, db=db
platform, config, registry.get(platform, {}), profiles, db=db,
data_registry=load_data_dir_registry(platforms_dir),
)
return yaml_load(yaml.dump(model, default_flow_style=False, sort_keys=False,
allow_unicode=True)) or {}
+63 -46
View File
@@ -96,6 +96,7 @@ from validation import (
deliverable,
filter_files_by_mode,
with_archive_members,
extra_alternatives,
)
yaml = require_yaml()
@@ -595,14 +596,7 @@ def _matches_an_alternative(fe: dict, local_path: str, profiles: dict) -> bool:
of Raze's table, eduke32's demo and retail NWINTER.GRP). The extra
carries the first, and the held copy matching a sibling is no mismatch.
"""
profile = profiles.get(fe.get("source_profile") or "", {})
name = fe.get("source_name") or fe.get("name")
path = fe.get("source_path") or name
siblings = [
entry for entry in profile.get("files") or []
if isinstance(entry, dict) and entry.get("name") == name
and (entry.get("path") or name) == path
]
siblings = extra_alternatives(fe, profiles)
if len(siblings) < 2:
return False
actual = compute_hashes(local_path, frozenset({"sha1", "md5", "crc32"}))
@@ -1050,7 +1044,7 @@ def generate_pack(
core_count = 0
extra_resolver = ExtraResolver(
db, bios_dir, zip_contents, data_registry, validation_index,
validation_owners, offline,
validation_owners, offline, profiles=emu_profiles,
)
for fe in core_files:
if required_only and fe.get("required") is False:
@@ -2615,16 +2609,15 @@ def _run_verify_packs(args: argparse.Namespace) -> None:
# A group pack and a pack built for the platform alone can both be
# present, and each one is a pack of this platform.
for pack_name in found:
if _narrows_contents(pack_name):
# Only reached after an --all-variants build, whose stage 1
# re-hashed every member against the collection above. The
# platform's list describes the full pack, so a narrowed one
# is not compared to it.
variant = _pack_variant(pack_name)
if variant is None:
# One BIOS per slot: the slot choice is not replayed here.
print(
f" {platform_name} [{pack_name}]: hashes checked during the build; "
"narrowed by design, so not compared to the platform's full list"
"one per slot by design, so not compared to the platform's full list"
)
continue
source, required_only = variant
zip_path = os.path.join(args.output_dir, pack_name)
result = verify_pack_against_platform(
zip_path,
@@ -2636,6 +2629,8 @@ def _run_verify_packs(args: argparse.Namespace) -> None:
regions=verify_regions,
data_registry=verify_data_registry,
target_cores=tc,
source=source,
required_only=required_only,
)
(ok, _checked, _present, errors, bl_checked, bl_present,
core_checked, core_present, bl_excluded) = result
@@ -3749,6 +3744,7 @@ def _manifest_core_entries(
validation_index: dict | None = None,
owners: dict | None = None,
data_registry: dict | None = None,
profiles: dict | None = None,
) -> int:
"""Add the files a platform's cores need but its list does not name.
@@ -3759,6 +3755,10 @@ def _manifest_core_entries(
"""
total_size = 0
extras_pfx = _detect_extras_prefix(config, base_dest)
resolver = ExtraResolver(
db, bios_dir, zip_contents, data_registry, validation_index or {},
owners, offline, profiles=profiles,
)
for fe in core_files:
if required_only and fe.get("required") is False:
continue
@@ -3804,20 +3804,12 @@ def _manifest_core_entries(
):
continue
dest_hint = fe.get("destination", "")
# Resolved with the data directories, as the builder resolves the
# same extra: without them blueMSX's MSX2P.rom named a different
# dump than the one its pack carried.
local_path, status = resolve_file(
fe,
db,
bios_dir,
zip_contents,
dest_hint=dest_hint,
data_dir_registry=data_registry,
offline=offline,
)
if status in ("not_found", "external", "user_provided") or not local_path:
# The builder's own lookup (ExtraResolver): the data directories, the
# copy the emulator accepts, and the size its code checks. Without
# the data directories blueMSX's MSX2P.rom named a different dump
# than the one its pack carried.
local_path, status, _disagreement = resolver.resolve(fe, dest)
if not local_path:
source_emu = fe.get("source_profile") or fe.get("source_emulator", "")
if not _claim_platform_omission(
omitted_by_destination.get(full_dest), source_emu
@@ -3831,10 +3823,6 @@ def _manifest_core_entries(
[source_emu] if source_emu else [],
)
continue
# The copy the emulator accepts, as the pack ships it.
local_path, _disagreement = validated_choice(
fe, local_path, db, validation_index or {}, bios_dir, None, dest, owners,
)
source_emu = fe.get("source_profile") or fe.get("source_emulator", "")
@@ -3873,7 +3861,7 @@ def _manifest_core_entries(
# without the cores served here leaves the slot to it, missing,
# and the installer narrowing to those cores has to say so too.
entry.setdefault("absent_claims", []).append(
{k: v for k, v in displaced.items() if k != "dest"}
{k: v for k, v in displaced.items() if k not in ("dest", "held")}
)
total_size += file_size
seen_destinations.add(full_dest)
@@ -3920,8 +3908,9 @@ def _manifest_selection(
required_only: bool,
regions: list[str] | None,
undeclared: list[dict] | None = None,
) -> tuple[set[str], SelectionIndex]:
"""Destinations a region list removes, and the groups the installer replays.
) -> tuple[set[str], SelectionIndex, set[str]]:
"""Destinations a region list removes, the groups the installer replays,
and the destinations the builder holds.
Grouped as the pack groups them, so a region the installer applies to
the full manifest withdraws what `--region` withdraws from the pack.
@@ -3953,7 +3942,7 @@ def _manifest_selection(
)
return drops, SelectionIndex.build(
region_groups, region_index, pack_systems, extras, required_only
)
), held
def _record_absent(
@@ -4115,6 +4104,9 @@ def generate_manifest(
reason: str,
cores: list[str] | None,
) -> None:
bare = sanitize_pack_path(
file_entry.get("destination", file_entry.get("name", ""))
)
omitted_by_destination[full_destination] = {
"dest": manifest_destination(full_destination),
"name": str(file_entry.get("name") or ""),
@@ -4122,14 +4114,14 @@ def generate_manifest(
"required": bool(file_entry.get("required", True)),
"reason": reason,
"cores": cores,
# The builder counts an unavailable regional file in its groups:
# the installer must too, or it keeps a fallback the pack drops.
**selection.fields(
sanitize_pack_path(
file_entry.get("destination", file_entry.get("name", ""))
),
str(file_entry.get("name") or ""),
),
# An omission keeps its region groups, as the builder groups it:
# a requested file the collection lacks answers the request
# without displacing a held fallback (resolve_region_drops,
# held). One the builder holds and the installer cannot fetch
# does displace it in the pack, so it says so, and the installer
# drops the same fallback.
**selection.fields(bare, str(file_entry.get("name") or "")),
**({"held": True} if bare in builder_held else {}),
}
# Read by the region grouping and by phase 2 with the same arguments.
@@ -4141,7 +4133,7 @@ def generate_manifest(
if source != "platform"
else None
)
region_drops, selection = _manifest_selection(
region_drops, selection, builder_held = _manifest_selection(
config, pack_systems, emulators_dir, db, base_dest, emu_profiles,
target_cores, source, required_only, regions, undeclared=undeclared,
)
@@ -4353,6 +4345,7 @@ def generate_manifest(
seen_destinations, seen_lower, seen_parents, manifest_files,
omitted_by_destination, record_omission, pack_only_sizes, selection,
required_only, manifest_validation, manifest_owners, data_registry,
emu_profiles,
)
for key, (entry, full_dest, dest) in platform_entries.items():
declared = declared_by.get(full_dest, set())
@@ -4715,6 +4708,30 @@ def _narrows_contents(pack_name: str) -> bool:
return any(f"{tag}_" in pack_name for tag in _CONTENT_NARROWING_TAGS)
def _pack_variant(pack_name: str) -> tuple[str, bool] | None:
"""The (source, required_only) a pack was built as, read from its name.
None for a one-per-slot pack, whose slot choice the check does not
replay. The tags come from the name builder, as for _narrows_contents.
"""
def tagged(**dimension: object) -> bool:
tags = [
tag for tag, _label in _narrowings(
str(dimension.get("source", "full")), None, None,
bool(dimension.get("one_per_slot", False)),
bool(dimension.get("required_only", False)),
)
]
return all(f"{tag}_" in pack_name for tag in tags)
if tagged(one_per_slot=True):
return None
source = next(
(s for s in ("platform", "truth") if tagged(source=s)), "full"
)
return source, tagged(required_only=True)
def _pack_archives(output_dir: str) -> list[str]:
"""The ZIPs of a directory that are packs.
+3
View File
@@ -16,6 +16,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from common import (
platform_arg,
list_registered_platforms,
load_data_dir_registry,
load_database,
load_emulator_profiles,
load_platform_config,
@@ -77,6 +78,7 @@ def main(argv: list[str] | None = None) -> None:
registry = (yaml_load(f) or {}).get("platforms", {})
profiles = load_emulator_profiles(args.emulators_dir)
data_registry = load_data_dir_registry(args.platforms_dir)
# Load database (optional)
# The default may be absent, but a --db that was named must exist.
@@ -132,6 +134,7 @@ def main(argv: list[str] | None = None) -> None:
profiles,
db=db,
target_cores=target_cores,
data_registry=data_registry,
)
out_path = os.path.join(output_dir, f"{name}.yml")
+19 -3
View File
@@ -178,7 +178,10 @@ class ExtraResolver:
validation_index: dict,
validation_owners: dict | None,
offline: bool | None = True,
*,
profiles: dict,
) -> None:
self.profiles = profiles
self.db = db
self.bios_dir = bios_dir
self.zip_contents = zip_contents
@@ -190,7 +193,7 @@ class ExtraResolver:
def resolve(self, fe: dict, dest: str) -> tuple[str | None, str, str]:
"""(local copy or None, resolver status, the core's disagreement)."""
from packresolve import resolve_file
from validation import validated_choice
from validation import core_size_refusal, extra_alternatives, validated_choice
local_path, status = resolve_file(
fe, self.db, self.bios_dir, self.zip_contents,
@@ -203,6 +206,12 @@ class ExtraResolver:
fe, local_path, self.db, self.validation_index, self.bios_dir, None,
dest, self.validation_owners,
)
# The platform never reads the bytes, the core does: a copy it turns
# away by its own size check is not its file, on any platform.
if core_size_refusal(
fe, local_path, extra_alternatives(fe, self.profiles)
):
return None, "not_found", ""
return local_path, status, disagreement
@@ -220,6 +229,8 @@ def hidden_extras(
placed_kind: dict[str, str] | None = None,
placed_local: dict[str, str] | None = None,
system_ids: list[str] | None = None,
include_all: bool = False,
required_only: bool = False,
) -> dict[tuple[str, str, str], str]:
"""Report entries the builder holds and still leaves out, with the reason.
@@ -243,7 +254,9 @@ def hidden_extras(
``system_ids`` replays a pack built with --system: the extras collected
without the platform's paths, then those the systems own.
``resolver`` is the builder's lookup. An extra it cannot resolve is
skipped and takes no path, as the builder skips it.
skipped and takes no path, as the builder skips it. ``include_all`` and
``required_only`` are the builder's for a --source truth or a
--required-only pack: an optional extra is skipped before it takes a path.
"""
from packpaths import _register_path, placement_block
@@ -263,11 +276,14 @@ def hidden_extras(
hidden: dict[tuple[str, str, str], str] = {}
extras = _collect_emulator_extras(
config, emulators_dir, db, set() if system_ids else set(shipped),
base_dest, profiles, target_cores=target_cores, undeclared=report,
base_dest, profiles, target_cores=target_cores, include_all=include_all,
undeclared=report,
)
if system_ids:
extras = extras_for_systems(extras, system_ids)
for fe in extras:
if required_only and fe.get("required") is False:
continue
dest = sanitize_pack_path(fe.get("destination", fe.get("name", "")))
if not dest or (region_drops and dest in region_drops):
continue
+64
View File
@@ -139,6 +139,70 @@ def _verified_asset(
return None
def run_is_offline(offline: bool | None = None) -> bool:
"""Whether this run fetches nothing: --offline, or RETROBIOS_OFFLINE."""
return bool(
(_OFFLINE if offline is None else offline)
or os.environ.get("RETROBIOS_OFFLINE")
)
_RECORDS_BY_PATH: list[tuple[dict, int, dict[str, dict]]] = []
def _records_by_path(db: dict) -> dict[str, dict]:
"""The database records by path, built once per database."""
files = db.get("files", {})
if _RECORDS_BY_PATH:
cached, size, index = _RECORDS_BY_PATH[0]
if cached is files and size == len(files):
return index
index = {record.get("path"): record for record in files.values()}
_RECORDS_BY_PATH[:] = [(files, len(files), index)]
return index
def pending_release_asset(
file_entry: dict, db: dict, registered: list[str] | None = None,
) -> str | None:
"""The release asset an online build fetches for an entry, if one does.
The registered path whose database record carries what the entry
declares, read without a download: verify never fetches, and a release
asset the cache lacks was reported missing while an online build packed
it. ``registered`` is the .gitignore registry (load_registered_paths).
"""
from largefiles import asset_candidates, asset_names, load_registered_paths
name = file_entry.get("name", "")
if not name or name.endswith("/") or file_entry.get("type") == "directory":
return None
declared = _declared_hashes(file_entry)
if file_entry.get("unsourceable") and not any(declared.values()):
return None
if file_entry.get("zipped_file") and not any(declared.values()):
return None
if registered is None:
registered = load_registered_paths()
assets = set(asset_candidates(name, registered))
if not assets:
return None
records = _records_by_path(db)
declared_size = file_entry.get("size")
for path, asset in sorted(asset_names(registered).items()):
record = records.get(path)
if asset not in assets or record is None:
continue
if isinstance(declared_size, int) and record.get("size") != declared_size:
continue
if all(
not values or str(record.get(field, "")).lower() in values
for field, values in declared.items()
):
return path
return None
# Digest length in hex digits -> kind. 29 is the MD5 Batocera writes with
# its last three digits cut, matched by prefix as the resolver does.
_HASH_KINDS = {8: "crc32", 29: "md5", 32: "md5", 40: "sha1", 64: "sha256"}
+19 -4
View File
@@ -535,6 +535,8 @@ def _builder_extras(
target_cores: set[str] | None,
region_drops: set[str],
data_registry: dict | None,
include_all: bool = False,
required_only: bool = False,
) -> dict[str, str]:
"""{full destination: local copy} of each core extra the builder writes.
@@ -581,9 +583,11 @@ def _builder_extras(
resolver=ExtraResolver(
db, DEFAULT_BIOS_DIR, build_zip_contents_index(db), data_registry,
_build_validation_index(platform_profiles),
destination_owners(platform_profiles), True,
destination_owners(platform_profiles), True, profiles=emu_profiles,
),
placed_local=placed_local,
include_all=include_all,
required_only=required_only,
)
return placed_local
@@ -607,13 +611,18 @@ def verify_pack_against_platform(
regions: list[str] | None = None,
data_registry: dict | None = None,
target_cores: set[str] | None = None,
source: str = "full",
required_only: bool = False,
) -> tuple[bool, int, int, list[str], int, int, int, int, int]:
"""Verify a pack ZIP against its platform config and core requirements.
A region priority list narrows the expectation to what the builder would
have packed, using the same selection function. A hardware target narrows
it the same way and for the same reason: the systems it removes are not
missing from the pack, they were never asked for.
missing from the pack, they were never asked for. ``source`` and
``required_only`` are the variant the pack was built as: a platform-only
pack is held to the platform's list alone, a truth pack to the cores'
files alone, and a required-only pack to the required entries of both.
Checks:
1. Every baseline file declared by the platform exists in the ZIP
@@ -659,6 +668,9 @@ def verify_pack_against_platform(
base_dest,
emu_profiles,
target_cores=target_cores,
include_extras=(source != "platform"),
include_all=(source == "truth"),
required_only=required_only,
held_out=held,
)
region_drops = region_mod.resolve_region_drops(
@@ -700,8 +712,10 @@ def verify_pack_against_platform(
for i in range(1, len(parts)):
zip_parents.add("/".join(parts[:i]))
baseline_groups: dict[str, list[dict]] = {}
for _sys_id, system in expected_systems.items():
for _sys_id, system in (expected_systems if source != "truth" else {}).items():
for fe in system.get("files", []):
if required_only and fe.get("required") is False:
continue
dest = sanitize_pack_path(fe.get("destination", fe.get("name", "")))
if not dest:
continue
@@ -787,11 +801,12 @@ def verify_pack_against_platform(
# holds the platform's other dump.
core_checked = 0
core_present = 0
if db is not None:
if db is not None and source != "platform":
placed_local = _builder_extras(
config, emulators_dir, db, emu_profiles, baseline_groups,
base_dest, is_flat, zip_set, zip_lower, target_cores,
region_drops, data_registry,
include_all=(source == "truth"), required_only=required_only,
)
for full, local in sorted(placed_local.items()):
core_checked += 1
+4
View File
@@ -874,6 +874,10 @@ def main() -> None:
verify_cmd.append("--include-archived")
if args.target:
verify_cmd.extend(["--target", args.target])
if args.offline:
# The packs of step 4 fetch no release asset: a missing one is
# missing on both sides.
verify_cmd.append("--offline")
ok, verify_output = run(verify_cmd, STEP_LABELS["verify"])
results["verify"] = ok
all_ok = all_ok and ok
+97 -1
View File
@@ -7,15 +7,18 @@ and diffs truth against scraped platform data to find divergences.
from __future__ import annotations
import copy
import os
import sys
from pathlib import Path
from common import (
_norm_system_id,
load_data_dir_registry,
platform_layouts,
resolve_platform_cores,
standalone_set as platform_standalone_set,
)
from hashing import parse_md5_list
from hashing import compute_hashes, parse_md5_list
from validation import (
filter_files_by_mode,
installed_with_emulator,
@@ -508,6 +511,90 @@ def _declaration_verdict(entry: dict, declaration: dict, db: dict | None) -> str
return "undecided"
_REPO_PLATFORMS = Path(__file__).resolve().parent.parent / "platforms"
def _fold_data_directory_readers(
systems: dict,
profiles: dict,
cores: list[str],
standalone_set: set[str],
platform_name: str,
data_registry: dict,
) -> None:
"""Count a core that reads a data directory among the readers of its files.
LRPS2 reads pcsx2/resources/GameIndex.yaml from its data directory and
declares no file entry for it, so the entry other cores build for that
path named armsx2 and yaps2 alone: RetroArch's System.dat line, which
describes LRPS2's copy, was paired with armsx2's revision and nothing
said the cores disagree. The copy in the directory's cache is what the
reader reads, so its hashes fold in as that core's declaration and a
different value marks hash_conflict. Only entries other cores already
build are touched: a directory's other files are not BIOS entries.
"""
by_path: dict[str, list[dict]] = {}
for sys_data in systems.values():
for entry in sys_data.get("files", []):
by_path.setdefault(entry.get("path") or entry.get("name", ""), []).append(entry)
if not by_path:
return
for emu_name in cores:
for view, standalone in platform_layouts(
emu_name, profiles[emu_name], standalone_set
):
trees = [
tree for tree in filter_files_by_mode(
[t for t in view.get("data_directories") or [] if isinstance(t, dict)],
standalone,
)
if tree.get("ref") in data_registry
]
for tree in trees:
cache = data_registry[tree["ref"]].get("local_cache", "")
if not cache or not os.path.isdir(cache):
continue
# Below the build's own folder, as the emulator pack lays the
# tree out: PPSSPP's assets sit under PPSSPP/, not at the root.
structure = (profiles[emu_name].get("pack_structure") or {}).get(
"standalone" if standalone else "libretro", ""
)
prefix = "/".join(
part.strip("/") for part in (structure, str(tree.get("destination") or ""))
if part and part.strip("/")
)
prefix = f"{prefix}/" if prefix else ""
if prefix and any(
str(f.get("path") or "").startswith(prefix)
for f in view.get("files") or [] if isinstance(f, dict)
):
# The profile names what it reads in the tree: yaps2
# lists its resources, pcee2 lists patches.zip alone
# (its build compiles GameIndex.yaml and the shaders in).
continue
installed = platform_name in (tree.get("installed_on") or [])
for path, entries in by_path.items():
if not path.startswith(prefix):
continue
copy_path = os.path.join(cache, path[len(prefix):])
if not os.path.isfile(copy_path):
continue
held = compute_hashes(copy_path, frozenset({"sha1", "md5", "crc32"}))
declaration = {
"name": os.path.basename(path),
"required": bool(tree.get("required", False)),
"source_ref": tree.get("source_ref"),
**held,
"_opens_by_name": True,
"_brought": None if installed else (
f"{emu_name}:{'standalone' if standalone else 'libretro'}"
),
}
for entry in entries:
if emu_name not in entry.get("_cores", set()):
_fold_into(entry, dict(declaration), emu_name)
def generate_platform_truth(
platform_name: str,
config: dict,
@@ -515,6 +602,7 @@ def generate_platform_truth(
profiles: dict[str, dict],
db: dict | None = None,
target_cores: set[str] | None = None,
data_registry: dict | None = None,
) -> dict:
"""Generate ground-truth system data for a platform from emulator profiles.
@@ -526,6 +614,8 @@ def generate_platform_truth(
profiles: all loaded emulator profiles
db: optional database for hash enrichment
target_cores: optional hardware target core filter
data_registry: the data directory registry (_data_dirs.yml), read
from the repository's platforms/ when not given
Returns a dict with platform metadata, systems, and per-file details
including which cores reference each file.
@@ -702,6 +792,12 @@ def generate_platform_truth(
)
sys_cov["unprofiled"].add(emu_name)
_fold_data_directory_readers(
systems, profiles, sorted(cores_profiled), standalone_set, platform_name,
load_data_dir_registry(str(_REPO_PLATFORMS)) if data_registry is None
else data_registry,
)
for sys_data in systems.values():
_require_by_slot(sys_data.get("files", []))
+51 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
import os
import zipfile
from collections.abc import Callable, Iterator, Mapping
from collections.abc import Callable, Iterator, Mapping, Sequence
from common import (
adler32_of,
@@ -961,3 +961,53 @@ def existence_discrepancy(file_entry: dict, local_path: str, platform_display: s
f"packed per {platform_display} existence check: declared hash {declared}, "
f"file md5 {actual['md5']} sha1 {actual['sha1']}"
)
def same_slot_entries(files: list, name: str, path: str) -> list[dict]:
"""The entries of one profile that name the same file at the same path.
A profile lists every version its code takes under one name (each GRP
of Raze's table, eduke32's demo and retail NWINTER.GRP): any of them
fills the slot.
"""
return [
entry for entry in files or []
if isinstance(entry, dict) and entry.get("name") == name
and (entry.get("path") or name) == path
]
def extra_alternatives(fe: dict, profiles: Mapping | None) -> list[dict]:
"""The versions the extra's own profile accepts at the extra's slot."""
if not profiles:
return []
profile = profiles.get(fe.get("source_profile") or "", {})
name = fe.get("source_name") or fe.get("name", "")
return same_slot_entries(
profile.get("files") or [], name, fe.get("source_path") or name
)
def core_size_refusal(
file_entry: dict, local_path: str | None, alternatives: Sequence[dict] = (),
) -> str | None:
"""Why the core refuses this copy by the size its own code checks, or None.
The one rule the builder, the install manifest and verify read for a
core's file. An existence platform never opens the bytes, so a copy whose
hash disagrees still ships to it, but a core that measures the file turns
one of the wrong size away: Basilisk II stops on the Apple IIGS' 128 KB
ROM (main_unix.cpp:692), so packing that file for it serves nothing.
``alternatives`` are the other versions the profile accepts at the same
slot: a copy any of them takes is not refused.
"""
if not local_path or not os.path.isfile(local_path):
return None
size = os.path.getsize(local_path)
if any(name_match_size_ok(entry, size) for entry in (file_entry, *alternatives)):
return None
bounds = (
f"{file_entry['size']}" if file_entry.get("size") is not None
else f"{file_entry.get('min_size', 0)} to {file_entry.get('max_size', 'any')}"
)
return f"{size} bytes, the core checks for {bounds}"
+64 -4
View File
@@ -106,6 +106,8 @@ from validation import (
unread_on,
outside_gap_scope,
with_archive_members,
core_size_refusal,
same_slot_entries,
)
DEFAULT_DB = "database.json"
@@ -150,6 +152,11 @@ class Status:
MISSING = "missing"
# A declaration whose file is a release asset the cache does not hold yet:
# an online build fetches it, verify reads the database record instead.
RELEASE_ASSET_PENDING = "release_asset_pending"
class Severity:
CRITICAL = "critical" # required file missing or bad hash (Recalbox RED)
WARNING = "warning" # optional missing or hash mismatch (Recalbox YELLOW)
@@ -694,6 +701,17 @@ def find_undeclared_files(
in_repo = _st != "not_found" and _lp is not None
if in_repo and _st == "hash_mismatch" and shipped_on_mismatch:
in_repo = False
if in_repo and "size" in _parse_validation(f.get("validation")):
# On any platform the core measures what it opens: a
# copy it turns away by its own size check is not
# held for it, the rule the builder's lookup applies
# (packextras.ExtraResolver). The Apple IIGS' 128 KB
# ROM is not Basilisk II's ROM.
_lp, _st = resolve_local_file(f, db, dest_hint=dest)
if core_size_refusal(
f, _lp, same_slot_entries(profile.get("files"), fname, dest)
):
in_repo = False
checks = _parse_validation(f.get("validation"))
entry = {
@@ -1066,7 +1084,7 @@ def _resolve_for_platform(
disk and the large-file cache: verify never downloads. A frontend that
never reads the bytes takes the filename-free core's own image.
"""
from packresolve import resolve_declaration
from packresolve import pending_release_asset, resolve_declaration, run_is_offline
override = slot_overrides.get(
sanitize_pack_path(file_entry.get("destination", file_entry.get("name", "")))
@@ -1075,6 +1093,12 @@ def _resolve_for_platform(
file_entry, db, DEFAULT_BIOS_DIR, zip_contents, data_dir_registry,
override, offline=True,
)
if status in ("not_found", "hash_mismatch") and not run_is_offline():
# Not in the cache, and the release holds a copy that answers the
# declaration: an online build fetches and packs it (resolve_file).
asset = pending_release_asset(file_entry, db)
if asset:
return asset, RELEASE_ASSET_PENDING
if status == "not_found" and not reads_file_contents(mode):
found = agnostic_substitute(file_entry, sys_id, db, platform_profiles)
if found:
@@ -1230,7 +1254,23 @@ def verify_platform(
file_entry.get("destination", file_entry.get("name", ""))
)
stored = _storage_verdict(file_entry, emulator_pack=False)
if stored is not None:
if resolve_status == RELEASE_ASSET_PENDING:
# Counted as an online build counts it, which fetches the
# asset. Under --offline the builder cannot, and neither
# does this report (_resolve_for_platform).
result = {
"name": file_entry.get("name", ""),
"required": file_entry.get("required", True),
"status": Status.OK,
"release_asset": local_path,
"reason": "release asset, fetched when the pack is built",
}
packed_order.append((
sys_id, f"{base_dest}/{destination}" if base_dest else destination, None,
))
shipped_keys.add(_dest_key(file_entry, sys_id))
local_path = None
elif stored is not None:
result = stored
local_path = None
# The builder keeps the slot: instructions for a user_provided
@@ -1364,7 +1404,7 @@ def verify_platform(
region_drops, report_for_builder,
ExtraResolver(
db, DEFAULT_BIOS_DIR, zip_contents, data_dir_registry, validation_index,
validation_owners,
validation_owners, profiles=profiles,
),
placed_kind,
system_filter,
@@ -1749,6 +1789,12 @@ def print_platform_result(
if missing:
parts.append(f"{missing} missing")
print(f"{label}: {', '.join(parts)} [{mode}]")
fetched = sorted({d["name"] for d in result["details"] if d.get("release_asset")})
if fetched:
# Counted present: an online build downloads them. --offline counts
# them missing, as an offline build leaves them out.
shown = ", ".join(fetched[:10]) + (f" (+{len(fetched) - 10})" if len(fetched) > 10 else "")
print(f" Release assets fetched when the pack is built ({len(fetched)}): {shown}")
fallbacks = result.get("region_fallbacks") or []
if fallbacks:
from packreadme import _group_label
@@ -2445,6 +2491,7 @@ def _refuse_listing_narrowings(
("--strict", args.strict),
("--verbose", args.verbose),
("--destinations", args.destinations),
("--offline", args.offline),
):
if on:
parser.error(f"{flag} is incompatible with {listing}")
@@ -2466,6 +2513,8 @@ def _refuse_mode_flags(args: argparse.Namespace, parser: argparse.ArgumentParser
"--destinations describes the full pack and is incompatible with --system"),
(args.destinations and not (args.platform or args.all),
"--destinations requires --platform or --all"),
(args.offline and not (args.platform or args.all),
"--offline requires --platform or --all"),
(args.destinations and getattr(args, "region", None),
"--destinations describes the full pack and is incompatible with --region"),
):
@@ -2545,6 +2594,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(
"--offline", action="store_true",
help="Report as an offline build packs: a release asset the cache "
"lacks is missing. Without it, it counts as fetched at build time",
)
parser.add_argument(
"--destinations", metavar="FILE",
help="With --platform or --all, also write the paths each pack holds "
@@ -2561,6 +2615,10 @@ def _main(held: contextlib.ExitStack) -> None:
work_from_repo_root(args, parser, ["db", "platforms_dir", "emulators_dir"])
if args.platform:
require_platform(args.platform, args.platforms_dir)
if args.offline:
import packresolve
packresolve.set_offline(True)
requested_regions: list[str] = []
if getattr(args, "region", None):
@@ -2718,7 +2776,9 @@ def _json_details(details: list[dict], verbose: bool) -> list[dict]:
used to be accepted beside --json and dropped, the output byte for byte
the same, so a script asking for the passing entries got nothing.
"""
return list(details) if verbose else [d for d in details if d["status"] != Status.OK]
return list(details) if verbose else [
d for d in details if d["status"] != Status.OK or d.get("release_asset")
]
def _exit_if_strict(strict: bool, results: list[dict]) -> None: