diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 06340b6b..501bd742 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -1224,6 +1224,14 @@ def _pack_data_tree( return added +def _same_bytes(path: str, other: str | None) -> bool: + if not other: + return False + if os.path.realpath(path) == os.path.realpath(other): + return True + return compute_hashes(path)["sha1"] == compute_hashes(other)["sha1"] + + def generate_emulator_pack( profile_names: list[str], emulators_dir: str, @@ -1279,6 +1287,9 @@ def generate_emulator_pack( set() ) # parent dirs of added files (path conflict detection) seen_hashes: set[str] = set() # SHA1 dedup for same file, different path + # Loose file packed at each folded destination, to tell a profile asking + # for the same file there from one asking for other bytes. + packed_at: dict[str, str] = {} data_dir_notices: list[str] = [] data_registry = load_data_dir_registry( platforms_dir or os.path.join(os.path.dirname(__file__), "..", "platforms") @@ -1390,15 +1401,18 @@ def generate_emulator_pack( slots.setdefault(dest, []).append(fe) for dest, alternatives in slots.items(): - if dest.lower() in seen_lower: - continue - if _has_path_conflict(dest, seen_destinations, seen_parents): - continue + # Name order, first file wins, case folded: verify replays + # the rule and reports a later profile's file as missing. + block = placement_block( + dest, seen_destinations, seen_lower, seen_parents, True + ) provided = next( (fe for fe in alternatives if fe.get("storage") == "user_provided"), None, ) + if provided is not None and block: + continue if provided is not None: # Instructions, not a file: written beside the slot the # user fills, and counted apart from what the pack holds. @@ -1421,6 +1435,7 @@ def generate_emulator_pack( chosen: tuple[dict, str, str | None] | None = None fallback: tuple[dict, str, str | None] | None = None refusal: str | None = None + refused_path: str | None = None for fe in alternatives: local_path, status = resolve_file( {**fe, "source_profile": emu_name}, @@ -1446,6 +1461,7 @@ def generate_emulator_pack( if disagreement: reason = disagreement.split(" says ", 1)[-1] refusal = refusal or f"{fe['name']} -{reason}" + refused_path = refused_path or local_path continue if better != local_path: chosen = (fe, better, None) @@ -1459,6 +1475,20 @@ def generate_emulator_pack( break chosen = chosen or fallback + if block: + held = ( + chosen[1] if chosen is not None and chosen[2] != "external" + else None if chosen is not None else refused_path + ) + if chosen is not None or held is not None: + if block == "taken" and held and _same_bytes( + held, packed_at.get(dest.lower()) + ): + # The file already at this path, asked for twice. + continue + missing_files.append(alternatives[0]["name"]) + continue + if chosen is None: if refusal: rejected_files.append(refusal) @@ -1508,6 +1538,7 @@ def generate_emulator_pack( seen_destinations.add(dest) _register_path(dest, seen_destinations, seen_parents) seen_lower.add(dest.lower()) + packed_at[dest.lower()] = local_path total_files += 1 # Remove empty ZIP (no files packed and no missing = nothing to ship) diff --git a/scripts/verify.py b/scripts/verify.py index 9768a032..f8d35ea9 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1463,7 +1463,12 @@ def verify_emulator( A region priority list narrows the report the same way a pack built with the same list would be narrowed, through the emulator pack's own drop set. """ - from packpaths import _archive_destination, _resolve_destination + from packpaths import ( + _archive_destination, + _register_path, + _resolve_destination, + placement_block, + ) load_emulator_profiles(emulators_dir) zip_contents = build_zip_contents_index(db) @@ -1491,7 +1496,16 @@ def verify_emulator( region_drops = emulator_region_drops(selected, standalone, regions) - for emu_name, profile in selected: + # The emulator pack walks profiles in name order and keeps the first + # file at a path, folding case: a later profile's different file there + # (zesarux pentagon.rom after speccy's PENTAGON.ROM) is not in the pack. + sha1_by_path = { + entry.get("path"): sha1 for sha1, entry in db.get("files", {}).items() + } + placed: dict[str, tuple[str | None, str, str]] = {} + placed_files: set[str] = set() + placed_parents: set[str] = set() + for emu_name, profile in sorted(selected): files = expand_directory_entries( filter_files_by_mode(profile.get("files", []), standalone), db, @@ -1701,7 +1715,48 @@ def verify_emulator( held = [a for a in alternatives if a[0]["status"] == Status.OK] if held and len(alternatives) > 1: alternatives = held[:1] + # What the emulator pack places here: an accepted file, else a + # file whose hash the profile contradicts. A refused copy is held + # but never packed. + fallback_reason = "declared hash contradicted by the local file" + packed = next( + (a[0].get("path") for a in alternatives + if a[0]["status"] == Status.OK and a[0].get("path")), + None, + ) or next( + (a[0].get("path") for a in alternatives + if a[0]["status"] == Status.UNTESTED + and a[0].get("reason") == fallback_reason and a[0].get("path")), + None, + ) + held = packed or next( + (a[0].get("path") for a in alternatives + if a[0]["status"] != Status.MISSING and a[0].get("path")), + None, + ) + block = placement_block( + dest, placed_files, set(placed), placed_parents, True + ) + block_reason = "" + if block and held is not None: + other_sha1, owner, other_dest = placed.get(dest.lower(), (None, "", "")) + if block == "path_conflict": + block_reason = "a packed file occupies its directory" + elif block == "case_twin": + block_reason = ( + f"packed only as {other_dest}, from {owner}" + if sha1_by_path.get(held) == other_sha1 + else f"the pack holds {owner}'s {other_dest} at this path" + ) + elif sha1_by_path.get(held) != other_sha1: + block_reason = f"the pack holds {owner}'s {other_dest} at this path" + elif not block and packed is not None: + placed[dest.lower()] = (sha1_by_path.get(packed), emu_name, dest) + _register_path(dest, placed_files, placed_parents) for result, required, hle in alternatives: + if block_reason and result["status"] != Status.MISSING: + result["status"] = Status.MISSING + result["reason"] = block_reason result["destination"] = dest details.append(result) diff --git a/tests/test_emulator_pack_counts.py b/tests/test_emulator_pack_counts.py index 61fc95c0..44209d4e 100644 --- a/tests/test_emulator_pack_counts.py +++ b/tests/test_emulator_pack_counts.py @@ -19,9 +19,11 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent # kronos: an archive unit under its archive_prefix and a loose same-named file. +# speccy,zesarux: the pack keeps speccy's PENTAGON.ROM and drops zesarux's +# 32 KB pentagon.rom, which verify called covered. CASES = ( "raze", "rust_dos", "hatari", "xash3d_fwgs", "catacombgl", "openjk", "sonic2013", - "kronos", + "kronos", "speccy,zesarux", ) @@ -49,7 +51,8 @@ class PackAndReportAgree(unittest.TestCase): cwd=REPO_ROOT, capture_output=True, text=True, timeout=900, check=False, ).stdout summary = next(line for line in pack.splitlines() if "_BIOS_Pack.zip:" in line) - verdict = next(line for line in report.splitlines() if line.startswith(f"{emulator}:")) + label = emulator.replace(",", " + ") + verdict = next(line for line in report.splitlines() if line.startswith(f"{label}:")) ok = int(re.search(r"(\d+)/\d+ OK", verdict).group(1)) self.assertEqual(_count(summary, "files packed"), ok, (summary, verdict)) self.assertEqual(_count(summary, "missing"), _count(verdict, "missing"))