mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-06 18:13:48 -06:00
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.
This commit is contained in:
1 parent
f66adfdcbc
commit
a75ea05b86
5 files changed
+180
-39
No files matched your search
@@ -241,50 +241,89 @@ class EntryCategories:
|
|||||||
archive, and the platform declares the cores it runs. Anything else,
|
archive, and the platform declares the cores it runs. Anything else,
|
||||||
and every entry of a platform with no core to examine (BizHawk, MiSTer),
|
and every entry of a platform with no core to examine (BizHawk, MiSTer),
|
||||||
stays firmware.
|
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],
|
def __init__(self, config: dict, profiles: dict[str, dict],
|
||||||
data_registry: dict | None = None) -> None:
|
data_registry: dict | None = None) -> None:
|
||||||
examined = resolve_platform_cores(config, profiles)
|
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
|
# Without a declared core list, the examined cores are those sharing
|
||||||
# a system id: a guess about what runs, which cannot prove a negative.
|
# a system id: a guess about what runs, which cannot prove a negative.
|
||||||
self._open = (
|
self._guessed = not config.get("cores")
|
||||||
not examined or not config.get("cores")
|
self._romsets = sorted(key for key in examined if _loads_romsets(profiles[key]))
|
||||||
or bool(unresolved_platform_cores(config, profiles))
|
self._profiles = profiles
|
||||||
)
|
# One index per core and per kind, so a proof names its reader.
|
||||||
self._romsets = any(_loads_romsets(profiles[key]) for key in examined)
|
self._readers: list[tuple[str, str, ReadEvidence]] = []
|
||||||
self._firmware = ReadEvidence()
|
|
||||||
self._game = ReadEvidence()
|
|
||||||
for key in sorted(examined):
|
for key in sorted(examined):
|
||||||
profile = profiles[key]
|
profile = profiles[key]
|
||||||
files = [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
files = [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
||||||
self._firmware.add_profile(
|
for kind, keep in ((BIOS, lambda f: f.get("category") != GAME_DATA),
|
||||||
{**profile, "files": [f for f in files if f.get("category") != GAME_DATA]},
|
(GAME_DATA, lambda f: f.get("category") == GAME_DATA)):
|
||||||
data_registry or {},
|
evidence = ReadEvidence()
|
||||||
)
|
evidence.add_profile(
|
||||||
self._game.add_profile(
|
{"files": [f for f in files if keep(f)],
|
||||||
{"files": [f for f in files if f.get("category") == GAME_DATA]},
|
"data_directories": profile.get("data_directories") if kind == BIOS else []},
|
||||||
data_registry or {},
|
data_registry or {},
|
||||||
)
|
)
|
||||||
self._firmware.add_launchers(config.get("launchers") or [])
|
self._readers.append((kind, key, evidence))
|
||||||
self._systems = {
|
self._launchers = ReadEvidence()
|
||||||
_norm_system_id(str(sid))
|
self._launchers.add_launchers(config.get("launchers") or [])
|
||||||
for key in examined
|
self._by_system: dict[str, list[str]] = {}
|
||||||
for sid in profiles[key].get("systems") or []
|
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:
|
def category(self, entry: dict, destination: str, system: str) -> str:
|
||||||
"""`system` is the platform's system id the entry is declared under.
|
return self.explain(entry, destination, system)["category"]
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1993,6 +1993,12 @@ def _render_platform_systems(
|
|||||||
status_display = f"untested: {reason}" if reason else "untested"
|
status_display = f"untested: {reason}" if reason else "untested"
|
||||||
elif status == "missing":
|
elif status == "missing":
|
||||||
status_display = "**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:
|
else:
|
||||||
status_display = status
|
status_display = status
|
||||||
|
|
||||||
@@ -2027,6 +2033,8 @@ def _render_platform_systems(
|
|||||||
if sha256:
|
if sha256:
|
||||||
hash_parts.append(f"SHA256: `{sha256}`")
|
hash_parts.append(f"SHA256: `{sha256}`")
|
||||||
lines.append(f"{pad} {' | '.join(hash_parts)}")
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -2053,6 +2061,27 @@ def _platform_data_links(name: str) -> str:
|
|||||||
return ", ".join(links)
|
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(
|
def generate_platform_page(
|
||||||
name: str,
|
name: str,
|
||||||
cov: dict,
|
cov: dict,
|
||||||
|
|||||||
+36
-4
@@ -1356,10 +1356,12 @@ def verify_platform(
|
|||||||
prev = file_status.get(dest)
|
prev = file_status.get(dest)
|
||||||
hle = hle_index.get(file_entry.get("name", ""), False)
|
hle = hle_index.get(file_entry.get("name", ""), False)
|
||||||
sev = compute_severity(cur, required, mode, hle)
|
sev = compute_severity(cur, required, mode, hle)
|
||||||
category = (
|
if cur == Status.MISSING:
|
||||||
categories.category(file_entry, destination, sys_id)
|
explained = categories.explain(file_entry, destination, sys_id)
|
||||||
if cur == Status.MISSING else BIOS
|
category = explained.pop("category")
|
||||||
)
|
result["category_evidence"] = explained
|
||||||
|
else:
|
||||||
|
category = BIOS
|
||||||
result["category"] = category
|
result["category"] = category
|
||||||
if file_category.get(dest, category) == BIOS:
|
if file_category.get(dest, category) == BIOS:
|
||||||
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:
|
def _print_detail_entries(details: list[dict], seen: set[str], verbose: bool) -> None:
|
||||||
"""Print UNTESTED, MISSING, and DISCREPANCY entries from verification details."""
|
"""Print UNTESTED, MISSING, and DISCREPANCY entries from verification details."""
|
||||||
for d in details:
|
for d in details:
|
||||||
@@ -1636,11 +1665,14 @@ def _print_detail_entries(details: list[dict], seen: set[str], verbose: bool) ->
|
|||||||
if verbose:
|
if verbose:
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
print(f" MISSING ({label}): {key}")
|
print(f" MISSING ({label}): {key}")
|
||||||
|
_print_category_evidence(d)
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
req = "required" if d.get("required", True) else "optional"
|
req = "required" if d.get("required", True) else "optional"
|
||||||
hle = ", HLE available" if d.get("hle_fallback") else ""
|
hle = ", HLE available" if d.get("hle_fallback") else ""
|
||||||
print(f" MISSING ({req}{hle}): {key}")
|
print(f" MISSING ({req}{hle}): {key}")
|
||||||
|
if verbose:
|
||||||
|
_print_category_evidence(d)
|
||||||
_print_ground_truth(d.get("ground_truth", []), verbose)
|
_print_ground_truth(d.get("ground_truth", []), verbose)
|
||||||
for d in details:
|
for d in details:
|
||||||
disc = d.get("discrepancy")
|
disc = d.get("discrepancy")
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ PROFILES = {
|
|||||||
"devilutionx": {
|
"devilutionx": {
|
||||||
"emulator": "DevilutionX", "type": "game", "cores": ["devilutionx"],
|
"emulator": "DevilutionX", "type": "game", "cores": ["devilutionx"],
|
||||||
"systems": ["diablo"],
|
"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)
|
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):
|
class StrictCountsFirmwareOnly(unittest.TestCase):
|
||||||
def test_missing_game_data_and_unread_carry_no_severity(self):
|
def test_missing_game_data_and_unread_carry_no_severity(self):
|
||||||
config = _config(["bluemsx", "devilutionx"], [
|
config = _config(["bluemsx", "devilutionx"], [
|
||||||
@@ -88,6 +120,9 @@ class StrictCountsFirmwareOnly(unittest.TestCase):
|
|||||||
"unread": ["bios/Machines/MSX/hardwareconfig.xml"],
|
"unread": ["bios/Machines/MSX/hardwareconfig.xml"],
|
||||||
})
|
})
|
||||||
self.assertEqual(result["status_counts"][verify.Status.MISSING], 3)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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: ...`)
|
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
|
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
|
`--strict` fails on firmware gaps only, and the pack check does not count them as
|
||||||
|
|||||||
Reference in new issue
Block a user