mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-25 21:29:05 -05:00
chore: merge audit fixes (packrules)
# Conflicts: # scripts/packextras.py
This commit is contained in:
commit
d6a0639330
23 files changed
+1074
-84
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user