mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 13:33:24 -05:00
refactor: split the pack builder into its layers
generate_pack.py held six responsibilities in 4744 lines. Five move out in dependency order, so nothing above reaches back down: destinations, core extras, resolution with its storage tiers, the notes shipped inside a pack, and the pack verifier. generate_pack.py keeps the build and the command line, and re-exports the rest. Two things the move surfaced. The offline switch was a module global the command line assigned, which a re-export would have copied and frozen at False; it is set through a call now and not re-exported. And a facade placed after the entry point binds too late: importing the module worked, running it did not, so the manifest run died on a name the tests never exercised because tests import. Verified against the previous revision on frozen inputs: every generated artefact is identical, save the catalogue that embeds hashes of files carrying a build timestamp.
This commit is contained in:
1 parent
b5fd643ccf
commit
c31ce0b693
7 files changed
+1872
-1754
No files matched your search
@@ -86,3 +86,17 @@ def artifact_lock(directory: str, exclusive: bool = True):
|
|||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
def _build_timestamp(db: dict | None = None) -> str:
|
||||||
|
"""Timestamp for generated artifacts.
|
||||||
|
|
||||||
|
Reads the database snapshot the artifact was built from, so rebuilding
|
||||||
|
the same data twice yields the same value. Falls back to the clock only
|
||||||
|
when no database is at hand.
|
||||||
|
"""
|
||||||
|
stamp = (db or {}).get("generated_at")
|
||||||
|
if isinstance(stamp, str) and stamp:
|
||||||
|
return stamp
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
+47
-1754
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,566 @@
|
|||||||
|
"""What the cores need that the platform does not declare.
|
||||||
|
|
||||||
|
A pack is the platform's own list plus the files its cores load. The
|
||||||
|
second half is derived here, and the region pass reads the grouping
|
||||||
|
this module builds so the report and the pack withdraw the same files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common import expand_platform_declared_names
|
||||||
|
from common import load_emulator_profiles
|
||||||
|
from common import resolution_is_hash_exact
|
||||||
|
from common import resolve_local_file
|
||||||
|
from common import sanitize_pack_path
|
||||||
|
def _emulator_systems_index(emu_profiles: dict | None) -> dict[str, list[str]]:
|
||||||
|
"""Map both the profile key and its display name to the profile's systems.
|
||||||
|
|
||||||
|
find_undeclared_files reports the display name ("Beetle PSX (Mednafen
|
||||||
|
PSX)"), while the profile dictionary is keyed by slug. A key-only lookup
|
||||||
|
therefore missed almost every core, dropping its files into one shared
|
||||||
|
bucket and losing the per-system grouping the narrowing passes rely on.
|
||||||
|
"""
|
||||||
|
index: dict[str, list[str]] = {}
|
||||||
|
for key, profile in (emu_profiles or {}).items():
|
||||||
|
systems = list(profile.get("systems", []))
|
||||||
|
index[key] = systems
|
||||||
|
display = profile.get("emulator", "")
|
||||||
|
if display:
|
||||||
|
index.setdefault(display, systems)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def _detect_extras_prefix(config: dict, base_dest: str) -> str:
|
||||||
|
"""Detect the effective BIOS prefix for core extras.
|
||||||
|
|
||||||
|
When base_destination is empty (RetroDECK), infer the prefix from
|
||||||
|
the dominant root of YAML-declared destinations. Returns the prefix
|
||||||
|
to prepend to every core-extra destination (may be empty).
|
||||||
|
"""
|
||||||
|
if base_dest:
|
||||||
|
return base_dest
|
||||||
|
dests: list[str] = []
|
||||||
|
for sys_data in config.get("systems", {}).values():
|
||||||
|
for f in sys_data.get("files", []):
|
||||||
|
d = f.get("destination", "")
|
||||||
|
if d and "/" in d:
|
||||||
|
dests.append(d)
|
||||||
|
if not dests:
|
||||||
|
return ""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
roots = Counter(d.split("/", 1)[0] for d in dests)
|
||||||
|
most_common, count = roots.most_common(1)[0]
|
||||||
|
if count / len(dests) > 0.9:
|
||||||
|
return most_common
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _detect_slug_structure(config: dict) -> tuple[bool, dict[str, str]]:
|
||||||
|
"""Detect whether a platform uses per-system slug destinations.
|
||||||
|
|
||||||
|
Returns ``(is_slug_based, system_to_slug)`` where ``system_to_slug``
|
||||||
|
maps system IDs to their destination slug prefix. Slug-based means
|
||||||
|
each system's files live under a per-system subfolder (e.g. RomM's
|
||||||
|
``bios/{platform_slug}/{file}``), with varying slugs across systems.
|
||||||
|
|
||||||
|
Only returns True when nearly ALL destinations have a subfolder and
|
||||||
|
nearly ALL systems map to a consistent slug, distinguishing true
|
||||||
|
slug-based layouts (RomM) from platforms that happen to have some
|
||||||
|
subfoldered files (RetroArch ``dc/``, ``neocd/``).
|
||||||
|
"""
|
||||||
|
total_files = 0
|
||||||
|
files_with_slash = 0
|
||||||
|
sys_to_slug: dict[str, str] = {}
|
||||||
|
total_systems_with_files = 0
|
||||||
|
for sys_id, sys_data in config.get("systems", {}).items():
|
||||||
|
files = sys_data.get("files", [])
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
total_systems_with_files += 1
|
||||||
|
slugs: set[str] = set()
|
||||||
|
for f in files:
|
||||||
|
d = f.get("destination", "")
|
||||||
|
if d:
|
||||||
|
total_files += 1
|
||||||
|
if "/" in d:
|
||||||
|
files_with_slash += 1
|
||||||
|
slugs.add(d.split("/", 1)[0])
|
||||||
|
if len(slugs) == 1:
|
||||||
|
sys_to_slug[sys_id] = slugs.pop()
|
||||||
|
|
||||||
|
if not sys_to_slug or total_files == 0:
|
||||||
|
return False, {}
|
||||||
|
# All conditions must hold for slug-based detection:
|
||||||
|
# 1. Nearly all files have a subfolder
|
||||||
|
# 2. Multiple distinct slugs (not a constant prefix)
|
||||||
|
# 3. Nearly all systems with files map to a slug
|
||||||
|
# 4. Files are exactly slug/filename (depth 2), not deeper
|
||||||
|
unique_slugs = set(sys_to_slug.values())
|
||||||
|
all_have_slash = files_with_slash / total_files > 0.95
|
||||||
|
varying_slugs = len(unique_slugs) > 1
|
||||||
|
high_coverage = len(sys_to_slug) / total_systems_with_files > 0.9
|
||||||
|
# Count files deeper than slug/filename (e.g., amiga/bios/kick.rom)
|
||||||
|
deep_files = 0
|
||||||
|
for sys_data in config.get("systems", {}).values():
|
||||||
|
for f in sys_data.get("files", []):
|
||||||
|
d = f.get("destination", "")
|
||||||
|
if d and d.count("/") > 1:
|
||||||
|
deep_files += 1
|
||||||
|
shallow = deep_files / total_files < 0.05 if total_files else True
|
||||||
|
return (all_have_slash and varying_slugs and high_coverage and shallow), sys_to_slug
|
||||||
|
|
||||||
|
def _map_emulator_to_slug(
|
||||||
|
profile: dict,
|
||||||
|
platform_systems: set[str],
|
||||||
|
norm_map: dict[str, str],
|
||||||
|
sys_to_slug: dict[str, str],
|
||||||
|
) -> str:
|
||||||
|
"""Map an emulator to a destination slug for slug-based platforms."""
|
||||||
|
from common import _norm_system_id
|
||||||
|
|
||||||
|
emu_systems = set(profile.get("systems", []))
|
||||||
|
# Direct match
|
||||||
|
direct = emu_systems & platform_systems
|
||||||
|
if direct:
|
||||||
|
target = sorted(direct)[0]
|
||||||
|
return sys_to_slug.get(target, "")
|
||||||
|
# Normalized match
|
||||||
|
for es in sorted(emu_systems):
|
||||||
|
norm = _norm_system_id(es)
|
||||||
|
if norm in norm_map:
|
||||||
|
target = norm_map[norm]
|
||||||
|
return sys_to_slug.get(target, "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _collect_emulator_extras(
|
||||||
|
config: dict,
|
||||||
|
emulators_dir: str,
|
||||||
|
db: dict,
|
||||||
|
seen: set,
|
||||||
|
base_dest: str,
|
||||||
|
emu_profiles: dict | None = None,
|
||||||
|
target_cores: set[str] | None = None,
|
||||||
|
include_all: bool = False,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Collect core requirement files from emulator profiles not in the platform pack.
|
||||||
|
|
||||||
|
Uses the same system-overlap matching as verify.py cross-reference:
|
||||||
|
- Matches emulators by shared system IDs with the platform
|
||||||
|
- Filters mode: standalone, type: launcher, type: alias
|
||||||
|
- Respects data_directories coverage
|
||||||
|
- Only returns files that exist in the repo (packable)
|
||||||
|
|
||||||
|
When the same file is needed at multiple destinations by different cores
|
||||||
|
(e.g. cdimono1.zip at root for cdi2015 and at same_cdi/bios/ for same_cdi),
|
||||||
|
all destinations are included so every core finds its files.
|
||||||
|
|
||||||
|
Works for ANY platform (RetroArch, Batocera, Recalbox, etc.)
|
||||||
|
"""
|
||||||
|
from common import _norm_system_id, resolve_platform_cores
|
||||||
|
from verify import find_undeclared_files
|
||||||
|
|
||||||
|
profiles = (
|
||||||
|
emu_profiles
|
||||||
|
if emu_profiles is not None
|
||||||
|
else load_emulator_profiles(emulators_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect destination conventions for core extras
|
||||||
|
extras_prefix = _detect_extras_prefix(config, base_dest)
|
||||||
|
is_slug_based, sys_to_slug = _detect_slug_structure(config)
|
||||||
|
platform_systems = set(config.get("systems", {}).keys())
|
||||||
|
norm_map: dict[str, str] = {}
|
||||||
|
if is_slug_based:
|
||||||
|
for sid in platform_systems:
|
||||||
|
norm_map[_norm_system_id(sid)] = sid
|
||||||
|
|
||||||
|
# Use strict YAML names (no DB alias enrichment) so that files known
|
||||||
|
# under an alias still get packed at the emulator's expected path.
|
||||||
|
yaml_names: set[str] = set()
|
||||||
|
for system in config.get("systems", {}).values():
|
||||||
|
for fe in system.get("files", []):
|
||||||
|
name = fe.get("name", "")
|
||||||
|
if name:
|
||||||
|
yaml_names.add(name)
|
||||||
|
|
||||||
|
undeclared = find_undeclared_files(
|
||||||
|
config, emulators_dir, db, emu_profiles, target_cores=target_cores,
|
||||||
|
include_all=include_all, declared_names=yaml_names,
|
||||||
|
)
|
||||||
|
extras = []
|
||||||
|
seen_dests: set[str] = set(seen)
|
||||||
|
for u in undeclared:
|
||||||
|
if not u["in_repo"]:
|
||||||
|
continue
|
||||||
|
# For archive entries, use the archive name for resolution
|
||||||
|
archive = u.get("archive")
|
||||||
|
name = archive if archive else u["name"]
|
||||||
|
raw_dest = archive if archive else (u.get("path") or u["name"])
|
||||||
|
# Directory path: append filename (e.g. "cafeLibs/" + "snd_user.rpl")
|
||||||
|
dest = f"{raw_dest}{u['name']}" if raw_dest.endswith("/") else raw_dest
|
||||||
|
|
||||||
|
# Slug-based platforms: prefix dest with system slug
|
||||||
|
if is_slug_based:
|
||||||
|
emu_name = u.get("profile") or u.get("emulator", "")
|
||||||
|
profile = profiles.get(emu_name, {})
|
||||||
|
# Try finding profile by display name if key lookup failed
|
||||||
|
if not profile:
|
||||||
|
for pn, pp in profiles.items():
|
||||||
|
if pp.get("emulator") == emu_name:
|
||||||
|
profile = pp
|
||||||
|
break
|
||||||
|
slug = _map_emulator_to_slug(
|
||||||
|
profile,
|
||||||
|
platform_systems,
|
||||||
|
norm_map,
|
||||||
|
sys_to_slug,
|
||||||
|
)
|
||||||
|
if not slug:
|
||||||
|
continue # can't place without slug
|
||||||
|
dest = f"{slug}/{dest}"
|
||||||
|
|
||||||
|
full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest
|
||||||
|
if full_dest in seen_dests:
|
||||||
|
continue
|
||||||
|
seen_dests.add(full_dest)
|
||||||
|
extra = {
|
||||||
|
"name": name,
|
||||||
|
"destination": dest,
|
||||||
|
"required": u.get("required", False),
|
||||||
|
"hle_fallback": u.get("hle_fallback", False),
|
||||||
|
"source_emulator": u.get("emulator", ""),
|
||||||
|
"source_profile": u.get("profile", ""),
|
||||||
|
# Identity of the report entry this extra came from. The name
|
||||||
|
# alone does not identify it: Dolphin declares three IPL.bin that
|
||||||
|
# differ only by path, and collapsing them onto one key withdraws
|
||||||
|
# the wrong regional variant from the report.
|
||||||
|
"source_name": u.get("name", ""),
|
||||||
|
"source_path": u.get("path") or "",
|
||||||
|
"source_system": u.get("system"),
|
||||||
|
"source_systems": u.get("systems", []),
|
||||||
|
"region": u.get("region"),
|
||||||
|
"variant_group": u.get("variant_group"),
|
||||||
|
}
|
||||||
|
# Keep every reproducible identity constraint carried by the source
|
||||||
|
# profile. Some profiles expose CRC32/SHA-256 only through their
|
||||||
|
# validation block; dropping those fields here would let a same-named
|
||||||
|
# but different payload enter a generated pack.
|
||||||
|
expected = u.get("expected") or {}
|
||||||
|
for identity in ("sha1", "md5", "sha256", "crc32", "size"):
|
||||||
|
declared = u.get(identity)
|
||||||
|
if declared in (None, "", []):
|
||||||
|
declared = expected.get(identity)
|
||||||
|
if declared not in (None, "", []):
|
||||||
|
extra[identity] = declared
|
||||||
|
extras.append(extra)
|
||||||
|
|
||||||
|
# Second pass: find alternative destinations for files already in the pack.
|
||||||
|
# A file declared by the platform or emitted above may also be needed at a
|
||||||
|
# different path by another core (e.g. neocd/ vs root, same_cdi/bios/ vs root).
|
||||||
|
# Only adds a copy when the file is ALREADY covered at a different path -
|
||||||
|
# never introduces a file that wasn't selected by the first pass.
|
||||||
|
#
|
||||||
|
# Skip for slug-based platforms (RomM): alternative paths don't map to
|
||||||
|
# the required {platform_slug}/{file} structure.
|
||||||
|
if is_slug_based:
|
||||||
|
return extras
|
||||||
|
|
||||||
|
relevant = resolve_platform_cores(config, profiles, target_cores=target_cores)
|
||||||
|
standalone_set = {str(c) for c in config.get("standalone_cores", [])}
|
||||||
|
by_name = db.get("indexes", {}).get("by_name", {})
|
||||||
|
by_path_suffix = db.get("indexes", {}).get("by_path_suffix", {})
|
||||||
|
|
||||||
|
# Build set of filenames already covered (platform baseline + first pass extras)
|
||||||
|
# Enriched with canonical names from DB via MD5 (handles platform renaming)
|
||||||
|
covered_names = expand_platform_declared_names(config, db)
|
||||||
|
for e in extras:
|
||||||
|
covered_names.add(e["name"])
|
||||||
|
|
||||||
|
for emu_name, profile in sorted(profiles.items()):
|
||||||
|
if profile.get("type") in ("launcher", "alias"):
|
||||||
|
continue
|
||||||
|
if emu_name not in relevant:
|
||||||
|
continue
|
||||||
|
is_standalone = emu_name in standalone_set or bool(
|
||||||
|
standalone_set & {str(c) for c in profile.get("cores", [])}
|
||||||
|
)
|
||||||
|
for f in profile.get("files", []):
|
||||||
|
fname = f.get("name", "")
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
# Only duplicate files already covered at another destination
|
||||||
|
if fname not in covered_names:
|
||||||
|
continue
|
||||||
|
file_mode = f.get("mode")
|
||||||
|
if file_mode == "standalone" and not is_standalone:
|
||||||
|
continue
|
||||||
|
if file_mode == "libretro" and is_standalone:
|
||||||
|
continue
|
||||||
|
# Skip files loaded from non-system directories (save_dir, content_dir)
|
||||||
|
load_from = f.get("load_from", "")
|
||||||
|
if load_from and load_from != "system_dir":
|
||||||
|
continue
|
||||||
|
if is_standalone:
|
||||||
|
raw = f.get("standalone_path") or f.get("path") or fname
|
||||||
|
else:
|
||||||
|
raw = f.get("path") or fname
|
||||||
|
dest = f"{raw}{fname}" if raw.endswith("/") else raw
|
||||||
|
if dest == fname:
|
||||||
|
continue # no alternative destination
|
||||||
|
full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest
|
||||||
|
if full_dest in seen_dests:
|
||||||
|
continue
|
||||||
|
# Check file exists in repo or data dirs
|
||||||
|
if not (
|
||||||
|
by_name.get(fname)
|
||||||
|
or by_name.get(dest.rsplit("/", 1)[-1])
|
||||||
|
or by_path_suffix.get(dest)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
seen_dests.add(full_dest)
|
||||||
|
extras.append(
|
||||||
|
{
|
||||||
|
"name": fname,
|
||||||
|
"destination": dest,
|
||||||
|
"required": f.get("required", False),
|
||||||
|
"hle_fallback": f.get("hle_fallback", False),
|
||||||
|
"source_emulator": profile.get("emulator", emu_name),
|
||||||
|
"source_profile": emu_name,
|
||||||
|
"source_system": f.get("system"),
|
||||||
|
"source_systems": list(profile.get("systems", [])),
|
||||||
|
"region": f.get("region"),
|
||||||
|
"variant_group": f.get("variant_group"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Archive prefix pass: cores that store BIOS archives in a subdirectory
|
||||||
|
# (e.g. system/fbneo/neogeo.zip). When the archive is already covered at
|
||||||
|
# the root, add a copy at the prefixed path so the core's .info firmware
|
||||||
|
# check finds it.
|
||||||
|
for emu_name, profile in sorted(profiles.items()):
|
||||||
|
if profile.get("type") in ("launcher", "alias"):
|
||||||
|
continue
|
||||||
|
if emu_name not in relevant:
|
||||||
|
continue
|
||||||
|
prefix = profile.get("archive_prefix", "")
|
||||||
|
if not prefix:
|
||||||
|
continue
|
||||||
|
profile_archives: dict[str, dict] = {}
|
||||||
|
for f in profile.get("files", []):
|
||||||
|
archive = f.get("archive", "")
|
||||||
|
if archive:
|
||||||
|
profile_archives.setdefault(archive, f)
|
||||||
|
for archive_name, archive_entry in sorted(profile_archives.items()):
|
||||||
|
if archive_name not in covered_names:
|
||||||
|
continue
|
||||||
|
dest = f"{prefix}/{archive_name}"
|
||||||
|
full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest
|
||||||
|
if full_dest in seen_dests:
|
||||||
|
continue
|
||||||
|
if not by_name.get(archive_name):
|
||||||
|
continue
|
||||||
|
seen_dests.add(full_dest)
|
||||||
|
extras.append(
|
||||||
|
{
|
||||||
|
"name": archive_name,
|
||||||
|
"destination": dest,
|
||||||
|
"required": True,
|
||||||
|
"hle_fallback": False,
|
||||||
|
"source_emulator": profile.get("emulator", emu_name),
|
||||||
|
"source_profile": emu_name,
|
||||||
|
"source_system": archive_entry.get("system"),
|
||||||
|
"source_systems": list(profile.get("systems", [])),
|
||||||
|
"region": archive_entry.get("region"),
|
||||||
|
"variant_group": archive_entry.get("variant_group"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Third pass: agnostic scan — for filename-agnostic cores, include all
|
||||||
|
# DB files matching the system path prefix and size criteria.
|
||||||
|
files_db = db.get("files", {})
|
||||||
|
for emu_name, profile in sorted(profiles.items()):
|
||||||
|
if profile.get("type") in ("launcher", "alias"):
|
||||||
|
continue
|
||||||
|
if emu_name not in relevant:
|
||||||
|
continue
|
||||||
|
is_profile_agnostic = profile.get("bios_mode") == "agnostic"
|
||||||
|
if not is_profile_agnostic:
|
||||||
|
if not any(f.get("agnostic") for f in profile.get("files", [])):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Where each of this profile's files resolves. A scan anchored on one
|
||||||
|
# filename walks into whatever tree that name happens to hit: rom1.bin
|
||||||
|
# is a PS2 ROM and a Roland SC-55 ROM, GameIndex.yaml belongs to four
|
||||||
|
# PS2 profiles and to an Android package. One match is not evidence
|
||||||
|
# of a directory; two files of the same profile agreeing is.
|
||||||
|
resolved_dirs: dict[int, str] = {}
|
||||||
|
named_only: set[int] = set()
|
||||||
|
agnostic_votes: dict[str, int] = {}
|
||||||
|
for candidate in profile.get("files", []):
|
||||||
|
if not isinstance(candidate, dict):
|
||||||
|
continue
|
||||||
|
local, status = resolve_local_file(
|
||||||
|
candidate, db, dest_hint=candidate.get("path", "")
|
||||||
|
)
|
||||||
|
if not local or "/" not in local:
|
||||||
|
continue
|
||||||
|
directory = local.rsplit("/", 1)[0]
|
||||||
|
if directory.endswith("/.variants"):
|
||||||
|
directory = directory[: -len("/.variants")]
|
||||||
|
resolved_dirs[id(candidate)] = directory + "/"
|
||||||
|
if not (resolution_is_hash_exact(status) or status == "path_exact"):
|
||||||
|
named_only.add(id(candidate))
|
||||||
|
agnostic_votes[directory + "/"] = (
|
||||||
|
agnostic_votes.get(directory + "/", 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
for f in profile.get("files", []):
|
||||||
|
if not is_profile_agnostic and not f.get("agnostic"):
|
||||||
|
continue
|
||||||
|
fname = f.get("name", "")
|
||||||
|
if not fname:
|
||||||
|
continue
|
||||||
|
# An agnostic BIOS mode says the BIOS filename is free, not that
|
||||||
|
# every file the emulator loads is. A free filename still has a
|
||||||
|
# known shape, and that shape is what identifies a candidate: an
|
||||||
|
# entry declaring no size takes the whole directory, which is how
|
||||||
|
# the flag icons of an Android package walked into a PS2 pack.
|
||||||
|
if f.get("category", "bios") != "bios":
|
||||||
|
continue
|
||||||
|
if not (f.get("size") or f.get("min_size") or f.get("max_size")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
path_prefix = resolved_dirs.get(id(f), "")
|
||||||
|
if not path_prefix:
|
||||||
|
continue
|
||||||
|
# A name matching one file identifies it. A name matching several
|
||||||
|
# identifies nothing on its own, so the profile's other files have
|
||||||
|
# to agree on the directory before the scan walks it.
|
||||||
|
ambiguous = id(f) in named_only and len(by_name.get(fname, [])) > 1
|
||||||
|
if ambiguous and agnostic_votes.get(path_prefix, 0) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Size criteria from the file entry
|
||||||
|
min_size = f.get("min_size", 0)
|
||||||
|
max_size = f.get("max_size", float("inf"))
|
||||||
|
exact_size = f.get("size")
|
||||||
|
if exact_size and not min_size:
|
||||||
|
min_size = exact_size
|
||||||
|
max_size = exact_size
|
||||||
|
|
||||||
|
# Scan DB for all files under this prefix matching size
|
||||||
|
for sha1, entry in files_db.items():
|
||||||
|
path = entry.get("path", "")
|
||||||
|
if not path.startswith(path_prefix):
|
||||||
|
continue
|
||||||
|
size = entry.get("size", 0)
|
||||||
|
if not (min_size <= size <= max_size):
|
||||||
|
continue
|
||||||
|
scan_name = entry.get("name", "")
|
||||||
|
if not scan_name:
|
||||||
|
continue
|
||||||
|
dest = scan_name
|
||||||
|
full_dest = f"{extras_prefix}/{dest}" if extras_prefix else dest
|
||||||
|
if full_dest in seen_dests:
|
||||||
|
continue
|
||||||
|
seen_dests.add(full_dest)
|
||||||
|
extras.append(
|
||||||
|
{
|
||||||
|
"name": scan_name,
|
||||||
|
"destination": dest,
|
||||||
|
# The scan already holds the entry it selected, so it
|
||||||
|
# names it by content. Emitting the filename alone
|
||||||
|
# sent the packing step back to a by-name lookup, and
|
||||||
|
# three PS2 resources came back from another
|
||||||
|
# emulator's copy of the same filename.
|
||||||
|
"sha1": sha1,
|
||||||
|
"required": False,
|
||||||
|
"hle_fallback": False,
|
||||||
|
"source_emulator": profile.get("emulator", emu_name),
|
||||||
|
"source_profile": emu_name,
|
||||||
|
"source_system": f.get("system"),
|
||||||
|
"source_systems": list(profile.get("systems", [])),
|
||||||
|
"region": f.get("region"),
|
||||||
|
"variant_group": f.get("variant_group"),
|
||||||
|
"agnostic_scan": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return extras
|
||||||
|
|
||||||
|
def _extra_system_ids(extra: dict) -> list[str]:
|
||||||
|
"""Return the narrowest system ownership preserved on a core extra."""
|
||||||
|
explicit = extra.get("source_system")
|
||||||
|
if explicit:
|
||||||
|
return [str(explicit)]
|
||||||
|
return [str(value) for value in extra.get("source_systems", []) if value]
|
||||||
|
|
||||||
|
def platform_region_groups(
|
||||||
|
config: dict,
|
||||||
|
systems: dict,
|
||||||
|
emulators_dir: str,
|
||||||
|
db: dict | None,
|
||||||
|
base_dest: str,
|
||||||
|
emu_profiles: dict | None,
|
||||||
|
*,
|
||||||
|
target_cores: set[str] | None = None,
|
||||||
|
include_extras: bool = True,
|
||||||
|
include_all: bool = False,
|
||||||
|
) -> tuple[dict[str, list[tuple[str, str]]], dict[tuple[str, str, str], str]]:
|
||||||
|
"""Group a platform's pack candidates the way region filtering reads them.
|
||||||
|
|
||||||
|
Returns the groups and, for every core extra, the destination it was
|
||||||
|
grouped under, keyed by (emulator, name). verify.py needs that mapping to
|
||||||
|
withdraw from its report exactly what the builder withdraws from the pack:
|
||||||
|
grouping the declared files here and the core extras there would let the
|
||||||
|
two answer differently on the same request.
|
||||||
|
"""
|
||||||
|
groups: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
for sys_id, system in systems.items():
|
||||||
|
members = groups.setdefault(sys_id, [])
|
||||||
|
for file_entry in system.get("files", []):
|
||||||
|
dest = sanitize_pack_path(
|
||||||
|
file_entry.get("destination", file_entry.get("name", ""))
|
||||||
|
)
|
||||||
|
if dest:
|
||||||
|
members.append((dest, file_entry.get("name", "")))
|
||||||
|
|
||||||
|
extra_dests: dict[tuple[str, str, str], str] = {}
|
||||||
|
if not include_extras or db is None:
|
||||||
|
return groups, extra_dests
|
||||||
|
|
||||||
|
for extra in _collect_emulator_extras(
|
||||||
|
config,
|
||||||
|
emulators_dir,
|
||||||
|
db,
|
||||||
|
set(),
|
||||||
|
base_dest,
|
||||||
|
emu_profiles,
|
||||||
|
target_cores=target_cores,
|
||||||
|
include_all=include_all,
|
||||||
|
):
|
||||||
|
dest = sanitize_pack_path(extra.get("destination", extra.get("name", "")))
|
||||||
|
if not dest:
|
||||||
|
continue
|
||||||
|
name = extra.get("name", "")
|
||||||
|
extra_dests[
|
||||||
|
(
|
||||||
|
extra.get("source_emulator", ""),
|
||||||
|
extra.get("source_name", ""),
|
||||||
|
extra.get("source_path", ""),
|
||||||
|
)
|
||||||
|
] = dest
|
||||||
|
variant = extra.get("variant_group")
|
||||||
|
for sys_id in _extra_system_ids(extra) or ["_extras"]:
|
||||||
|
group_id = f"{sys_id}:variant:{variant}" if variant else sys_id
|
||||||
|
groups.setdefault(group_id, []).append((dest, name))
|
||||||
|
return groups, extra_dests
|
||||||
|
|
||||||
|
def _emulator_region_group(emu_name: str, profile: dict, file_entry: dict) -> str:
|
||||||
|
"""Stable group ID for regional alternatives within an emulator profile."""
|
||||||
|
variant = file_entry.get("variant_group")
|
||||||
|
if variant:
|
||||||
|
return f"{emu_name}:variant:{variant}"
|
||||||
|
system = file_entry.get("system")
|
||||||
|
profile_systems = list(profile.get("systems", []))
|
||||||
|
if not system and len(profile_systems) == 1:
|
||||||
|
system = profile_systems[0]
|
||||||
|
return f"{emu_name}:system:{system or '_profile'}"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Where a file lands inside a pack.
|
||||||
|
|
||||||
|
Two files may not claim the same destination, and a destination may
|
||||||
|
not sit inside another file's path: a pack that cannot be extracted
|
||||||
|
on Windows is not a pack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from common import sanitize_pack_path
|
||||||
|
def _path_parents(dest: str) -> list[str]:
|
||||||
|
"""Return all parent directory segments of a path."""
|
||||||
|
parts = dest.split("/")
|
||||||
|
return ["/".join(parts[:i]) for i in range(1, len(parts))]
|
||||||
|
|
||||||
|
def _has_path_conflict(dest: str, seen_files: set[str], seen_parents: set[str]) -> bool:
|
||||||
|
"""Check if dest conflicts with existing paths (file vs directory).
|
||||||
|
|
||||||
|
Returns True if adding dest would create an impossible extraction:
|
||||||
|
- A parent of dest is already a file (e.g., adding X/Y when X is a file)
|
||||||
|
- dest itself is already used as a directory (e.g., adding X when X/Y exists)
|
||||||
|
"""
|
||||||
|
for parent in _path_parents(dest):
|
||||||
|
if parent in seen_files:
|
||||||
|
return True
|
||||||
|
if dest in seen_parents:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _register_path(dest: str, seen_files: set[str], seen_parents: set[str]) -> None:
|
||||||
|
"""Track a file path and its parent directories."""
|
||||||
|
seen_files.add(dest)
|
||||||
|
for parent in _path_parents(dest):
|
||||||
|
seen_parents.add(parent)
|
||||||
|
|
||||||
|
def _flat(arcname: str, prefix: str, flatten: bool) -> str:
|
||||||
|
"""Strip base_destination prefix from ZIP arcname when flattening."""
|
||||||
|
if flatten and prefix and arcname.startswith(prefix + "/"):
|
||||||
|
return arcname[len(prefix) + 1:]
|
||||||
|
return arcname
|
||||||
|
|
||||||
|
def _resolve_destination(
|
||||||
|
file_entry: dict, pack_structure: dict | None, standalone: bool
|
||||||
|
) -> str:
|
||||||
|
"""Resolve the ZIP destination path for a file entry."""
|
||||||
|
# 1. standalone_path override
|
||||||
|
if standalone and file_entry.get("standalone_path"):
|
||||||
|
rel = file_entry["standalone_path"]
|
||||||
|
# 2. path field
|
||||||
|
elif file_entry.get("path"):
|
||||||
|
rel = file_entry["path"]
|
||||||
|
# 3. name fallback
|
||||||
|
else:
|
||||||
|
rel = file_entry.get("name", "")
|
||||||
|
|
||||||
|
rel = sanitize_pack_path(rel)
|
||||||
|
|
||||||
|
# Prepend pack_structure prefix
|
||||||
|
if pack_structure:
|
||||||
|
mode_key = "standalone" if standalone else "libretro"
|
||||||
|
prefix = pack_structure.get(mode_key, "")
|
||||||
|
if prefix:
|
||||||
|
rel = f"{prefix}/{rel}"
|
||||||
|
|
||||||
|
return rel
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""The notes shipped inside a pack.
|
||||||
|
|
||||||
|
A pack has to explain itself offline: what it holds, where each file
|
||||||
|
goes, and which ones the user has to rename."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
def _build_readme(
|
||||||
|
platform_name: str,
|
||||||
|
platform_display: str,
|
||||||
|
base_dest: str,
|
||||||
|
total_files: int,
|
||||||
|
num_systems: int,
|
||||||
|
source: str = "full",
|
||||||
|
contributors: list[dict] | None = None,
|
||||||
|
regions: list[str] | None = None,
|
||||||
|
fallback_systems: list[str] | None = None,
|
||||||
|
one_per_slot: bool = False,
|
||||||
|
undecidable_slots: list[str] | None = None,
|
||||||
|
narrowings: list[tuple[str, str]] | None = None,
|
||||||
|
system_filter: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a personalized step-by-step README for each platform pack."""
|
||||||
|
narrowings = narrowings or []
|
||||||
|
sep = "=" * 50
|
||||||
|
header = (
|
||||||
|
f"{sep}\n"
|
||||||
|
f" RETROBIOS - {platform_display} BIOS Pack\n"
|
||||||
|
f" {total_files} files for {num_systems} systems\n"
|
||||||
|
f"{sep}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
guides: dict[str, str] = {
|
||||||
|
"retroarch": (
|
||||||
|
"INSTALLATION GUIDE\n\n"
|
||||||
|
" Option A: Automatic (recommended)\n"
|
||||||
|
" ---------------------------------\n"
|
||||||
|
" Run this in a terminal:\n\n"
|
||||||
|
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
|
||||||
|
" The script auto-detects your RetroArch install and copies\n"
|
||||||
|
" files to the correct location.\n\n"
|
||||||
|
" Option B: Manual (PC)\n"
|
||||||
|
" ---------------------\n"
|
||||||
|
" 1. Find your RetroArch system directory:\n"
|
||||||
|
" - RetroArch > Settings > Directory > System/BIOS\n"
|
||||||
|
" - Default: retroarch/system/\n"
|
||||||
|
" 2. Extract all files from this archive directly into your system directory\n"
|
||||||
|
" 3. Overwrite if asked\n\n"
|
||||||
|
" Option C: Manual (handheld / SD card)\n"
|
||||||
|
" -------------------------------------\n"
|
||||||
|
" Anbernic, Retroid, Miyoo, Trimui, etc.:\n"
|
||||||
|
" 1. Connect your SD card to your PC\n"
|
||||||
|
" 2. Find the BIOS folder (usually BIOS/ or system/)\n"
|
||||||
|
" 3. Extract all files from this archive directly into that folder\n"
|
||||||
|
" 4. Eject SD card and reboot your device\n\n"
|
||||||
|
" Common paths by device:\n"
|
||||||
|
" Anbernic (ArkOS/JELOS): BIOS/\n"
|
||||||
|
" Retroid (RetroArch): RetroArch/system/\n"
|
||||||
|
" Miyoo Mini (Onion OS): BIOS/\n"
|
||||||
|
" Steam Deck (RetroArch): ~/.config/retroarch/system/\n\n"
|
||||||
|
),
|
||||||
|
"batocera": (
|
||||||
|
"INSTALLATION GUIDE\n\n"
|
||||||
|
" Option A: Automatic (recommended)\n"
|
||||||
|
" ---------------------------------\n"
|
||||||
|
" Open a terminal (F1 from Batocera menu) and run:\n\n"
|
||||||
|
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
|
||||||
|
" Option B: Manual (network share)\n"
|
||||||
|
" --------------------------------\n"
|
||||||
|
" 1. On your PC, open the Batocera network share:\n"
|
||||||
|
" - Windows: \\\\BATOCERA\\share\\bios\\\n"
|
||||||
|
" - Mac/Linux: smb://batocera/share/bios/\n"
|
||||||
|
" 2. Extract all files from this archive directly into the share\n"
|
||||||
|
" 3. Overwrite if asked\n\n"
|
||||||
|
" Option C: Manual (SD card)\n"
|
||||||
|
" --------------------------\n"
|
||||||
|
" 1. Put the SD card in your PC\n"
|
||||||
|
" 2. Navigate to /userdata/bios/ on the SHARE partition\n"
|
||||||
|
" 3. Extract all files from this archive directly into that folder\n\n"
|
||||||
|
" NOTE: Dreamcast flash memory is named dc_nvmem.bin\n"
|
||||||
|
" (if your setup asks for dc_flash.bin, same file).\n\n"
|
||||||
|
),
|
||||||
|
"recalbox": (
|
||||||
|
"INSTALLATION GUIDE\n\n"
|
||||||
|
" Option A: Automatic\n"
|
||||||
|
" -------------------\n"
|
||||||
|
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
|
||||||
|
" Option B: Manual (network share)\n"
|
||||||
|
" --------------------------------\n"
|
||||||
|
" 1. On your PC, open the Recalbox network share:\n"
|
||||||
|
" - Windows: \\\\RECALBOX\\share\\bios\\\n"
|
||||||
|
" - Mac/Linux: smb://recalbox/share/bios/\n"
|
||||||
|
" 2. Extract all files from this archive directly into the share\n\n"
|
||||||
|
" Option C: Manual (SD card)\n"
|
||||||
|
" --------------------------\n"
|
||||||
|
" 1. Put the SD card in your PC\n"
|
||||||
|
" 2. Navigate to /recalbox/share/bios/\n"
|
||||||
|
" 3. Extract all files from this archive directly into that folder\n\n"
|
||||||
|
),
|
||||||
|
"emudeck": (
|
||||||
|
"INSTALLATION GUIDE (Steam Deck / Linux)\n\n"
|
||||||
|
" Option A: Automatic (recommended)\n"
|
||||||
|
" ---------------------------------\n"
|
||||||
|
" Open Konsole (or any terminal) and run:\n\n"
|
||||||
|
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
|
||||||
|
" The script places BIOS files AND sets up standalone\n"
|
||||||
|
" emulator keys automatically.\n\n"
|
||||||
|
" Option B: Manual\n"
|
||||||
|
" ----------------\n"
|
||||||
|
" 1. Open Dolphin file manager\n"
|
||||||
|
" 2. Navigate to ~/Emulation/bios/\n"
|
||||||
|
" 3. Extract all files from this archive directly into ~/Emulation/bios/\n\n"
|
||||||
|
" STANDALONE EMULATORS (extra step)\n"
|
||||||
|
" Switch and 3DS emulators need keys in specific folders:\n"
|
||||||
|
" prod.keys -> ~/.local/share/yuzu/keys/\n"
|
||||||
|
" prod.keys -> ~/.local/share/eden/keys/\n"
|
||||||
|
" prod.keys -> ~/.config/Ryujinx/system/\n"
|
||||||
|
" aes_keys.txt -> ~/Emulation/bios/citra/keys/\n"
|
||||||
|
" The automatic installer handles this for you.\n\n"
|
||||||
|
),
|
||||||
|
"retrodeck": (
|
||||||
|
"INSTALLATION GUIDE (Steam Deck / Linux)\n\n"
|
||||||
|
" Option A: Automatic (recommended)\n"
|
||||||
|
" ---------------------------------\n"
|
||||||
|
" Open Konsole (or any terminal) and run:\n\n"
|
||||||
|
" curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n\n"
|
||||||
|
" Option B: Manual\n"
|
||||||
|
" ----------------\n"
|
||||||
|
" 1. Open Dolphin file manager\n"
|
||||||
|
" 2. Show hidden files (Ctrl+H)\n"
|
||||||
|
" 3. Navigate to ~/retrodeck/bios/\n"
|
||||||
|
" 4. Extract all files from this archive directly into ~/retrodeck/bios/\n\n"
|
||||||
|
" NOTE: RetroDECK uses its own BIOS checker. After\n"
|
||||||
|
" copying, open RetroDECK > Tools > BIOS Checker to\n"
|
||||||
|
" verify everything is detected.\n\n"
|
||||||
|
),
|
||||||
|
"retrobat": (
|
||||||
|
"INSTALLATION GUIDE (Windows)\n\n"
|
||||||
|
" Option A: Automatic (recommended)\n"
|
||||||
|
" ---------------------------------\n"
|
||||||
|
" Download and run install.bat from:\n"
|
||||||
|
" https://github.com/Abdess/retrobios/releases\n\n"
|
||||||
|
" Option B: Manual\n"
|
||||||
|
" ----------------\n"
|
||||||
|
" 1. Open your RetroBat installation folder\n"
|
||||||
|
" 2. Navigate to the bios\\ subfolder\n"
|
||||||
|
" (default: C:\\RetroBat\\bios\\)\n"
|
||||||
|
" 3. Extract all files from this archive directly into your bios\\ folder\n"
|
||||||
|
" 4. Overwrite if asked\n\n"
|
||||||
|
),
|
||||||
|
"bizhawk": (
|
||||||
|
"INSTALLATION GUIDE\n\n"
|
||||||
|
" 1. Open your BizHawk installation folder\n"
|
||||||
|
" 2. Navigate to the Firmware subfolder:\n"
|
||||||
|
" - Windows: BizHawk\\Firmware\\\n"
|
||||||
|
" - Linux: ~/.config/BizHawk/Firmware/\n"
|
||||||
|
" 3. Extract all files from this archive directly into your Firmware folder\n"
|
||||||
|
" 4. In BizHawk: Config > Paths > Firmware should\n"
|
||||||
|
" point to this folder\n\n"
|
||||||
|
),
|
||||||
|
"romm": (
|
||||||
|
"INSTALLATION GUIDE (RomM server)\n\n"
|
||||||
|
" 1. Locate your RomM library folder\n"
|
||||||
|
" 2. Navigate to the bios/ subdirectory\n"
|
||||||
|
" 3. Extract all files from this archive directly into that folder\n"
|
||||||
|
" 4. Restart the RomM service to detect new files\n\n"
|
||||||
|
),
|
||||||
|
"retropie": (
|
||||||
|
"INSTALLATION GUIDE (Raspberry Pi)\n\n"
|
||||||
|
" Option A: Via network share\n"
|
||||||
|
" --------------------------\n"
|
||||||
|
" 1. On your PC, open: \\\\RETROPIE\\bios\\\n"
|
||||||
|
" 2. Extract all files from this archive directly into that folder\n\n"
|
||||||
|
" Option B: Via SSH\n"
|
||||||
|
" -----------------\n"
|
||||||
|
" 1. SSH into your Pi: ssh pi@retropie\n"
|
||||||
|
" 2. Copy files to ~/RetroPie/BIOS/\n\n"
|
||||||
|
" Option C: Via SD card\n"
|
||||||
|
" ---------------------\n"
|
||||||
|
" 1. Put the SD card in your PC\n"
|
||||||
|
" 2. Navigate to /home/pi/RetroPie/BIOS/\n"
|
||||||
|
" 3. Extract all files from this archive directly into that folder\n\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Lakka uses same guide as RetroArch
|
||||||
|
guides["lakka"] = guides["retroarch"]
|
||||||
|
|
||||||
|
guide = guides.get(
|
||||||
|
platform_name,
|
||||||
|
(
|
||||||
|
f"INSTALLATION\n\n"
|
||||||
|
f" 1. Extract all files from this archive directly into your BIOS directory\n"
|
||||||
|
f" 2. Overwrite if asked\n\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if regions:
|
||||||
|
region_help = (
|
||||||
|
" - Wrong region? This pack was filtered. Only the\n"
|
||||||
|
" best-matching BIOS was kept per system. Use the\n"
|
||||||
|
" unfiltered pack to play imports.\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
region_help = (
|
||||||
|
" - Wrong region? Some systems have regional BIOS\n"
|
||||||
|
" variants (USA/EUR/JAP). All are included.\n"
|
||||||
|
)
|
||||||
|
footer = (
|
||||||
|
"TROUBLESHOOTING\n\n"
|
||||||
|
" - Core says BIOS missing? Check the exact filename\n"
|
||||||
|
" and make sure it's in the right subfolder.\n"
|
||||||
|
f"{region_help}"
|
||||||
|
" - Need help? https://github.com/Abdess/retrobios/issues\n\n"
|
||||||
|
f"{sep}\n"
|
||||||
|
f" https://github.com/Abdess/retrobios\n"
|
||||||
|
f"{sep}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
source_info = ""
|
||||||
|
if source == "platform":
|
||||||
|
source_info = (
|
||||||
|
"PACK TYPE: Platform Only\n\n"
|
||||||
|
f" This pack contains only files declared by {platform_display}.\n"
|
||||||
|
" Core extras from emulator profiles are not included.\n"
|
||||||
|
" Use the Full pack for maximum coverage.\n\n"
|
||||||
|
)
|
||||||
|
elif source == "truth":
|
||||||
|
source_info = (
|
||||||
|
"PACK TYPE: Ground Truth\n\n"
|
||||||
|
" This pack contains files that emulators actually load,\n"
|
||||||
|
" based on source code analysis of emulator profiles.\n"
|
||||||
|
" Independent of platform scraper accuracy.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
region_info = ""
|
||||||
|
if regions:
|
||||||
|
pretty = ", ".join(
|
||||||
|
" ".join(w.title() for w in slug.split("-")) for slug in regions
|
||||||
|
)
|
||||||
|
region_info = (
|
||||||
|
"PACK TYPE: Region Filtered\n\n"
|
||||||
|
f" Region priority: {pretty}\n\n"
|
||||||
|
" Only the best-matching BIOS was kept for each system.\n"
|
||||||
|
)
|
||||||
|
if fallback_systems:
|
||||||
|
listed = "\n".join(f" {s}" for s in fallback_systems)
|
||||||
|
region_info += (
|
||||||
|
"\n These systems have no BIOS in those regions, so all\n"
|
||||||
|
" of theirs were kept:\n"
|
||||||
|
f"{listed}\n"
|
||||||
|
)
|
||||||
|
region_info += (
|
||||||
|
"\n This shrinks the pack. It does not change how cores pick\n"
|
||||||
|
" a BIOS: most already select per region from fixed filename\n"
|
||||||
|
" lists driven by the game's region. Loading imports from\n"
|
||||||
|
" another region may need the unfiltered pack.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
slot_info = ""
|
||||||
|
if one_per_slot:
|
||||||
|
slot_info = (
|
||||||
|
" Where a core declares which BIOS it prefers, only that one was\n"
|
||||||
|
" kept for each system and region. Systems whose cores declare no\n"
|
||||||
|
" order keep all of theirs.\n"
|
||||||
|
)
|
||||||
|
if undecidable_slots:
|
||||||
|
slot_info += (
|
||||||
|
f" {len(undecidable_slots)} slot(s) had no declared order.\n"
|
||||||
|
)
|
||||||
|
slot_info += "\n"
|
||||||
|
|
||||||
|
narrowed = ""
|
||||||
|
labels = [label for _tag, label in narrowings]
|
||||||
|
if system_filter:
|
||||||
|
labels.append(f"systems {', '.join(system_filter)}")
|
||||||
|
if labels:
|
||||||
|
listed = "".join(f" {label}\n" for label in labels)
|
||||||
|
narrowed = (
|
||||||
|
"PACK TYPE: Narrowed\n\n"
|
||||||
|
" This pack holds fewer files than the full one:\n"
|
||||||
|
f"{listed}"
|
||||||
|
"\n The unfiltered pack is the one to use when in doubt.\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
credits = ""
|
||||||
|
if contributors:
|
||||||
|
credits = "\nCONTRIBUTORS\n\n"
|
||||||
|
for cb in contributors:
|
||||||
|
username = cb.get("username", "")
|
||||||
|
credits += f" @{username}\n"
|
||||||
|
credits += "\n"
|
||||||
|
|
||||||
|
return (
|
||||||
|
header + narrowed + source_info + region_info + slot_info
|
||||||
|
+ guide + credits + footer
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_agnostic_rename_readme(
|
||||||
|
destination: str,
|
||||||
|
original: str,
|
||||||
|
alternatives: list[str],
|
||||||
|
) -> str:
|
||||||
|
"""Build a README explaining an agnostic file rename."""
|
||||||
|
lines = [
|
||||||
|
"This file was renamed for compatibility:",
|
||||||
|
f" {destination} <- {original}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if alternatives:
|
||||||
|
lines.append("All variants included in this pack:")
|
||||||
|
for alt in sorted(alternatives):
|
||||||
|
lines.append(f" {alt}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"To use a different variant, rename it to: {destination}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Finding the bytes a pack entry names.
|
||||||
|
|
||||||
|
Resolution proper lives in common; this layer adds what only a build
|
||||||
|
needs: storage tiers, release assets for files too large to commit,
|
||||||
|
and the offline switch that forbids reaching for the network."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
_HEX_RE = re.compile(r"\b([0-9a-fA-F]{8,40})\b")
|
||||||
|
from validation import check_file_validation
|
||||||
|
from largefiles import fetch_large_file
|
||||||
|
import os
|
||||||
|
from hashing import parse_md5_list
|
||||||
|
from common import resolve_local_file
|
||||||
|
# CLI-wide network policy. Public helpers can override it explicitly, while
|
||||||
|
# every CLI mode inherits --offline (including large-file release fallback).
|
||||||
|
_OFFLINE = False
|
||||||
|
|
||||||
|
|
||||||
|
def set_offline(value: bool) -> None:
|
||||||
|
"""Forbid every network reach for the rest of the run."""
|
||||||
|
global _OFFLINE
|
||||||
|
_OFFLINE = value
|
||||||
|
|
||||||
|
def _detect_hash_type(h: str) -> str:
|
||||||
|
n = len(h)
|
||||||
|
if n == 40:
|
||||||
|
return "sha1"
|
||||||
|
if n == 32:
|
||||||
|
return "md5"
|
||||||
|
if n == 8:
|
||||||
|
return "crc32"
|
||||||
|
return "md5"
|
||||||
|
|
||||||
|
def parse_hash_input(raw: str) -> list[tuple[str, str]]:
|
||||||
|
"""Parse comma-separated hash string into (type, hash) tuples."""
|
||||||
|
results: list[tuple[str, str]] = []
|
||||||
|
for part in raw.split(","):
|
||||||
|
part = part.strip().lower()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
m = _HEX_RE.search(part)
|
||||||
|
if m:
|
||||||
|
h = m.group(1)
|
||||||
|
results.append((_detect_hash_type(h), h))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def parse_hash_file(path: str) -> list[tuple[str, str]]:
|
||||||
|
"""Parse hash file (one per line, comments with #, mixed formats)."""
|
||||||
|
results: list[tuple[str, str]] = []
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
m = _HEX_RE.search(line.lower())
|
||||||
|
if m:
|
||||||
|
h = m.group(1)
|
||||||
|
results.append((_detect_hash_type(h), h))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def lookup_hashes(
|
||||||
|
hashes: list[tuple[str, str]],
|
||||||
|
db: dict,
|
||||||
|
bios_dir: str,
|
||||||
|
emulators_dir: str,
|
||||||
|
platforms_dir: str,
|
||||||
|
) -> None:
|
||||||
|
"""Print diagnostic info for each hash."""
|
||||||
|
files_db = db.get("files", {})
|
||||||
|
by_md5 = db.get("indexes", {}).get("by_md5", {})
|
||||||
|
by_crc32 = db.get("indexes", {}).get("by_crc32", {})
|
||||||
|
|
||||||
|
for hash_type, hash_val in hashes:
|
||||||
|
sha1 = None
|
||||||
|
if hash_type == "sha1" and hash_val in files_db:
|
||||||
|
sha1 = hash_val
|
||||||
|
elif hash_type == "md5":
|
||||||
|
sha1 = by_md5.get(hash_val)
|
||||||
|
elif hash_type == "crc32":
|
||||||
|
sha1 = by_crc32.get(hash_val)
|
||||||
|
|
||||||
|
if not sha1 or sha1 not in files_db:
|
||||||
|
print(f"\n{hash_type.upper()}: {hash_val}")
|
||||||
|
print(" NOT FOUND in database")
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = files_db[sha1]
|
||||||
|
name = entry.get("name", "?")
|
||||||
|
md5 = entry.get("md5", "?")
|
||||||
|
paths = entry.get("paths") or []
|
||||||
|
aliases = entry.get("aliases") or []
|
||||||
|
|
||||||
|
print(f"\n{hash_type.upper()}: {hash_val}")
|
||||||
|
print(f" SHA1: {sha1}")
|
||||||
|
print(f" MD5: {md5}")
|
||||||
|
print(f" Name: {name}")
|
||||||
|
if paths:
|
||||||
|
print(f" Path: {paths[0]}")
|
||||||
|
if aliases:
|
||||||
|
print(f" Aliases: {aliases}")
|
||||||
|
|
||||||
|
# Check if file exists in repo (by path or by resolve_local_file)
|
||||||
|
in_repo = False
|
||||||
|
if paths:
|
||||||
|
primary = os.path.join(bios_dir, paths[0])
|
||||||
|
if os.path.exists(primary):
|
||||||
|
in_repo = True
|
||||||
|
if not in_repo:
|
||||||
|
try:
|
||||||
|
fe_check = {"name": name, "sha1": sha1, "md5": md5}
|
||||||
|
local, status = resolve_file(fe_check, db, bios_dir, {})
|
||||||
|
if local and status != "not_found":
|
||||||
|
in_repo = True
|
||||||
|
except (KeyError, OSError):
|
||||||
|
pass
|
||||||
|
print(f" In repo: {'YES' if in_repo else 'NO'}")
|
||||||
|
|
||||||
|
def _find_candidate_satisfying_both(
|
||||||
|
file_entry: dict,
|
||||||
|
db: dict,
|
||||||
|
local_path: str,
|
||||||
|
validation_index: dict,
|
||||||
|
bios_dir: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""Search for a repo file that satisfies both platform MD5 and emulator validation.
|
||||||
|
|
||||||
|
When the current file passes platform verification but fails emulator checks,
|
||||||
|
search all candidates with the same name for one that passes both.
|
||||||
|
Returns a better path, or None if no upgrade found.
|
||||||
|
"""
|
||||||
|
fname = file_entry.get("name", "")
|
||||||
|
if not fname:
|
||||||
|
return None
|
||||||
|
entry = validation_index.get(fname)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
md5_expected = file_entry.get("md5", "")
|
||||||
|
md5_set = (
|
||||||
|
{m.strip().lower() for m in md5_expected.split(",") if m.strip()}
|
||||||
|
if md5_expected
|
||||||
|
else set()
|
||||||
|
)
|
||||||
|
|
||||||
|
by_name = db.get("indexes", {}).get("by_name", {})
|
||||||
|
files_db = db.get("files", {})
|
||||||
|
|
||||||
|
for sha1 in by_name.get(fname, []):
|
||||||
|
candidate = files_db.get(sha1, {})
|
||||||
|
path = candidate.get("path", "")
|
||||||
|
if (
|
||||||
|
not path
|
||||||
|
or not os.path.exists(path)
|
||||||
|
or os.path.realpath(path) == os.path.realpath(local_path)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
# Must still satisfy platform MD5
|
||||||
|
if md5_set and candidate.get("md5", "").lower() not in md5_set:
|
||||||
|
continue
|
||||||
|
# Check emulator validation
|
||||||
|
reason = check_file_validation(path, fname, validation_index, bios_dir)
|
||||||
|
if reason is None:
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_file(
|
||||||
|
file_entry: dict,
|
||||||
|
db: dict,
|
||||||
|
bios_dir: str,
|
||||||
|
zip_contents: dict | None = None,
|
||||||
|
dest_hint: str = "",
|
||||||
|
data_dir_registry: dict | None = None,
|
||||||
|
*,
|
||||||
|
offline: bool | None = None,
|
||||||
|
) -> tuple[str | None, str]:
|
||||||
|
"""Resolve a BIOS file with storage tiers and release asset fallback.
|
||||||
|
|
||||||
|
Wraps common.resolve_local_file() with pack-specific logic for
|
||||||
|
storage tiers (external/user_provided), large file release assets,
|
||||||
|
and MAME clone mapping (deduped ZIPs).
|
||||||
|
"""
|
||||||
|
storage = file_entry.get("storage", "embedded")
|
||||||
|
if storage == "user_provided":
|
||||||
|
return None, "user_provided"
|
||||||
|
if storage == "external":
|
||||||
|
return None, "external"
|
||||||
|
|
||||||
|
path, status = resolve_local_file(
|
||||||
|
file_entry,
|
||||||
|
db,
|
||||||
|
zip_contents,
|
||||||
|
dest_hint=dest_hint,
|
||||||
|
data_dir_registry=data_dir_registry,
|
||||||
|
)
|
||||||
|
if path and status != "hash_mismatch":
|
||||||
|
return path, status
|
||||||
|
|
||||||
|
# Large files from GitHub release assets -tried when local file is
|
||||||
|
# missing OR has a hash mismatch (wrong variant on disk)
|
||||||
|
name = file_entry.get("name", "")
|
||||||
|
sha1 = file_entry.get("sha1")
|
||||||
|
first_sha1 = (sha1[0] if sha1 else "") if isinstance(sha1, list) else (sha1 or "")
|
||||||
|
md5_list = parse_md5_list(file_entry.get("md5"))
|
||||||
|
first_md5 = md5_list[0] if md5_list else ""
|
||||||
|
cached = fetch_large_file(
|
||||||
|
name,
|
||||||
|
expected_sha1=first_sha1,
|
||||||
|
expected_md5=first_md5,
|
||||||
|
offline=_OFFLINE if offline is None else offline,
|
||||||
|
)
|
||||||
|
if cached:
|
||||||
|
return cached, "release_asset"
|
||||||
|
|
||||||
|
# Fall back to hash_mismatch local file if release asset unavailable
|
||||||
|
if path:
|
||||||
|
return path, status
|
||||||
|
|
||||||
|
return None, "not_found"
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""Checking a built pack the way the frontend would.
|
||||||
|
|
||||||
|
The builder and the verifier have to reach the same verdict on the
|
||||||
|
same file, so both read the native mode from one place: a pack and a
|
||||||
|
coverage report that disagree describe different collections."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auto_fetch import DEFAULT_BIOS_DIR
|
||||||
|
from artifacts import _build_timestamp
|
||||||
|
from packextras import _collect_emulator_extras
|
||||||
|
from packextras import _detect_extras_prefix
|
||||||
|
from packpaths import _has_path_conflict
|
||||||
|
from packpaths import _register_path
|
||||||
|
from ziptools import build_zip_contents_index
|
||||||
|
from ziptools import check_inside_zip
|
||||||
|
from nativemode import digest_algorithm
|
||||||
|
from nativemode import hash_mismatch_excludes_file
|
||||||
|
import hashlib
|
||||||
|
from common import load_emulator_profiles
|
||||||
|
from common import load_platform_config
|
||||||
|
import os
|
||||||
|
from packextras import platform_region_groups
|
||||||
|
from nativemode import reads_file_contents
|
||||||
|
import region as region_mod
|
||||||
|
from packresolve import resolve_file
|
||||||
|
from common import sanitize_pack_path
|
||||||
|
import zipfile
|
||||||
|
def verify_pack(
|
||||||
|
zip_path: str, db: dict, data_registry: dict | None = None
|
||||||
|
) -> tuple[bool, dict]:
|
||||||
|
"""Verify a generated pack ZIP by re-hashing every file inside.
|
||||||
|
|
||||||
|
Checks against database.json, data directory caches, and verifies
|
||||||
|
rebuilt ZIP content by comparing inner CRC32s against source.
|
||||||
|
Returns (all_ok, manifest_dict).
|
||||||
|
"""
|
||||||
|
files_db = db.get("files", {}) # SHA1 -> file_info
|
||||||
|
by_md5 = db.get("indexes", {}).get("by_md5", {}) # MD5 -> SHA1
|
||||||
|
by_name = db.get("indexes", {}).get("by_name", {}) # name -> [SHA1]
|
||||||
|
|
||||||
|
# Data directory file index
|
||||||
|
_data_index: dict[str, list[str]] = {}
|
||||||
|
_data_path_index: dict[str, str] = {}
|
||||||
|
if data_registry:
|
||||||
|
for _dk, _de in data_registry.items():
|
||||||
|
cache = _de.get("local_cache", "")
|
||||||
|
if not cache or not os.path.isdir(cache):
|
||||||
|
continue
|
||||||
|
for _r, _d, _fns in os.walk(cache):
|
||||||
|
for _fn in _fns:
|
||||||
|
_fp = os.path.join(_r, _fn)
|
||||||
|
_rel = os.path.relpath(_fp, cache)
|
||||||
|
_data_path_index[_rel] = _fp
|
||||||
|
_data_index.setdefault(_fn, []).append(_fp)
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"version": 1,
|
||||||
|
"generator": "retrobios generate_pack.py",
|
||||||
|
"generated": _build_timestamp(db),
|
||||||
|
"files": [],
|
||||||
|
}
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = info.filename
|
||||||
|
if name.startswith("INSTRUCTIONS_") or name in (
|
||||||
|
"manifest.json",
|
||||||
|
"README.txt",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
with zf.open(info) as f:
|
||||||
|
sha1_h = hashlib.sha1()
|
||||||
|
md5_h = hashlib.md5()
|
||||||
|
size = 0
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
sha1_h.update(chunk)
|
||||||
|
md5_h.update(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
sha1 = sha1_h.hexdigest()
|
||||||
|
md5 = md5_h.hexdigest()
|
||||||
|
|
||||||
|
# Look up in database: files_db keyed by SHA1
|
||||||
|
db_entry = files_db.get(sha1)
|
||||||
|
status = "verified"
|
||||||
|
file_name = ""
|
||||||
|
if db_entry:
|
||||||
|
file_name = db_entry.get("name", "")
|
||||||
|
else:
|
||||||
|
# Try MD5 -> SHA1 lookup
|
||||||
|
ref_sha1 = by_md5.get(md5)
|
||||||
|
if ref_sha1:
|
||||||
|
db_entry = files_db.get(ref_sha1)
|
||||||
|
if db_entry:
|
||||||
|
file_name = db_entry.get("name", "")
|
||||||
|
status = "verified_md5"
|
||||||
|
else:
|
||||||
|
status = "untracked"
|
||||||
|
else:
|
||||||
|
status = "untracked"
|
||||||
|
|
||||||
|
# Rebuilt ZIP: verify inner ROM CRC32s match source
|
||||||
|
if status == "untracked" and name.endswith(".zip"):
|
||||||
|
_bn = os.path.basename(name)
|
||||||
|
for _src_sha1 in by_name.get(_bn, []):
|
||||||
|
if _src_sha1 not in files_db:
|
||||||
|
continue
|
||||||
|
_src_path = files_db[_src_sha1]["path"]
|
||||||
|
if not os.path.exists(_src_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
import io as _io
|
||||||
|
|
||||||
|
with zipfile.ZipFile(_src_path) as _sz:
|
||||||
|
_sc = {
|
||||||
|
i.filename: i.CRC
|
||||||
|
for i in _sz.infolist()
|
||||||
|
if not i.is_dir()
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(_io.BytesIO(zf.read(name))) as _pz:
|
||||||
|
_pc = {
|
||||||
|
i.filename: i.CRC
|
||||||
|
for i in _pz.infolist()
|
||||||
|
if not i.is_dir()
|
||||||
|
}
|
||||||
|
if _sc == _pc:
|
||||||
|
status = "verified_rebuild"
|
||||||
|
file_name = _bn
|
||||||
|
break
|
||||||
|
except (zipfile.BadZipFile, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Data directory: check against cached files
|
||||||
|
if status == "untracked" and _data_index:
|
||||||
|
_bn = os.path.basename(name)
|
||||||
|
_pr = name
|
||||||
|
for _known_prefix in ("system/", "bios/", "BIOS/", "Firmware/"):
|
||||||
|
if name.startswith(_known_prefix):
|
||||||
|
_pr = name[len(_known_prefix):]
|
||||||
|
break
|
||||||
|
_cands = []
|
||||||
|
if _pr in _data_path_index:
|
||||||
|
_cands.append(_data_path_index[_pr])
|
||||||
|
for _dp in _data_index.get(_bn, []):
|
||||||
|
if _dp not in _cands:
|
||||||
|
_cands.append(_dp)
|
||||||
|
for _dp in _cands:
|
||||||
|
if not os.path.exists(_dp):
|
||||||
|
continue
|
||||||
|
if os.path.getsize(_dp) == size:
|
||||||
|
status = "verified_data"
|
||||||
|
file_name = _bn
|
||||||
|
break
|
||||||
|
if name.endswith(".zip") and _dp.endswith(".zip"):
|
||||||
|
try:
|
||||||
|
import io as _io2
|
||||||
|
|
||||||
|
with zipfile.ZipFile(_io2.BytesIO(zf.read(name))) as _pz2:
|
||||||
|
_pc2 = {
|
||||||
|
i.filename: i.CRC
|
||||||
|
for i in _pz2.infolist()
|
||||||
|
if not i.is_dir()
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(_dp) as _dz:
|
||||||
|
_dc = {
|
||||||
|
i.filename: i.CRC
|
||||||
|
for i in _dz.infolist()
|
||||||
|
if not i.is_dir()
|
||||||
|
}
|
||||||
|
if _pc2 == _dc:
|
||||||
|
status = "verified_data"
|
||||||
|
file_name = _bn
|
||||||
|
break
|
||||||
|
except (zipfile.BadZipFile, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
manifest["files"].append(
|
||||||
|
{
|
||||||
|
"path": name,
|
||||||
|
"sha1": sha1,
|
||||||
|
"md5": md5,
|
||||||
|
"size": size,
|
||||||
|
"status": status,
|
||||||
|
"name": file_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Corruption check: SHA1 in DB but doesn't match what we computed
|
||||||
|
# This should never happen (we looked up by SHA1), but catches
|
||||||
|
# edge cases where by_md5 resolved to a different SHA1
|
||||||
|
if db_entry and status == "verified_md5":
|
||||||
|
expected_sha1 = db_entry.get("sha1", "")
|
||||||
|
if expected_sha1 and expected_sha1.lower() != sha1.lower():
|
||||||
|
errors.append(
|
||||||
|
f"{name}: SHA1 mismatch (expected {expected_sha1}, got {sha1})"
|
||||||
|
)
|
||||||
|
|
||||||
|
verified = sum(1 for f in manifest["files"] if f["status"].startswith("verified"))
|
||||||
|
untracked = sum(1 for f in manifest["files"] if f["status"] == "untracked")
|
||||||
|
total = len(manifest["files"])
|
||||||
|
manifest["summary"] = {
|
||||||
|
"total_files": total,
|
||||||
|
"verified": verified,
|
||||||
|
"untracked": untracked,
|
||||||
|
"errors": len(errors),
|
||||||
|
}
|
||||||
|
manifest["errors"] = errors
|
||||||
|
|
||||||
|
all_ok = len(errors) == 0
|
||||||
|
return all_ok, manifest
|
||||||
|
|
||||||
|
def generate_sha256sums(output_dir: str) -> str | None:
|
||||||
|
"""Generate SHA256SUMS.txt for all ZIP files in output_dir."""
|
||||||
|
sums_path = os.path.join(output_dir, "SHA256SUMS.txt")
|
||||||
|
entries = []
|
||||||
|
for name in sorted(os.listdir(output_dir)):
|
||||||
|
if not name.endswith(".zip"):
|
||||||
|
continue
|
||||||
|
path = os.path.join(output_dir, name)
|
||||||
|
sha256 = hashlib.sha256()
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
sha256.update(chunk)
|
||||||
|
entries.append(f"{sha256.hexdigest()} {name}")
|
||||||
|
if not entries:
|
||||||
|
return None
|
||||||
|
with open(sums_path, "w") as f:
|
||||||
|
f.write("\n".join(entries) + "\n")
|
||||||
|
print(f"\n{sums_path}: {len(entries)} pack checksums")
|
||||||
|
return sums_path
|
||||||
|
|
||||||
|
def _hash_matches(declared: str, actual: str) -> bool:
|
||||||
|
"""Compare a declared hash value against an actual hex digest.
|
||||||
|
|
||||||
|
Handles comma-separated multi-hash lists and uppercase (Recalbox)
|
||||||
|
and truncated MD5s (Batocera 29-char): a declared value shorter
|
||||||
|
than the digest matches by prefix.
|
||||||
|
"""
|
||||||
|
actual = actual.lower()
|
||||||
|
for cand in declared.split(","):
|
||||||
|
cand = cand.strip().lower()
|
||||||
|
if not cand:
|
||||||
|
continue
|
||||||
|
if len(cand) < len(actual):
|
||||||
|
if actual.startswith(cand):
|
||||||
|
return True
|
||||||
|
elif actual == cand:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_member_hash(
|
||||||
|
zf: zipfile.ZipFile, member: str, file_entry: dict, mode: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Verify a pack member against its platform-declared hash.
|
||||||
|
|
||||||
|
Reproduces the platform's native check inside the pack: md5/sha1 of
|
||||||
|
the member bytes, or checkInsideZip when zipped_file is set
|
||||||
|
(Batocera hashes a ROM inside the ZIP, matched case-insensitively).
|
||||||
|
Returns an error string, or None when the member passes.
|
||||||
|
"""
|
||||||
|
declared = str(file_entry.get(mode) or "").strip()
|
||||||
|
if not declared:
|
||||||
|
return None
|
||||||
|
|
||||||
|
zipped_file = file_entry.get("zipped_file")
|
||||||
|
if zipped_file:
|
||||||
|
import io
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zf.read(member))) as inner:
|
||||||
|
want = zipped_file.casefold()
|
||||||
|
target = next(
|
||||||
|
(n for n in inner.namelist() if n.casefold() == want), None
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
return f"{member}: {zipped_file} not found inside ZIP"
|
||||||
|
actual = hashlib.md5(inner.read(target)).hexdigest()
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
return f"{member}: not a valid ZIP"
|
||||||
|
if not _hash_matches(declared, actual):
|
||||||
|
return (
|
||||||
|
f"{member}: {zipped_file} inside-zip md5 {actual} "
|
||||||
|
f"!= declared {declared}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
h = hashlib.md5() if mode == "md5" else hashlib.sha1()
|
||||||
|
with zf.open(member) as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
actual = h.hexdigest()
|
||||||
|
if _hash_matches(declared, actual):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Recalbox Md5Composite: MD5 over sorted inner contents of a ZIP,
|
||||||
|
# independent of compression and metadata (Zip::Md5Composite()).
|
||||||
|
if mode == "md5" and member.endswith(".zip"):
|
||||||
|
import io
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(zf.read(member))) as inner:
|
||||||
|
names = sorted(n for n in inner.namelist() if not n.endswith("/"))
|
||||||
|
ch = hashlib.md5()
|
||||||
|
for n in names:
|
||||||
|
ch.update(inner.read(n))
|
||||||
|
if _hash_matches(declared, ch.hexdigest()):
|
||||||
|
return None
|
||||||
|
except (zipfile.BadZipFile, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return f"{member}: {mode} {actual} != declared {declared}"
|
||||||
|
|
||||||
|
def _repo_satisfies_declaration(
|
||||||
|
entries: list[dict], db: dict, mode: str
|
||||||
|
) -> bool:
|
||||||
|
"""Check whether any repo file matches one of the declared hashes.
|
||||||
|
|
||||||
|
Used to separate pack divergence (a matching file exists but was not
|
||||||
|
packed) from data coverage gaps (no repo file matches the upstream
|
||||||
|
declaration): only the former is a pack generation error.
|
||||||
|
"""
|
||||||
|
from common import md5_composite
|
||||||
|
|
||||||
|
files_db = db.get("files", {})
|
||||||
|
by_md5 = db.get("indexes", {}).get("by_md5", {})
|
||||||
|
by_name = db.get("indexes", {}).get("by_name", {})
|
||||||
|
|
||||||
|
for fe in entries:
|
||||||
|
declared = str(fe.get(mode) or "").strip()
|
||||||
|
if not declared:
|
||||||
|
continue
|
||||||
|
hashes = [h.strip().lower() for h in declared.split(",") if h.strip()]
|
||||||
|
for h in hashes:
|
||||||
|
if mode == "sha1":
|
||||||
|
entry = files_db.get(h)
|
||||||
|
else:
|
||||||
|
entry = files_db.get(by_md5.get(h, ""))
|
||||||
|
if entry and os.path.exists(entry.get("path", "")):
|
||||||
|
return True
|
||||||
|
if mode == "md5":
|
||||||
|
hash_set = set(hashes)
|
||||||
|
for cand_sha in by_name.get(fe.get("name", ""), []):
|
||||||
|
entry = files_db.get(cand_sha)
|
||||||
|
if not entry:
|
||||||
|
continue
|
||||||
|
path = entry.get("path", "")
|
||||||
|
if not path.endswith(".zip") or not os.path.exists(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if md5_composite(path).lower() in hash_set:
|
||||||
|
return True
|
||||||
|
except (zipfile.BadZipFile, OSError):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _intentional_hash_exclusion(
|
||||||
|
entries: list[dict],
|
||||||
|
db: dict,
|
||||||
|
bios_dir: str = DEFAULT_BIOS_DIR,
|
||||||
|
zip_contents: dict | None = None,
|
||||||
|
data_dir_registry: dict | None = None,
|
||||||
|
verification_mode: str = "md5",
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether the builder must omit every declaration as unsafe.
|
||||||
|
|
||||||
|
A hash platform never substitutes a same-named payload for an explicitly
|
||||||
|
hash-identified one, so a missing pack member is accounted for when every
|
||||||
|
declaration for that destination resolves to a local hash mismatch. An
|
||||||
|
existence platform reads no bytes and the builder ships the file anyway,
|
||||||
|
so nothing is ever excluded on its behalf. Genuine absence,
|
||||||
|
external-download failure and a packable alternative stay conformance
|
||||||
|
errors in both modes.
|
||||||
|
"""
|
||||||
|
if not entries or not hash_mismatch_excludes_file(verification_mode):
|
||||||
|
return False
|
||||||
|
archive_index = zip_contents if zip_contents is not None else {}
|
||||||
|
for entry in entries:
|
||||||
|
local_path, status = resolve_file(
|
||||||
|
entry,
|
||||||
|
db,
|
||||||
|
bios_dir,
|
||||||
|
archive_index,
|
||||||
|
data_dir_registry=data_dir_registry,
|
||||||
|
offline=True,
|
||||||
|
)
|
||||||
|
if status != "hash_mismatch":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# A container can mismatch the outer declaration while still carrying
|
||||||
|
# the exact inner ROM requested by Batocera-style zipped_file entries.
|
||||||
|
zipped_file = entry.get("zipped_file")
|
||||||
|
if zipped_file and local_path:
|
||||||
|
declared = str(entry.get("md5") or "")
|
||||||
|
candidates = [value.strip() for value in declared.split(",") if value.strip()]
|
||||||
|
if not candidates:
|
||||||
|
candidates = [""]
|
||||||
|
if any(
|
||||||
|
check_inside_zip(local_path, zipped_file, candidate) == "ok"
|
||||||
|
for candidate in candidates
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def verify_pack_against_platform(
|
||||||
|
zip_path: str,
|
||||||
|
platform_name: str,
|
||||||
|
platforms_dir: str,
|
||||||
|
db: dict | None = None,
|
||||||
|
emulators_dir: str = "emulators",
|
||||||
|
emu_profiles: dict | None = None,
|
||||||
|
regions: list[str] | None = None,
|
||||||
|
data_registry: dict | None = None,
|
||||||
|
) -> 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.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. Every baseline file declared by the platform exists in the ZIP
|
||||||
|
at the correct destination path
|
||||||
|
2. Every in-repo core extra file (from emulator profiles) is present
|
||||||
|
3. No duplicate entries
|
||||||
|
4. No path anomalies (double slash, absolute, traversal)
|
||||||
|
5. No unexpected zero-byte BIOS files
|
||||||
|
|
||||||
|
Returns ``(all_ok, checked, present, errors, baseline_checked,
|
||||||
|
baseline_present, core_checked, core_present, baseline_excluded)``.
|
||||||
|
"""
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
config = load_platform_config(platform_name, platforms_dir)
|
||||||
|
base_dest = config.get("base_destination", "")
|
||||||
|
errors: list[str] = []
|
||||||
|
checked = 0
|
||||||
|
present = 0
|
||||||
|
|
||||||
|
if emu_profiles is None:
|
||||||
|
emu_profiles = load_emulator_profiles(emulators_dir)
|
||||||
|
|
||||||
|
region_drops: set[str] = set()
|
||||||
|
if regions:
|
||||||
|
region_index = region_mod.build_region_index(emu_profiles)
|
||||||
|
region_groups, _extra_dests = platform_region_groups(
|
||||||
|
config,
|
||||||
|
config.get("systems", {}),
|
||||||
|
emulators_dir,
|
||||||
|
db,
|
||||||
|
base_dest,
|
||||||
|
emu_profiles,
|
||||||
|
)
|
||||||
|
region_drops = region_mod.resolve_region_drops(
|
||||||
|
region_groups, region_index, regions
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||||
|
zip_set = set(zf.namelist())
|
||||||
|
zip_lower = {n.lower(): n for n in zip_set}
|
||||||
|
|
||||||
|
# Auto-detect flat vs nested ZIP
|
||||||
|
is_flat = bool(base_dest) and not any(
|
||||||
|
n.startswith(base_dest + "/")
|
||||||
|
for n in zip_set
|
||||||
|
if n not in ("README.txt", "manifest.json") and not n.endswith("/")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Structural checks
|
||||||
|
dupes = sum(1 for c in Counter(zf.namelist()).values() if c > 1)
|
||||||
|
if dupes:
|
||||||
|
errors.append(f"{dupes} duplicate entries")
|
||||||
|
for n in zip_set:
|
||||||
|
if "//" in n:
|
||||||
|
errors.append(f"double slash: {n}")
|
||||||
|
if n.startswith("/"):
|
||||||
|
errors.append(f"absolute path: {n}")
|
||||||
|
if ".." in n:
|
||||||
|
errors.append(f"path traversal: {n}")
|
||||||
|
|
||||||
|
# Zero-byte check (exclude Dolphin GraphicMods markers)
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.file_size == 0 and not info.is_dir():
|
||||||
|
if "GraphicMods" not in info.filename and info.filename not in (
|
||||||
|
"manifest.json",
|
||||||
|
"README.txt",
|
||||||
|
):
|
||||||
|
errors.append(f"zero-byte: {info.filename}")
|
||||||
|
|
||||||
|
# 1. Baseline file presence + native hash check
|
||||||
|
verification_mode = config.get("verification_mode", "existence")
|
||||||
|
baseline_checked = 0
|
||||||
|
baseline_present = 0
|
||||||
|
baseline_excluded = 0
|
||||||
|
decl_by_member: dict[str, list[dict]] = {}
|
||||||
|
# Mirror the builder's path-conflict logic: a declaration whose path
|
||||||
|
# collides file-vs-directory with a packed member was skipped by the
|
||||||
|
# builder (upstream declares e.g. both SGB1.sfc and SGB1.sfc/program.rom)
|
||||||
|
zip_parents: set[str] = set()
|
||||||
|
for n in zip_set:
|
||||||
|
parts = n.split("/")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
zip_parents.add("/".join(parts[:i]))
|
||||||
|
baseline_groups: dict[str, list[dict]] = {}
|
||||||
|
for _sys_id, system in config.get("systems", {}).items():
|
||||||
|
for fe in system.get("files", []):
|
||||||
|
dest = sanitize_pack_path(fe.get("destination", fe.get("name", "")))
|
||||||
|
if not dest:
|
||||||
|
continue
|
||||||
|
if region_drops and dest in region_drops:
|
||||||
|
continue
|
||||||
|
expected = f"{base_dest}/{dest}" if base_dest and not is_flat else dest
|
||||||
|
baseline_groups.setdefault(expected, []).append(fe)
|
||||||
|
|
||||||
|
baseline_checked = len(baseline_groups)
|
||||||
|
exclusion_index = build_zip_contents_index(db) if db is not None else {}
|
||||||
|
for expected, declarations in baseline_groups.items():
|
||||||
|
if expected in zip_set:
|
||||||
|
member = expected
|
||||||
|
elif expected.lower() in zip_lower:
|
||||||
|
member = zip_lower[expected.lower()]
|
||||||
|
elif _has_path_conflict(expected, zip_set, zip_parents):
|
||||||
|
# Skipped by the builder for the same reason: not an error
|
||||||
|
baseline_present += 1
|
||||||
|
continue
|
||||||
|
elif db is not None and _intentional_hash_exclusion(
|
||||||
|
declarations,
|
||||||
|
db,
|
||||||
|
zip_contents=exclusion_index,
|
||||||
|
data_dir_registry=data_registry,
|
||||||
|
verification_mode=verification_mode,
|
||||||
|
):
|
||||||
|
baseline_excluded += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
errors.append(f"baseline missing: {expected}")
|
||||||
|
continue
|
||||||
|
baseline_present += 1
|
||||||
|
decl_by_member.setdefault(member, []).extend(declarations)
|
||||||
|
|
||||||
|
# Reproduce the platform's native hash check on pack bytes.
|
||||||
|
# A destination declared by several entries passes when the packed
|
||||||
|
# member satisfies any of them. A failure only counts as a pack
|
||||||
|
# error when the repo holds a file matching a declaration: without
|
||||||
|
# one, the pack ships its best effort and the gap is a data issue
|
||||||
|
# reported by verify.py, not a generation bug.
|
||||||
|
digest = digest_algorithm(verification_mode)
|
||||||
|
if reads_file_contents(verification_mode) and digest:
|
||||||
|
for member, decl_entries in decl_by_member.items():
|
||||||
|
checkable = [
|
||||||
|
fe
|
||||||
|
for fe in decl_entries
|
||||||
|
if str(fe.get(digest) or "").strip()
|
||||||
|
]
|
||||||
|
if not checkable:
|
||||||
|
continue
|
||||||
|
member_errors = []
|
||||||
|
satisfied = False
|
||||||
|
for fe in checkable:
|
||||||
|
err = _check_member_hash(zf, member, fe, digest)
|
||||||
|
if err is None:
|
||||||
|
satisfied = True
|
||||||
|
break
|
||||||
|
member_errors.append(err)
|
||||||
|
if satisfied:
|
||||||
|
continue
|
||||||
|
if db is None or _repo_satisfies_declaration(
|
||||||
|
checkable, db, verification_mode
|
||||||
|
):
|
||||||
|
errors.append(member_errors[0])
|
||||||
|
|
||||||
|
# 2. Core extras presence (files from emulator profiles, in repo)
|
||||||
|
# Mirror the pack builder's skip logic: only count files that
|
||||||
|
# can actually be resolved and don't have path conflicts.
|
||||||
|
core_checked = 0
|
||||||
|
core_present = 0
|
||||||
|
if db is not None:
|
||||||
|
core_files = _collect_emulator_extras(
|
||||||
|
config,
|
||||||
|
emulators_dir,
|
||||||
|
db,
|
||||||
|
set(),
|
||||||
|
base_dest,
|
||||||
|
emu_profiles,
|
||||||
|
)
|
||||||
|
seen_conformance: set[str] = set(zip_set)
|
||||||
|
seen_parents: set[str] = set()
|
||||||
|
for n in zip_set:
|
||||||
|
parts = n.split("/")
|
||||||
|
for i in range(1, len(parts)):
|
||||||
|
seen_parents.add("/".join(parts[:i]))
|
||||||
|
extras_pfx = _detect_extras_prefix(config, base_dest)
|
||||||
|
for fe in core_files:
|
||||||
|
raw_dest = fe.get("destination", fe.get("name", ""))
|
||||||
|
dest = sanitize_pack_path(raw_dest)
|
||||||
|
if not dest:
|
||||||
|
continue
|
||||||
|
if region_drops and sanitize_pack_path(dest) in region_drops:
|
||||||
|
continue
|
||||||
|
if extras_pfx and not (is_flat and extras_pfx == base_dest):
|
||||||
|
if not dest.startswith(f"{extras_pfx}/"):
|
||||||
|
full = f"{extras_pfx}/{dest}"
|
||||||
|
else:
|
||||||
|
full = dest
|
||||||
|
else:
|
||||||
|
full = dest
|
||||||
|
# Skip path conflicts (same logic as pack builder)
|
||||||
|
if _has_path_conflict(full, seen_conformance, seen_parents):
|
||||||
|
continue
|
||||||
|
# Skip unresolvable files (game_data dirs, etc.)
|
||||||
|
local_path, status = resolve_file(
|
||||||
|
fe,
|
||||||
|
db,
|
||||||
|
"bios",
|
||||||
|
{},
|
||||||
|
dest_hint=raw_dest,
|
||||||
|
data_dir_registry=data_registry,
|
||||||
|
offline=True,
|
||||||
|
)
|
||||||
|
if status in ("not_found", "external", "user_provided"):
|
||||||
|
continue
|
||||||
|
core_checked += 1
|
||||||
|
if full in zip_set or full.lower() in zip_lower:
|
||||||
|
core_present += 1
|
||||||
|
seen_conformance.add(full)
|
||||||
|
_register_path(full, seen_conformance, seen_parents)
|
||||||
|
else:
|
||||||
|
errors.append(f"core missing: {full}")
|
||||||
|
|
||||||
|
checked = baseline_checked + core_checked
|
||||||
|
present = baseline_present + core_present
|
||||||
|
|
||||||
|
return (
|
||||||
|
len(errors) == 0,
|
||||||
|
checked,
|
||||||
|
present,
|
||||||
|
errors,
|
||||||
|
baseline_checked,
|
||||||
|
baseline_present,
|
||||||
|
core_checked,
|
||||||
|
core_present,
|
||||||
|
baseline_excluded,
|
||||||
|
)
|
||||||
Reference in new issue
Block a user