From cfe9edb3a11486be199a0ddc93b5ad7549dec440 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:17:09 +0100
Subject: [PATCH 1/5] fix: gaps and cores pages count from the data
The gaps page said the exporter regenerates each platform's file, which it patches, and opened on 48 missing above a heading of 10 files. The provenance total sat above three lists that add up to more, and the cores page counted every profile as run by the platforms.
---
scripts/generate_site.py | 53 +++++++++++++++++++++++----------
tests/test_audit_regressions.py | 13 ++++++++
2 files changed, 50 insertions(+), 16 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index b951e8a2d..52066b5bb 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -5293,12 +5293,19 @@ def generate_provenance_page(
kept.append(entry)
data["missing"] = kept
total_missing = distinct_missing(report)
+ # The three counts below added up to more than the total above it.
+ listed = sum(len(data["missing"]) for data in report.values())
+ overlap = (
+ f" A dump two catalogs describe is listed under each, so the lists "
+ f"hold {listed:,} rows."
+ if listed != total_missing else ""
+ )
lines.extend(
[
"## Wanted: catalogued dumps this project does not have",
"",
f"The tables below list **{total_missing:,}** dumps a catalog "
- "describes and the collection lacks. Nothing here ships in any "
+ f"describes and the collection lacks.{overlap} Nothing here ships in any "
"pack: it is an acquisition list, published with hashes so anyone "
"can check a personal collection against it. A contribution "
"matching one of these hashes is welcome: see "
@@ -9992,7 +9999,8 @@ def _copy_cell(path: str, algos: list[str], db: dict | None) -> str:
def _render_problem_files(
- platform_problems: list, settled: list | None = None, db: dict | None = None
+ platform_problems: list, settled: list | None = None, db: dict | None = None,
+ missing_total: int | None = None,
) -> list[str]:
"""Files a platform declares that its own check then rejects.
@@ -10038,12 +10046,17 @@ def _render_problem_files(
entry["platforms"].append(p["platform"])
total_problems = len(missing_files) + len(untested_files) + len(mismatch_files)
+ # One row per name: the page opened on "48 are missing", counted by
+ # destination, above a heading that said "10 files". Both are said.
+ names = _count(len(missing_files), "name")
+ missing_said = (f"{missing_total:,} missing under {names}"
+ if missing_total is not None else f"{len(missing_files)} missing")
if total_problems > 0:
lines.extend([
"## Problem files",
"",
- f"{len(missing_files)} missing, {len(untested_files)} untested, "
+ f"{missing_said}, {len(untested_files)} untested, "
f"{len(mismatch_files)} discrepancy.",
"",
])
@@ -10051,13 +10064,14 @@ def _render_problem_files(
if missing_files:
lines.extend([
f'### Missing '
- f"{_count(len(missing_files), 'file')}",
+ f"{_count(len(missing_files), 'name')}",
"",
"Required is the platform's own flag. Category says what the "
"platform's emulators do with the file: `bios` is firmware they "
"load, `game_data` comes from a copy of the game, `unread` is "
"opened by none of them. Only a missing `bios` entry stops a "
- "console from starting.",
+ "console from starting. A name a list declares in several "
+ "folders is one row.",
"",
"| File | Category | Required | Platforms |",
"|------|----------|----------|-----------|",
@@ -10372,9 +10386,9 @@ def generate_gap_analysis(
prof_cell = gt_cell = "-"
elif gt["total"]:
prof_pct = f"{gt.get('with_profile', 0) / gt['total'] * 100:.0f}%"
- prof_cell = f"{gt.get('with_profile', 0)} ({prof_pct})"
+ prof_cell = f"{gt.get('with_profile', 0):,} ({prof_pct})"
gt_pct = f"{gt['with_validation'] / gt['total'] * 100:.0f}%"
- gt_cell = f"{gt['with_validation']} ({gt_pct})"
+ gt_cell = f"{gt['with_validation']:,} ({gt_pct})"
else:
prof_cell = gt_cell = "0"
lines.append(
@@ -10445,14 +10459,14 @@ def generate_gap_analysis(
"so there is nothing to compare: the platform's own source is the "
"only authority for its files.",
"",
- "The same comparison drives `scripts/exporter/`: each platform's "
- "own file can be regenerated in its native format with these "
- "differences applied (System.dat, es_bios.xml, "
- "batocera-systems.json, ...).",
+ "The same comparison feeds the [updated platform files](exports.md): "
+ "each platform's own file, patched with these differences and kept "
+ "in its native format (System.dat, es_bios.xml, batocera-systems, ...).",
"",
])
- lines.extend(_render_problem_files(platform_problems, settled_problems, db))
+ lines.extend(_render_problem_files(platform_problems, settled_problems, db,
+ total_missing_verify))
# ---- Section 3: Core complement (cross-reference provenance) ----
@@ -10504,8 +10518,8 @@ def generate_gap_analysis(
f"{src_totals['missing']:,} to source.",
"",
"This counts every profiled emulator, including those no platform "
- "ships yet, so it reaches past what the pack tables above measure: "
- "those cover only the emulators each platform actually ships. "
+ "ships yet, so it reaches past what the first table above counts: "
+ "it covers only the emulators each platform actually ships. "
"Whatever is not in the collection is an acquisition target, named "
"per emulator below.",
"",
@@ -10959,6 +10973,12 @@ def generate_cross_reference(
total_upstreams = len({
upstream_project(_xref_upstream(p))[0] for p in unique.values() if _xref_upstream(p)
})
+ # "623 cores across 18 platforms" counted every profile: 91 of them
+ # no platform runs.
+ run_cores = len({
+ key for cov in coverages.values()
+ for key in resolve_platform_cores(cov["config"], unique)
+ })
lines = [
"# Cores by platform",
@@ -10967,8 +10987,9 @@ def generate_cross_reference(
"it comes from. A core built for RetroArch is often a port of another "
"emulator, and the two can differ in the files they read and the checks "
"they make, so each core names its upstream project. "
- f"{total_cores} cores across {len(coverages)} platforms come from "
- f"{total_upstreams} upstream projects.",
+ f"{total_cores} cores are profiled, {run_cores} of them run by the "
+ f"{len(coverages)} platforms, and they come from {total_upstreams} "
+ "upstream projects.",
"",
"The files a platform's emulators load and its own list does not name are "
"counted on [What the packs do not hold](gaps.md).",
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 955b0dc52..1275d3c97 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -5002,6 +5002,19 @@ class GapsPageCountsOneWay(unittest.TestCase):
self.assertIn("Other revisions of a held destination", text)
self.assertIn("`cdibios.zip`", text)
+ def test_missing_says_the_destination_count_of_the_opening_sentence(self):
+ # The page opened on "48 are missing" (by destination) above a
+ # heading that said "10 files": one row per name.
+ from scripts.generate_site import _render_problem_files
+
+ problems = [{"platform": "AmberELEC", "name": "hardwareconfig.xml",
+ "status": "missing", "required": True, "reason": "",
+ "discrepancy": "", "category": "unread"}] * 3
+ text = "\n".join(_render_problem_files(problems, missing_total=3))
+ self.assertIn("3 missing under 1 name,", text)
+ self.assertIn('rb-badge-danger">1 name', text)
+ self.assertNotIn("1 missing", text)
+
def test_verify_marks_the_settled_entry(self):
sys.path.insert(0, str(ROOT / "scripts"))
import verify
From 50f9dd7f5f6b188e28b3bceb5514951e4ff23717 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:17:09 +0100
Subject: [PATCH 2/5] fix: pr checklist generates the site first
validate.yml generates the pages before the suite, and the site tests fail on a clone without them.
---
CONTRIBUTING.md | 1 +
scripts/generate_readme.py | 1 +
tests/test_doc_surfaces.py | 46 ++++++++++++++++++++++++++++++++++++++
3 files changed, 48 insertions(+)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7f1dafdcf..2fa3a8ece 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -68,6 +68,7 @@ python scripts/validate_pr.py --changed # the BIOS files the branch add
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
+python scripts/generate_site.py # the pages the site tests read
python -m unittest discover -s tests -t .
```
diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py
index cf3a1fea1..aba4e9c9f 100644
--- a/scripts/generate_readme.py
+++ b/scripts/generate_readme.py
@@ -975,6 +975,7 @@ python scripts/validate_pr.py --changed # the BIOS files the branch add
python scripts/validate_schemas.py --source-only # profiles and platform files
python scripts/profile_sync.py --changed --strict # each profile the branch adds or edits
python scripts/refresh_data_dirs.py # data directories the pack-building tests read
+python scripts/generate_site.py # the pages the site tests read
python -m unittest discover -s tests -t .
```
diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py
index 6431b3afd..d7cee518a 100644
--- a/tests/test_doc_surfaces.py
+++ b/tests/test_doc_surfaces.py
@@ -364,6 +364,24 @@ class ContributorChecksMatchCi(unittest.TestCase):
workflow.index("python -m unittest discover"),
)
+ def test_the_pr_checklist_generates_the_site_before_the_tests(self):
+ # validate.yml generates the pages before the suite. Without them
+ # the 228 tests that read the gitignored pages fail on a clone.
+ workflow = _read(".github/workflows/validate.yml")
+ self.assertLess(
+ workflow.index("run: python scripts/generate_site.py"),
+ workflow.index("python -m unittest discover"),
+ )
+ for text in (
+ contributing_text(lambda page: page, "# C", "here"),
+ _read("CONTRIBUTING.md"),
+ ):
+ block = _section(text, "## Before opening a PR")
+ self.assertIn("python scripts/generate_site.py", block)
+ self.assertLess(
+ block.index("generate_site.py"), block.index("unittest discover")
+ )
+
class PagesOpenOnTheirContent(unittest.TestCase):
"""The download page said "This page picks the right pack for a setup"
@@ -382,3 +400,31 @@ class PagesOpenOnTheirContent(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+
+
+class CoreCountsReadFromTheData(unittest.TestCase):
+ """The cores page said "623 cores across 18 platforms": that was every
+ profile, and 91 of them no platform runs."""
+
+ def test_the_run_count_is_what_the_platforms_resolve(self):
+ import yaml
+ from common import load_emulator_profiles, load_platform_config, resolve_platform_cores
+
+ from tests import sitepages
+
+ sitepages.require()
+ text = sitepages.page("cross-reference.md").read_text(encoding="utf-8")
+ profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
+ unique = {k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test")}
+ registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text(
+ encoding="utf-8"))["platforms"]
+ run = set()
+ for name in registry:
+ config = load_platform_config(name, str(REPO_ROOT / "platforms"))
+ run |= set(resolve_platform_cores(config, unique))
+ said = re.search(r"([\d,]+) cores are profiled, ([\d,]+) of them run by the (\d+) platforms",
+ text)
+ self.assertIsNotNone(said, "the page says how many profiled cores the platforms run")
+ self.assertEqual(int(said[1].replace(",", "")), len(unique))
+ self.assertEqual(int(said[2].replace(",", "")), len(run))
+ self.assertEqual(int(said[3]), len(registry))
From 292281a8fa8051317e3d33c2c860e376a11db865 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:17:10 +0100
Subject: [PATCH 3/5] refactor: drop the unused pack table class
No page writes table.rb-packs: the Get your files tables are matched by their labelled cells. The file list comment named the Tested block, removed on 2026-10-28.
---
tests/test_site_r3_css.py | 4 +++-
website/src/styles/retrobios.css | 18 +++++++++---------
2 files changed, 12 insertions(+), 10 deletions(-)
diff --git a/tests/test_site_r3_css.py b/tests/test_site_r3_css.py
index 6607230bd..8a532c294 100644
--- a/tests/test_site_r3_css.py
+++ b/tests/test_site_r3_css.py
@@ -11,7 +11,9 @@ import unittest
from tests.test_site_r2_css import CSS_PATH, PAGE_BG, _blocks, _ratio, _rules, _strip_comments, _theme_tokens
-PACKS = "table.rb-packs"
+# The Get your files tables: no class reaches them, the Markdown table is
+# matched by the cells the generator labels.
+PACKS = "table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])"
class ThemeStylesheetRound3(unittest.TestCase):
diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css
index 4b560aa21..3c1f8c6b6 100644
--- a/website/src/styles/retrobios.css
+++ b/website/src/styles/retrobios.css
@@ -371,19 +371,19 @@
.sl-markdown-content :is(td.rb-reason, td[data-label='Reason'], td:has(> .rb-reason)) { min-width: 12rem; }
/* Get your files: every group table shares one set of columns. */
- .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) {
+ .sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) {
display: table;
table-layout: fixed;
width: 100%;
}
- .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(1) { width: 18%; }
- .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(2) { width: 20%; }
- .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(3) { width: 40%; }
- .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) th:nth-child(4) { width: 22%; }
- .sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) td { overflow-wrap: break-word; }
+ .sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) th:nth-child(1) { width: 18%; }
+ .sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) th:nth-child(2) { width: 20%; }
+ .sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) th:nth-child(3) { width: 40%; }
+ .sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) th:nth-child(4) { width: 22%; }
+ .sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) td { overflow-wrap: break-word; }
}
/* A ZIP part link or button never breaks across two lines. */
-.sl-markdown-content :is(table.rb-packs, table:has(td[data-label='Runs on']):has(td[data-label='ZIP'])) td:last-child :is(a, .rb-button) { white-space: nowrap; }
+.sl-markdown-content table:has(td[data-label='Runs on']):has(td[data-label='ZIP']) td:last-child :is(a, .rb-button) { white-space: nowrap; }
/* Key and value cards: the empty header row is not drawn */
.rb-meta-card thead,
@@ -636,8 +636,8 @@ body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); }
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
-/* A platform's file list (generate_site._platform_file_list), on the
- pattern of the Tested block: each row a details whose summary is the
+/* A platform's file list (generate_site._platform_file_list): each row
+ a details whose summary is the
row (name, folder, status), its body the evidence across the whole
list. The short hash sits on its copy button (site.ts) beside the
summary, since a summary is itself a control. The details reaches under
From 118535db246b939a999a439b138afbfd581c38a9 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Tue, 3 Nov 2026 10:17:10 +0100
Subject: [PATCH 4/5] feat: guard generated pages against retired text
Retired commands, addresses and feature terms, each with its reason, and every option a generated command passes, read against its script's parser.
---
tests/test_site_retired_terms.py | 157 +++++++++++++++++++++++++++++++
1 file changed, 157 insertions(+)
create mode 100644 tests/test_site_retired_terms.py
diff --git a/tests/test_site_retired_terms.py b/tests/test_site_retired_terms.py
new file mode 100644
index 000000000..6a93b19b9
--- /dev/null
+++ b/tests/test_site_retired_terms.py
@@ -0,0 +1,157 @@
+"""What the generators write must not bring back what the project retired.
+
+The generated pages, README.md, CONTRIBUTING.md and the site's components
+are read for two things. A retired command, address or feature term, each
+listed with the reason it was retired. And every `--option` a generated
+command passes: the script it names must declare it in its parser, or a
+reader copies a command that stops on "unrecognized arguments".
+
+The pages copied from wiki/ are left to their own tests: the installer page
+keeps the raw one-liner as the documented fallback.
+"""
+from __future__ import annotations
+
+import ast
+import re
+import unittest
+from functools import cache
+from pathlib import Path
+
+from tests import sitepages
+
+ROOT = Path(__file__).resolve().parents[1]
+
+# (string, why it is retired). Compared case-sensitively against the text.
+RETIRED = (
+ ("raw.githubusercontent.com/Abdess/retrobios/main/install.sh",
+ "the site serves the one-liner at https://abdess.github.io/retrobios/i "
+ "since 2026-10-28, pinned to the commit the site was built from"),
+ ("raw.githubusercontent.com/Abdess/retrobios/main/install.ps1",
+ "served at https://abdess.github.io/retrobios/i.ps1 since 2026-10-28"),
+ ("Abdess/retroarch_system",
+ "the repository was renamed retrobios in March 2026"),
+ ("mkdocs serve", "the MkDocs site was replaced by Astro Starlight on 2026-10-22"),
+ ("mkdocs build", "the MkDocs site was replaced by Astro Starlight on 2026-10-22"),
+ ("pip install mkdocs", "the site is built by Node from website/package-lock.json"),
+ ('class="rb-tested', "the Tested blocks were removed on 2026-10-28: "
+ "only Proof lines and the Measurements page remain"),
+ ("rb-region-runs", "region outcomes left the system pages on 2026-10-28"),
+ ("## Campaign", "the campaign table left the Measurements page on 2026-10-28"),
+ ("| Tested by |", "the coverage table left the Measurements page on 2026-10-28"),
+ ("ms per frame", "benchmark figures stay in the records and SQLite files, "
+ "never on a page, since 2026-10-28"),
+ ("with this BIOS folder as", "the Tested block of platform pages, removed on 2026-10-28"),
+ ("regenerated in its native format", "the export patches each platform's own "
+ "file and never regenerates it (scripts/exporter/base_exporter.py needs_original)"),
+)
+
+COMMAND = re.compile(
+ r"(?:python3? (?P