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()