From 74a4a036eae00328df7e87bc2d67d24fedce107f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:04:26 +0100 Subject: [PATCH] fix: read crc32 past a copier header in slots Geargrafx drops a 512-byte header before its crc32 (src/media.cpp:754-773 at 7c447b0b), and its profile lists both sizes. slots compared the crc32 of the whole file, so EmuELEC's headered syscard3.pce read as contested. --- scripts/slots.py | 28 ++++++++++++++++++++++++++++ tests/test_slots.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/scripts/slots.py b/scripts/slots.py index b261d6c74..bc770ecb4 100644 --- a/scripts/slots.py +++ b/scripts/slots.py @@ -20,6 +20,7 @@ feeds the report this module prints. from __future__ import annotations import os +import zlib from dataclasses import dataclass, field from typing import TYPE_CHECKING @@ -892,10 +893,37 @@ def _accepted_by_all(path: str, entries: list[dict], db: dict) -> bool: for field_name in ("md5", "sha1", "sha256", "crc32"): declared = _declared_hashes(entry, field_name) if declared and str(record.get(field_name, "")).lower() not in declared: + if field_name == "crc32" and _headerless_crc32(path, record, entry) in declared: + continue return False return True +COPIER_HEADER = 512 + + +def _headerless_crc32(path: str, record: dict, entry: dict) -> str | None: + """The crc32 of a file past its 512-byte copier header, when the entry + declares both the file's size and the size without the header. + + Geargrafx drops the header when the file is 512 bytes over the expected + size and computes the crc32 on the rest (src/media.cpp:754-773). The + profile lists both sizes. EmuELEC's headered syscard3.pce (crc32 + 64f78e3c) is the 6d9a73ef image the profile names. + """ + sizes = entry.get("size") + declared = sizes if isinstance(sizes, list) else [sizes] + size = record.get("size") + if not isinstance(size, int) or not {size, size - COPIER_HEADER} <= set(declared): + return None + try: + with open(path, "rb") as f: + f.seek(COPIER_HEADER) + return f"{zlib.crc32(f.read()):08x}" + except OSError: + return None + + def _same_file_family(paths: list[str]) -> bool: """Whether the paths are one file and its own pinned variants. diff --git a/tests/test_slots.py b/tests/test_slots.py index 417f91f16..a9a0b9152 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -992,6 +992,42 @@ class TwoPathsHoldingOneContentAgree(unittest.TestCase): self.assertFalse(slots._satisfies(different, platform, db)) +class ACopierHeaderIsNotAnotherImage(unittest.TestCase): + """Geargrafx drops a 512-byte header before the crc32 (src/media.cpp: + 754-773). EmuELEC's headered syscard3.pce was reported contested, though + the core reads it as the 6d9a73ef image its profile lists.""" + + def test_headered_file_satisfies_the_profile(self): + import zlib # noqa: PLC0415 + + image = b"S" * 1024 + with tempfile.TemporaryDirectory() as tmp: + headered = Path(tmp, "headered.pce") + headered.write_bytes(b"\0" * 512 + image) + plain = Path(tmp, "plain.pce") + plain.write_bytes(image) + crc = f"{zlib.crc32(image):08x}" + db = {"files": { + "a" * 40: {"path": str(headered), "size": 1536, + "crc32": f"{zlib.crc32(headered.read_bytes()):08x}"}, + "b" * 40: {"path": str(plain), "size": 1024, "crc32": crc}, + }} + platform = Claim("platform", "syscard3.pce", "syscard3.pce", + local_path=str(headered), status="md5_exact") + profile = Claim( + "profile", "syscard3.pce", "syscard3.pce", emulator="geargrafx", + local_path=str(plain), status="crc32_exact", + entry={"name": "syscard3.pce", "crc32": [crc], "size": [1024, 1536]}, + ) + self.assertTrue(slots._satisfies(profile, platform, db)) + one_size = Claim( + "profile", "syscard3.pce", "syscard3.pce", emulator="geargrafx", + local_path=str(plain), status="crc32_exact", + entry={"name": "syscard3.pce", "crc32": [crc], "size": 1024}, + ) + self.assertFalse(slots._satisfies(one_size, platform, db)) + + class TheSlotsSummaryClosesTheRun(unittest.TestCase): """The totals came before the collision section and ignored it, and the help said the default run fails on conflicts the pack should settle,