From fe77535c3bd5fc671e7c1ed082c72f0b3de94d63 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:41:17 +0200 Subject: [PATCH] fix: state one catalog ratio, correct the faq The home page and the stats export counted every file carrying a provenance record, the provenance page and the README only the system files. The site published 553 and 566 for the same quantity, one click apart, and the export paired the wider count with composition.systems as its denominator. common.count_catalog_matched is now the single source, scoped to the systems bucket. The FAQ had drifted from the profiles it describes: MAME pinned at 0.287 against 0.289 in mame.yml, Adler-32 attributed to Dolphin's IPL rather than the DSP ROMs that carry known_hash_adler32, and the per-emulator verbose report named as the only content check on an existence platform, which skips the DISCREPANCY line the platform report raises itself. Tests read both sides: no generator may count matches inline, and each FAQ claim is checked against the profile or the script that owns it. --- scripts/common.py | 35 +++++++-- scripts/generate_readme.py | 24 ++---- scripts/generate_site.py | 30 +++----- tests/test_audit_regressions.py | 131 ++++++++++++++++++++++++++++++++ wiki/faq.md | 16 ++-- 5 files changed, 184 insertions(+), 52 deletions(-) diff --git a/scripts/common.py b/scripts/common.py index 2bfe1fed..0380edcf 100644 --- a/scripts/common.py +++ b/scripts/common.py @@ -950,6 +950,17 @@ def unique_emulator_profiles(profiles: dict[str, dict]) -> dict[str, dict]: GAME_DATA_TOPS = ("RPG Maker", "ScummVM") +def composition_tier(path: str) -> str: + """Which composition bucket a repository path belongs to.""" + parts = path.split("/") + top = parts[1] if len(parts) > 1 else "" + if top == "Arcade": + return "arcade" + if top in GAME_DATA_TOPS: + return "game_data" + return "systems" + + def compute_composition(db: dict) -> dict: """File and byte counts by tree area. @@ -963,19 +974,27 @@ def compute_composition(db: dict) -> dict: "game_data": {"files": 0, "size_bytes": 0}, } for entry in db.get("files", {}).values(): - parts = entry.get("path", "").split("/") - top = parts[1] if len(parts) > 1 else "" - if top == "Arcade": - bucket = buckets["arcade"] - elif top in GAME_DATA_TOPS: - bucket = buckets["game_data"] - else: - bucket = buckets["systems"] + bucket = buckets[composition_tier(entry.get("path", ""))] bucket["files"] += 1 bucket["size_bytes"] += entry.get("size", 0) return buckets +def count_catalog_matched(db: dict) -> int: + """System files byte-identical to a dump-preservation catalog entry. + + Scoped to the systems bucket, the denominator every surface pairs it + with: No-Intro, Redump and TOSEC index console and computer dumps, so + arcade ROM sets and engine data sit on neither side of the ratio. + """ + return sum( + 1 + for entry in db.get("files", {}).values() + if entry.get("provenance") + and composition_tier(entry.get("path", "")) == "systems" + ) + + def group_identical_platforms( platforms: list[str], platforms_dir: str, diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index fb9323e2..08e7c06c 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -18,8 +18,8 @@ from datetime import datetime, timezone sys.path.insert(0, os.path.dirname(__file__)) from common import ( - GAME_DATA_TOPS, compute_composition, + count_catalog_matched, list_registered_platforms, load_database, load_emulator_profiles, @@ -184,25 +184,13 @@ _CATALOG_LABELS = {"redump": "Redump", "no-intro": "No-Intro", "tosec": "TOSEC"} def _catalog_matched_line(db: dict) -> list[str]: - """Bullet line for files matched to dump-preservation catalogs. - - Counted against the system files alone: those catalogs index console and - computer dumps, so arcade ROM sets and engine data sit outside their - scope and would only dilute the ratio. - """ - sources: set[str] = set() - matched = 0 - for entry in db.get("files", {}).values(): - provenance = entry.get("provenance") - if not provenance: - continue - sources.update(provenance) - parts = entry.get("path", "").split("/") - top = parts[1] if len(parts) > 1 else "" - if top != "Arcade" and top not in GAME_DATA_TOPS: - matched += 1 + """Bullet line for files matched to dump-preservation catalogs.""" + matched = count_catalog_matched(db) if not matched: return [] + sources: set[str] = set() + for entry in db.get("files", {}).values(): + sources.update(entry.get("provenance") or ()) system_files = compute_composition(db)["systems"]["files"] labels = ", ".join(_CATALOG_LABELS.get(s, s) for s in sorted(sources)) return [ diff --git a/scripts/generate_site.py b/scripts/generate_site.py index a5f2cf6c..b606c72c 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -32,7 +32,7 @@ from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) from common import ( compute_composition, - GAME_DATA_TOPS, + count_catalog_matched, list_registered_platforms, load_database, load_emulator_profiles, @@ -296,16 +296,16 @@ def generate_home( ] ) - catalog_matched = sum( - 1 for f in db.get("files", {}).values() if f.get("provenance") - ) + catalog_matched = count_catalog_matched(db) if catalog_matched: + system_files = compute_composition(db)["systems"]["files"] lines.extend( [ "", - f"**{catalog_matched:,}** files are byte-identical to a dump " - "catalogued by No-Intro, Redump, or TOSEC, and say so on their " - "system page. [What that means](provenance.md).", + f"**{catalog_matched:,}** of {system_files:,} system files are " + "byte-identical to a dump catalogued by No-Intro, Redump, or " + "TOSEC, and say so on their system page. " + "[What that means](provenance.md).", ] ) @@ -404,9 +404,7 @@ def compute_stats(db: dict, coverages: dict, profiles: dict) -> dict: "platforms": len(coverages), "emulators": len(unique), "systems": len(systems), - "catalog_matched": sum( - 1 for f in db.get("files", {}).values() if f.get("provenance") - ), + "catalog_matched": count_catalog_matched(db), "source": REPO_URL, "downloads": RELEASE_URL, } @@ -1504,17 +1502,7 @@ def _prov_title(data: dict) -> str: def generate_provenance_page(db: dict, report: dict) -> str: """Page explaining the verified dump badges and listing catalog gaps.""" - # Scoped to system files: these catalogs index console and computer dumps, - # so arcade ROM sets and engine data can never match and would only make - # the ratio look worse than the work behind it. - matched_files = 0 - for entry in db.get("files", {}).values(): - if not entry.get("provenance"): - continue - parts = entry.get("path", "").split("/") - top = parts[1] if len(parts) > 1 else "" - if top != "Arcade" and top not in GAME_DATA_TOPS: - matched_files += 1 + matched_files = count_catalog_matched(db) total_files = compute_composition(db)["systems"]["files"] lines = [ diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index c9b8d299..9605a822 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -69,6 +69,137 @@ class ReadmeRegressions(unittest.TestCase): self.assertEqual(unknown, set(), f"README advertises unknown flags: {unknown}") +class FaqRegressions(unittest.TestCase): + """The FAQ states facts the code owns; these tie it back to the source. + + Three of its claims had drifted: a pinned MAME version, Adler-32 + attributed to Dolphin's IPL instead of its DSP ROMs, and a verbose + per-emulator report described as the only way to catch a bad file on an + existence platform. Readers act on all three. + """ + + _MAME_GENERATIONS = { + "MAME 2000": "mame2000", + "MAME 2003": "mame2003", + "MAME 2009": "mame2009", + "MAME 2010": "mame2010", + "MAME 2015": "mame2015", + "MAME 2016": "mame2016", + "current MAME": "mame", + } + + @staticmethod + def _faq() -> str: + return (ROOT / "wiki" / "faq.md").read_text(encoding="utf-8") + + @staticmethod + def _profile(key: str) -> dict: + with (ROOT / "emulators" / f"{key}.yml").open(encoding="utf-8") as handle: + return yaml.safe_load(handle) + + def test_mame_versions_match_their_profiles(self): + # The list is wrapped, so a label and its version can straddle a line. + faq = " ".join(self._faq().split()) + for label, key in self._MAME_GENERATIONS.items(): + match = re.search(rf"{re.escape(label)} \(([^)]+)\)", faq) + self.assertIsNotNone(match, f"FAQ no longer states a version for {label}") + self.assertEqual( + match.group(1), + self._profile(key)["core_version"], + f"FAQ version for {label} has drifted from emulators/{key}.yml", + ) + + def test_adler32_is_attributed_to_the_files_that_carry_it(self): + dolphin = self._profile("dolphin") + carriers = { + f["name"] for f in dolphin["files"] if f.get("known_hash_adler32") + } + self.assertTrue(carriers, "dolphin.yml declares no Adler-32 hash") + sentence = self._faq().split("Adler-32", 1)[1].split("\n\n", 1)[0] + for name in carriers: + self.assertIn(name, sentence, f"FAQ omits the Adler-32 file {name}") + self.assertNotIn( + "IPL.bin", + {f["name"] for f in dolphin["files"] if f.get("known_hash_adler32")}, + "IPL.bin gained an Adler-32 hash; the FAQ says it has none", + ) + + def test_existence_platforms_are_not_told_the_verbose_report_is_the_only_check(self): + self.assertIn( + "DISCREPANCY", + self._faq(), + "the FAQ must name the check the platform report performs itself", + ) + self.assertIn( + 'result["discrepancy"]', + (ROOT / "scripts" / "verify.py").read_text(encoding="utf-8"), + "verify.py no longer raises the discrepancy the FAQ advertises", + ) + + +class CatalogRatioRegressions(unittest.TestCase): + """The catalog-matched count must read the same on every surface. + + The home page, the provenance page, the README and the stats export each + used to count matches on their own. Two of them dropped the systems + scope, so the site published 566 and 553 for the same quantity, one click + apart, and the export paired the wider number with composition.systems as + its denominator. + """ + + _SURFACES = ("scripts/generate_site.py", "scripts/generate_readme.py") + + def test_no_surface_counts_matches_on_its_own(self): + for relative in self._SURFACES: + tree = ast.parse((ROOT / relative).read_text(encoding="utf-8")) + inline = [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and getattr(node.func, "id", "") == "sum" + and "provenance" in ast.dump(node) + ] + self.assertEqual( + inline, + [], + f"{relative} counts provenance matches inline at {inline}; " + "call common.count_catalog_matched instead", + ) + + def test_arcade_and_engine_data_stay_out_of_the_ratio(self): + from scripts.common import compute_composition, count_catalog_matched + + db = { + "files": { + "a": {"path": "bios/Sony/PlayStation/scph5501.bin", "size": 1, + "provenance": {"redump": {}}}, + "b": {"path": "bios/Arcade/Arcade/neogeo.zip", "size": 1, + "provenance": {"no-intro": {}}}, + "c": {"path": "bios/ScummVM/soundfonts/Roland.sf2", "size": 1, + "provenance": {"tosec": {}}}, + "d": {"path": "bios/Sega/Saturn/sega_101.bin", "size": 1}, + } + } + self.assertEqual(count_catalog_matched(db), 1) + self.assertEqual(compute_composition(db)["systems"]["files"], 2) + + def test_readme_ratio_matches_the_database(self): + from scripts.common import compute_composition, count_catalog_matched, load_database + + database = ROOT / "database.json" + if not database.exists(): + self.skipTest("database.json not generated") + db = load_database(str(database)) + readme = (ROOT / "README.md").read_text(encoding="utf-8") + match = re.search( + r"\*\*([\d,]+) of ([\d,]+) system files\*\* matched to", readme + ) + self.assertIsNotNone(match, "README no longer states the catalog ratio") + matched, total = (int(g.replace(",", "")) for g in match.groups()) + self.assertEqual(matched, count_catalog_matched(db)) + self.assertEqual(total, compute_composition(db)["systems"]["files"]) + + class PipelineRegressions(unittest.TestCase): def test_pack_parser_removes_source_metadata(self): output = "\n".join( diff --git a/wiki/faq.md b/wiki/faq.md index 52a66d6b..04a91363 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -72,10 +72,13 @@ per catalog and the full list of catalogued dumps still missing. Arcade BIOS sets are coupled to the romset version, so there is one profile per MAME core generation: MAME 2000 (0.37b5), MAME 2003 (0.78), MAME 2003-Plus, MAME 2009 (0.135u4), MAME 2010 (0.139), MAME 2015 -(0.160), MAME 2016 (0.174), and current MAME (0.287), each read from that +(0.160), MAME 2016 (0.174), and current MAME (0.289), each read from that version's own source tree, because the BIOS root sets differ between -versions. The pack ships the sets matching the core you run; a generic -unversioned arcade pack cannot do that. +versions. Derivatives and ports carry their own profiles on top of those +generations: `mamearcade`, `mamemess`, `mame2003_midway`, the three +`mame4droid` drops, `advancemame`, `groovymame` and `hbmame`. The pack +ships the sets matching the core you run; a generic unversioned arcade +pack cannot do that. ## How do I know which BIOS I need? @@ -189,7 +192,8 @@ A hash is a fixed-length fingerprint computed from a file's contents. If even on | CRC32 | 8 hex chars | `2F468B96` | ROM-set matching, arcade DATs | Emulator profiles add Adler-32 where the code checks it, which is how Dolphin -validates its IPL files. +checks its DSP ROMs (`dsp_rom.bin` and `dsp_coef.bin`, hashed byte-swapped). +`IPL.bin` is not one of them: Dolphin loads it without a hash check. Verification uses whichever one the platform itself uses: MD5 for Batocera, RetroBat, Recalbox, EmuDeck, RetroDECK, RomM, ROCKNIX and MiSTer FPGA, SHA1 for @@ -202,7 +206,9 @@ that the file exists. On existence-mode platforms (RetroArch, Lakka, RetroPie), files are never `untested` because the platform only checks presence, not content. Those files show as `ok` if present, whatever they contain. -Running `verify.py --emulator --verbose` shows the emulator-level ground truth, which can confirm whether the file's hash matches what the source code expects. On an existence platform, that verbose report is the only thing that can tell you the file is wrong. +Running `verify.py --emulator --verbose` shows the emulator-level ground truth, which can confirm whether the file's hash matches what the source code expects. The platform report applies the same check on its own: `verify.py --platform retroarch` prints a `DISCREPANCY` line for every file the platform accepts and a profiled emulator rejects. + +It reaches only as far as the profiles do. Files no profile states a value for are platform-only, and nothing can be said about their content; the footer of the platform report gives that ratio as `Ground truth: N/M files have emulator validation`. ## Can I use BIOS from one platform on another?