From a75ea05b8667cc3470c84f2bb1f351a7f6fcffc3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 22 Oct 2026 05:43:06 +0200 Subject: [PATCH] feat: document what settles each category Each missing platform entry now carries its evidence: the profile entries that read it with their source reference, the cores of its system examined without finding a reader, or why the question stays open. verify --verbose prints it, --json carries it and the platform pages show it. --- scripts/exporter/candidates.py | 107 ++++++++++++++++++++++----------- scripts/generate_site.py | 29 +++++++++ scripts/verify.py | 40 ++++++++++-- tests/test_entry_categories.py | 37 +++++++++++- wiki/verification-modes.md | 6 ++ 5 files changed, 180 insertions(+), 39 deletions(-) diff --git a/scripts/exporter/candidates.py b/scripts/exporter/candidates.py index 6bfe6bf6..ce89495a 100644 --- a/scripts/exporter/candidates.py +++ b/scripts/exporter/candidates.py @@ -241,50 +241,89 @@ class EntryCategories: archive, and the platform declares the cores it runs. Anything else, and every entry of a platform with no core to examine (BizHawk, MiSTer), stays firmware. + + `explain` returns the category with its evidence: the profile entries + that read the file, or the cores of its system that were examined. """ def __init__(self, config: dict, profiles: dict[str, dict], data_registry: dict | None = None) -> None: examined = resolve_platform_cores(config, profiles) + self._unresolved = sorted(unresolved_platform_cores(config, profiles)) # Without a declared core list, the examined cores are those sharing # a system id: a guess about what runs, which cannot prove a negative. - self._open = ( - not examined or not config.get("cores") - or bool(unresolved_platform_cores(config, profiles)) - ) - self._romsets = any(_loads_romsets(profiles[key]) for key in examined) - self._firmware = ReadEvidence() - self._game = ReadEvidence() + self._guessed = not config.get("cores") + self._romsets = sorted(key for key in examined if _loads_romsets(profiles[key])) + self._profiles = profiles + # One index per core and per kind, so a proof names its reader. + self._readers: list[tuple[str, str, ReadEvidence]] = [] for key in sorted(examined): profile = profiles[key] files = [f for f in profile.get("files") or [] if isinstance(f, dict)] - self._firmware.add_profile( - {**profile, "files": [f for f in files if f.get("category") != GAME_DATA]}, - data_registry or {}, - ) - self._game.add_profile( - {"files": [f for f in files if f.get("category") == GAME_DATA]}, - data_registry or {}, - ) - self._firmware.add_launchers(config.get("launchers") or []) - self._systems = { - _norm_system_id(str(sid)) - for key in examined - for sid in profiles[key].get("systems") or [] + for kind, keep in ((BIOS, lambda f: f.get("category") != GAME_DATA), + (GAME_DATA, lambda f: f.get("category") == GAME_DATA)): + evidence = ReadEvidence() + evidence.add_profile( + {"files": [f for f in files if keep(f)], + "data_directories": profile.get("data_directories") if kind == BIOS else []}, + data_registry or {}, + ) + self._readers.append((kind, key, evidence)) + self._launchers = ReadEvidence() + self._launchers.add_launchers(config.get("launchers") or []) + self._by_system: dict[str, list[str]] = {} + for key in sorted(examined): + for sid in profiles[key].get("systems") or []: + self._by_system.setdefault(_norm_system_id(str(sid)), []).append(key) + + def _matching(self, key: str, kind: str, entry: dict) -> list[dict]: + """The profile entries of `key` that name the platform entry.""" + names = { + str(n).casefold() + for n in (entry.get("name"), *(entry.get("aliases") or [])) + if n } + found = [] + for f in self._profiles[key].get("files") or []: + if not isinstance(f, dict) or (f.get("category") == GAME_DATA) != (kind == GAME_DATA): + continue + own = {str(n).casefold() for n in (f.get("name"), *(f.get("aliases") or [])) if n} + if own & names: + found.append({k: f[k] for k in ("name", "source_ref") if f.get(k)}) + return found + + def explain(self, entry: dict, destination: str, system: str) -> dict: + """The category of an entry and what establishes it.""" + readers: dict[str, list[dict]] = {BIOS: [], GAME_DATA: []} + for kind, key, evidence in self._readers: + proof = evidence.proof(entry, destination) + if proof is not None: + readers[kind].append({ + "emulator": key, "proof": proof, + "entries": self._matching(key, kind, entry), + }) + if self._launchers.proof(entry, destination) is not None: + readers[BIOS].append({"emulator": "launcher", "proof": "launcher", "entries": []}) + system_cores = self._by_system.get(_norm_system_id(system), []) + if readers[BIOS]: + return {"category": BIOS, "read_by": readers[BIOS]} + if readers[GAME_DATA]: + return {"category": GAME_DATA, "read_by": readers[GAME_DATA]} + examined = {"cores_examined": system_cores} + if self._guessed: + return {"category": BIOS, "unsettled": "the platform declares no core list", **examined} + if self._unresolved: + return {"category": BIOS, + "unsettled": "cores the platform names have no profile: " + + ", ".join(self._unresolved), **examined} + if not system_cores: + return {"category": BIOS, + "unsettled": "no examined core emulates this system", **examined} + if self._romsets and str(entry.get("name", "")).casefold().endswith(_ARCHIVES): + return {"category": BIOS, + "unsettled": "a ROM set, opened by name by " + ", ".join(self._romsets), + **examined} + return {"category": UNREAD, **examined} def category(self, entry: dict, destination: str, system: str) -> str: - """`system` is the platform's system id the entry is declared under. - - Unread also needs a core of that system among those examined: an - entry of a system no profiled core emulates is simply unexamined. - """ - if self._firmware.proof(entry, destination) is not None: - return BIOS - if self._game.proof(entry, destination) is not None: - return GAME_DATA - if self._open or _norm_system_id(system) not in self._systems: - return BIOS - if self._romsets and str(entry.get("name", "")).casefold().endswith(_ARCHIVES): - return BIOS - return UNREAD + return self.explain(entry, destination, system)["category"] diff --git a/scripts/generate_site.py b/scripts/generate_site.py index fb08e885..c8fd7e14 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -1993,6 +1993,12 @@ def _render_platform_systems( status_display = f"untested: {reason}" if reason else "untested" elif status == "missing": status_display = "**missing**" + # Not every missing entry is firmware: the platform may list a + # game's data or a file no core it runs opens. + kind = {"game_data": "game data", + "unread": "no core the platform runs opens it"}.get(f.get("category", "")) + if kind: + status_display += f", {kind}" else: status_display = status @@ -2027,6 +2033,8 @@ def _render_platform_systems( if sha256: hash_parts.append(f"SHA256: `{sha256}`") lines.append(f"{pad} {' | '.join(hash_parts)}") + if status == "missing" and f.get("category_evidence"): + lines.append(f"{pad} {_category_evidence_md(f['category_evidence'])}") lines.append("") @@ -2053,6 +2061,27 @@ def _platform_data_links(name: str) -> str: return ", ".join(links) +def _category_evidence_md(evidence: dict) -> str: + """What establishes a missing entry's category, as the page shows it.""" + if evidence.get("read_by"): + readers = [] + for reader in evidence["read_by"]: + refs = [ + f"`{e['name']}` (`{e['source_ref']}`)" if e.get("source_ref") else f"`{e['name']}`" + for e in reader.get("entries", []) + ] + name = (_emulator_link(reader["emulator"], "../") + if reader["emulator"] != "launcher" else "the platform's launcher") + readers.append(f"{name}: {', '.join(refs)}" if refs else f"{name} (by {reader['proof']})") + return "Read by " + "; ".join(readers) + examined = evidence.get("cores_examined") or [] + cores = ("cores examined: " + ", ".join(_emulator_link(c, "../") for c in examined) + if examined else "no core examined") + if evidence.get("unsettled"): + return f"No reader found, not settled: {evidence['unsettled']} ({cores})" + return f"No reader found ({cores})" + + def generate_platform_page( name: str, cov: dict, diff --git a/scripts/verify.py b/scripts/verify.py index 26187b60..b34cdc62 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1356,10 +1356,12 @@ def verify_platform( prev = file_status.get(dest) hle = hle_index.get(file_entry.get("name", ""), False) sev = compute_severity(cur, required, mode, hle) - category = ( - categories.category(file_entry, destination, sys_id) - if cur == Status.MISSING else BIOS - ) + if cur == Status.MISSING: + explained = categories.explain(file_entry, destination, sys_id) + category = explained.pop("category") + result["category_evidence"] = explained + else: + category = BIOS result["category"] = category if file_category.get(dest, category) == BIOS: category = BIOS @@ -1608,6 +1610,33 @@ _NOT_FIRMWARE = { } +def category_evidence_text(evidence: dict) -> str: + """One line saying what establishes a missing entry's category.""" + if evidence.get("read_by"): + readers = [] + for reader in evidence["read_by"]: + refs = [ + f"{e['name']} ({e['source_ref']})" if e.get("source_ref") else e["name"] + for e in reader.get("entries", []) + ] + readers.append( + f"{reader['emulator']}: {', '.join(refs)}" if refs + else f"{reader['emulator']} (by {reader['proof']})" + ) + return "read by " + "; ".join(readers) + examined = evidence.get("cores_examined") or [] + cores = f"cores examined: {', '.join(examined)}" if examined else "no core examined" + if evidence.get("unsettled"): + return f"no reader found, not settled: {evidence['unsettled']} ({cores})" + return f"no reader found ({cores})" + + +def _print_category_evidence(detail: dict) -> None: + evidence = detail.get("category_evidence") + if evidence: + print(f" {category_evidence_text(evidence)}") + + def _print_detail_entries(details: list[dict], seen: set[str], verbose: bool) -> None: """Print UNTESTED, MISSING, and DISCREPANCY entries from verification details.""" for d in details: @@ -1636,11 +1665,14 @@ def _print_detail_entries(details: list[dict], seen: set[str], verbose: bool) -> if verbose: seen.add(key) print(f" MISSING ({label}): {key}") + _print_category_evidence(d) continue seen.add(key) req = "required" if d.get("required", True) else "optional" hle = ", HLE available" if d.get("hle_fallback") else "" print(f" MISSING ({req}{hle}): {key}") + if verbose: + _print_category_evidence(d) _print_ground_truth(d.get("ground_truth", []), verbose) for d in details: disc = d.get("discrepancy") diff --git a/tests/test_entry_categories.py b/tests/test_entry_categories.py index 73cc42f6..1ca8ebea 100644 --- a/tests/test_entry_categories.py +++ b/tests/test_entry_categories.py @@ -24,7 +24,8 @@ PROFILES = { "devilutionx": { "emulator": "DevilutionX", "type": "game", "cores": ["devilutionx"], "systems": ["diablo"], - "files": [{"name": "diabdat.mpq", "category": "game_data"}], + "files": [{"name": "diabdat.mpq", "category": "game_data", + "source_ref": "Source/init.cpp:120"}], }, } @@ -71,6 +72,37 @@ class Category(unittest.TestCase): self.assertEqual(cats.category({"name": "anything.bin"}, "anything.bin", "s"), BIOS) +class Evidence(unittest.TestCase): + """Each category says what establishes it, so the report documents it.""" + + def setUp(self): + self.cats = EntryCategories(_config(["bluemsx", "devilutionx"], []), PROFILES) + + def test_game_data_names_its_reader_and_reference(self): + found = self.cats.explain({"name": "diabdat.mpq"}, "ports/diablo/diabdat.mpq", "diablo") + self.assertEqual(found["category"], GAME_DATA) + self.assertEqual(found["read_by"], [{ + "emulator": "devilutionx", "proof": "name", + "entries": [{"name": "diabdat.mpq", "source_ref": "Source/init.cpp:120"}]}]) + self.assertEqual(verify.category_evidence_text(found), + "read by devilutionx: diabdat.mpq (Source/init.cpp:120)") + + def test_unread_names_the_cores_examined(self): + found = self.cats.explain({"name": "hardwareconfig.xml"}, + "bios/Machines/MSX/hardwareconfig.xml", "microsoft-msx") + self.assertEqual(found, {"category": UNREAD, "cores_examined": ["bluemsx"]}) + self.assertEqual(verify.category_evidence_text(found), + "no reader found (cores examined: bluemsx)") + + def test_an_unsettled_entry_says_why(self): + cats = EntryCategories(_config(["bluemsx", "devilutionx", "mystery"], []), PROFILES) + found = cats.explain({"name": "hardwareconfig.xml"}, + "bios/Machines/MSX/hardwareconfig.xml", "microsoft-msx") + self.assertEqual(found["category"], BIOS) + self.assertIn("mystery", found["unsettled"]) + self.assertIn("not settled", verify.category_evidence_text(found)) + + class StrictCountsFirmwareOnly(unittest.TestCase): def test_missing_game_data_and_unread_carry_no_severity(self): config = _config(["bluemsx", "devilutionx"], [ @@ -88,6 +120,9 @@ class StrictCountsFirmwareOnly(unittest.TestCase): "unread": ["bios/Machines/MSX/hardwareconfig.xml"], }) self.assertEqual(result["status_counts"][verify.Status.MISSING], 3) + evidence = {d["name"]: d.get("category_evidence") for d in result["details"]} + self.assertEqual(evidence["hardwareconfig.xml"]["cores_examined"], ["bluemsx"]) + self.assertEqual(evidence["diabdat.mpq"]["read_by"][0]["emulator"], "devilutionx") if __name__ == "__main__": diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index e81e3091..fb0003bc 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -292,6 +292,12 @@ categories, read from the profiles of the cores the platform runs: | game data | only `category: game_data` entries of those profiles name it | none, counted apart | | unread | the platform declares its cores, every one has a profile, at least one emulates the entry's system, and none opens the entry | none, counted apart | +Every missing entry carries what establishes its category: the profile entries +that read it, with their source reference, or the cores of its system that were +examined and found no reader, or the reason the question stays open. `--verbose` +prints it under the entry, `--json` carries it as `category_evidence`, and each +platform page shows it. + The report counts the last two on a line of their own (`Missing, not firmware: ...`) and `--verbose` names them. They stay in the platform's file and in the totals, but `--strict` fails on firmware gaps only, and the pack check does not count them as