fix: pick the archive holding a core's members

This commit is contained in:
Abdessamad Derraz committed 2026-10-16 19:22:45 +02:00
1 parent a491d9728a
commit 1699caaca2
16 files changed
+461 -270

No files matched your search

+45
View File
@@ -599,6 +599,48 @@ def _rank_tail_candidates(
)
def archive_members(archive: str, files: list[dict]) -> list[dict]:
"""The members a profile reads inside *archive*, with what it requires."""
return [
{"crc32": str(f["crc32"]).lower(), "required": bool(f.get("required", True))}
for f in files
if f.get("archive") == archive and f.get("crc32")
]
def archive_entry(archive: str, files: list[dict]) -> dict:
"""The entry a profile's archive resolves through.
Its own declaration when it has one (it carries the container's hashes),
and the members it reads inside, so a copy of the name that lacks a ROM
the core requires is not taken for the archive.
"""
own = next(
(f for f in files if f.get("name") == archive and not f.get("archive")),
{"name": archive},
)
members = archive_members(archive, files)
return {**own, "members": members} if members else own
def _holding_members(paths: list[str], members: list[dict]) -> list[str]:
"""The same-named archives holding the most required members, then the most members.
FBNeo reads neocd.bin and 000-lo.lo inside neocdz.zip; the primary copy,
a set without 000-lo.lo, won on the name while a variant held both.
"""
def held(path: str) -> tuple[int, int]:
crcs = zip_member_crcs(path)
return (
sum(1 for m in members if m["required"] and m["crc32"] in crcs),
sum(1 for m in members if m["crc32"] in crcs),
)
scores = {path: held(path) for path in paths}
best = max(scores.values(), default=(0, 0))
return [path for path in paths if scores[path] == best]
def _by_affinity(paths: list[str], file_entry: dict, dest_hint: str) -> list[str]:
"""Same-named candidates, the likeliest first.
@@ -910,6 +952,8 @@ def resolve_local_file(
if candidates:
if zipped_file:
candidates = [p for p in candidates if ".zip" in os.path.basename(p)]
if file_entry.get("members"):
candidates = _holding_members(candidates, file_entry["members"])
candidates = _by_affinity(candidates, file_entry, dest_hint)
primary = [p for p in candidates if "/.variants/" not in p]
if primary or candidates:
@@ -2010,6 +2054,7 @@ from ziptools import ( # noqa: E402,F401
safe_extract_zip,
_zip_contents_cache,
_BOUNDED_RATIO_METHODS,
zip_member_crcs,
)
from artifacts import ( # noqa: E402,F401
write_if_changed,
+5 -13
View File
@@ -31,6 +31,7 @@ sys.path.insert(0, os.path.dirname(__file__))
from common import resolve_platform_cores as _platform_cores
from common import (
apply_target_overrides,
archive_entry,
profiles_for_systems,
write_text_atomic,
artifact_lock,
@@ -1299,20 +1300,11 @@ def generate_emulator_pack(
if _has_path_conflict(archive_dest, seen_destinations, seen_parents):
continue
# Prefer the profile's own entry for the archive (carries
# hashes for exact resolution when several dumps share a name)
archive_entry = next(
(
f
for f in files
if f.get("name") == archive_name and not f.get("archive")
),
{"name": archive_name},
)
if required_only and _archive_is_optional(archive_entry, archive_name, files):
own_entry = archive_entry(archive_name, files)
if required_only and _archive_is_optional(own_entry, archive_name, files):
continue
local_path, status = resolve_file(
archive_entry,
own_entry,
db,
bios_dir,
zip_contents,
@@ -1321,7 +1313,7 @@ def generate_emulator_pack(
)
if local_path and status not in ("not_found",):
if local_path.endswith(".zip"):
_add_zip_to_pack(local_path, archive_dest, zf, archive_entry)
_add_zip_to_pack(local_path, archive_dest, zf, own_entry)
else:
_add_pack_member(zf, local_path, archive_dest)
seen_destinations.add(archive_dest)
+6
View File
@@ -7,6 +7,7 @@ this module builds so the report and the pack withdraw the same files."""
from __future__ import annotations
from common import PROFILE_IDENTITY_FIELDS
from common import archive_members
from common import declared_root, expand_platform_declared_names, platform_declarations
from common import load_emulator_profiles
from common import resolution_is_hash_exact
@@ -497,6 +498,9 @@ def _archive_prefix_extras(
"region": archive_entry.get("region"),
"variant_group": archive_entry.get("variant_group"),
}
members = archive_members(archive_name, profile.get("files", []))
if members:
extra["members"] = members
extra["claim_order"] = (3, 0, emu_name)
if _take(extra, full_dest, seen_dests, taken):
extras.append(extra)
@@ -774,6 +778,8 @@ def _report_extra(u: dict, name: str, dest: str) -> dict:
"region": u.get("region"),
"variant_group": u.get("variant_group"),
}
if u.get("members"):
extra["members"] = u["members"]
# 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
+4 -11
View File
@@ -35,6 +35,8 @@ import slots
sys.path.insert(0, os.path.dirname(__file__))
from common import (
archive_entry,
archive_members,
profiles_for_systems,
PROFILE_IDENTITY_FIELDS,
list_available_targets,
@@ -577,6 +579,7 @@ def find_undeclared_files(
"expected": {},
"archive_file_count": 0,
"archive_required_count": 0,
"members": archive_members(archive, profile.get("files", [])),
}
entry = archive_entries[archive_key]
entry["archive_file_count"] += 1
@@ -1523,18 +1526,8 @@ def verify_emulator(
archive = file_entry.get("archive")
if archive and archive not in seen_archives:
seen_archives.add(archive)
# Prefer the profile's own entry for the archive (carries
# hashes for exact resolution when several dumps share a name)
archive_entry = next(
(
f
for f in files
if f.get("name") == archive and not f.get("archive")
),
{"name": archive},
)
local_path, resolve_status = resolve_local_file(
archive_entry,
archive_entry(archive, files),
db,
zip_contents,
data_dir_registry=data_registry,
+21
View File
@@ -5,6 +5,7 @@ capping a real romset: the largest pack is already several gigabytes."""
from __future__ import annotations
import functools
import hashlib
import os
import re
@@ -36,6 +37,26 @@ def check_inside_zip(container: str, file_name: str, expected_md5: str) -> str:
except (zipfile.BadZipFile, OSError, KeyError):
return "error"
@functools.lru_cache(maxsize=4096)
def _member_crcs(path: str, mtime: float) -> frozenset[str]:
try:
with zipfile.ZipFile(path) as archive:
return frozenset(
format(info.CRC, "08x") for info in archive.infolist() if not info.is_dir()
)
except (zipfile.BadZipFile, OSError):
return frozenset()
def zip_member_crcs(path: str) -> frozenset[str]:
"""CRC-32 of each member, read from the central directory."""
try:
mtime = os.path.getmtime(path)
except OSError:
return frozenset()
return _member_crcs(path, mtime)
_zip_contents_cache: tuple[frozenset[tuple[str, float]], dict] | None = None
def build_zip_contents_index(db: dict, max_entry_size: int = 512 * 1024 * 1024) -> dict: