From fb639647e6f6521c1dc637f97269b620182f2f4f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:05:54 +0200 Subject: [PATCH] fix: resolve a loose archive entry by its members gngeopi and neo-emu declare neogeo.zip loose and, apart, the ROMs they open inside it. The archive pass took the copy holding them, the loose entry resolved by name to another copy, and both verify --emulator and the emulator pack reported the archive missing while the ZIP held it. with_archive_members() gives the loose entry the members in verify, the pack, slots and the gap report. --- scripts/generate_pack.py | 3 +- scripts/slots.py | 7 +++-- scripts/validation.py | 15 +++++++++ scripts/verify.py | 6 +++- tests/test_archive_choice.py | 60 ++++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 56975f37..fff9b337 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -94,6 +94,7 @@ from validation import ( _build_validation_index, deliverable, filter_files_by_mode, + with_archive_members, ) yaml = require_yaml() @@ -1572,7 +1573,7 @@ def generate_emulator_pack( refused_path: str | None = None for fe in alternatives: local_path, status = resolve_file( - {**fe, "source_profile": emu_name}, + {**with_archive_members(fe, files), "source_profile": emu_name}, db, bios_dir, zip_contents, diff --git a/scripts/slots.py b/scripts/slots.py index 1e91c6b8..68a464fd 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -37,7 +37,7 @@ from common import ( variant_base_name, ) from packresolve import preferred_entries -from validation import outside_gap_scope +from validation import outside_gap_scope, with_archive_members if TYPE_CHECKING: from packextras import ExtraPlacement @@ -241,7 +241,10 @@ def profile_claims( full = placed[1] # The owner's own copy is the one the builder ships. local, status = resolve_local_file( - {**entry, "source_profile": emu_name}, + { + **with_archive_members(entry, profile.get("files") or []), + "source_profile": emu_name, + }, db, zip_contents, dest_hint=dest, diff --git a/scripts/validation.py b/scripts/validation.py index e009c4fc..8aa7e54b 100644 --- a/scripts/validation.py +++ b/scripts/validation.py @@ -13,6 +13,7 @@ from collections.abc import Callable, Iterator, Mapping from common import ( adler32_of, + archive_members, compute_hashes, name_match_size_ok, sanitize_pack_path, @@ -512,6 +513,20 @@ def declaration_settles( return False +def with_archive_members(entry: dict, files: list[dict]) -> dict: + """A loose entry resolved as the archive its profile reads members from. + + gngeopi declares neogeo.zip and, apart, the ROMs it opens inside it. The + members decide which same-named copy is the archive: resolved by name + alone, the loose entry took another copy than the archive pass and the + profile blocked its own archive. + """ + if entry.get("archive"): + return entry + members = archive_members(entry.get("name", ""), files) + return {**entry, "members": members} if members else entry + + def opens_by_name(file_entry: dict, profile: dict, standalone: bool = False) -> bool: """Whether the code opens this entry by its name. diff --git a/scripts/verify.py b/scripts/verify.py index dfae360a..a80b6f70 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -100,6 +100,7 @@ from validation import ( installed_with_emulator, unread_on, outside_gap_scope, + with_archive_members, ) DEFAULT_DB = "database.json" @@ -683,6 +684,9 @@ def find_undeclared_files( } if f.get("contents"): entry["contents"] = f["contents"] + members = with_archive_members(f, profile.get("files", [])).get("members") + if members: + entry["members"] = members if launcher_layer: launcher_candidates.append((emu_name, is_standalone, f, entry)) if duplicate: @@ -1887,7 +1891,7 @@ def verify_emulator( dest_hint = file_entry.get("path", "") local_path, resolve_status = resolve_local_file( - {**file_entry, "source_profile": emu_name}, + {**with_archive_members(file_entry, files), "source_profile": emu_name}, db, zip_contents, dest_hint=dest_hint, diff --git a/tests/test_archive_choice.py b/tests/test_archive_choice.py index d961a48b..046742d3 100644 --- a/tests/test_archive_choice.py +++ b/tests/test_archive_choice.py @@ -349,5 +349,65 @@ class AnArchiveRankedByWhatItHolds(unittest.TestCase): self.assertIn('entry["contents"] = f["contents"]', report) +class ALooseEntryNamingItsArchive(unittest.TestCase): + """gngeopi declares neogeo.zip loose and, apart, the ROMs it opens inside. + The archive pass took the copy holding them, the loose entry resolved by + name alone to another copy, and the profile blocked its own archive: + verify said missing, the pack said missing, and the ZIP held it.""" + + def test_verify_and_the_pack_ship_the_archive_once(self): + import io + from contextlib import redirect_stdout + + import yaml + + from common import _emulator_profiles_cache + from generate_pack import generate_emulator_pack + from verify import verify_emulator + + _emulator_profiles_cache.clear() + (REPO_ROOT / "tmp").mkdir(exist_ok=True) + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + previous = os.getcwd() + os.chdir(root) + self.addCleanup(os.chdir, previous) + roms = {"bios.rom": b"B" * 32, "lo.lo": b"L" * 32, "rev.rom": b"R" * 32} + files, by_name = {}, {} + # Two revisions, neither holding the other: index order picks A. + for path, members in (("bios/A/set.zip", ["bios.rom", "rev.rom"]), + ("bios/B/set.zip", ["bios.rom", "lo.lo"])): + Path(path).parent.mkdir(parents=True) + with zipfile.ZipFile(path, "w") as zf: + for member in members: + zf.writestr(member, roms[member]) + sha1 = hashlib.sha1(Path(path).read_bytes()).hexdigest() + files[sha1] = {"path": path, "name": "set.zip", "sha1": sha1, "size": 1} + by_name.setdefault("set.zip", []).append(sha1) + db = {"files": files, "indexes": {"by_name": by_name, "by_md5": {}, + "by_path_suffix": {}, "by_crc32": {}}} + Path("emulators").mkdir() + Path("emulators/neo.yml").write_text(yaml.safe_dump({ + "emulator": "Neo", "type": "libretro", "systems": ["snk-neogeo-mvs"], + "files": [{"name": "set.zip", "required": True, "category": "bios_zip"}] + [ + {"name": name, "archive": "set.zip", "required": True, + "crc32": format(zlib.crc32(data), "08x")} + for name, data in roms.items() if name != "rev.rom" + ], + })) + report = verify_emulator(["neo"], "emulators", db, platforms_dir="platforms") + with redirect_stdout(io.StringIO()) as out: + zip_path = generate_emulator_pack( + ["neo"], "emulators", db, "bios", "out", offline=True, + platforms_dir="platforms", + ) + with zipfile.ZipFile(zip_path) as zf, zf.open("set.zip") as inner: + held = zipfile.ZipFile(io.BytesIO(inner.read())).namelist() + self.assertEqual(sorted(held), ["bios.rom", "lo.lo"]) + self.assertNotIn("missing", out.getvalue()) + self.assertEqual({d["status"] for d in report["details"]}, {"ok"}) + self.assertEqual(report["total_files"], 1) + + if __name__ == "__main__": unittest.main()