From 8022ef4cb31c9140d195863c7e6299773f73e04b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:12:43 +0100 Subject: [PATCH 01/22] fix: read the given database on every page The page-finishing pass reloaded the repository's database.json whatever --db said, so a card held only by the given collection raised KeyError. siteinputs holds the inputs generate_site was given. --- scripts/generate_site.py | 25 +++++++++++----- scripts/siteinputs.py | 32 +++++++++++++++++++++ tests/test_site_inputs.py | 60 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 scripts/siteinputs.py create mode 100644 tests/test_site_inputs.py diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 32f926da0..9b83ff328 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -89,6 +89,7 @@ from generate_readme import ( from provenance_report import build_report, distinct_missing import sitemarkup as markup import siteentries +import siteinputs import retired_files import sitestarlight from siteexports import generate_exports_page, load_export_reports @@ -3174,7 +3175,9 @@ def _front_matter(markdown: str) -> dict: return data if isinstance(data, dict) else {} -_SHA1_BY_PREFIX: dict[str, str] = {} +@functools.lru_cache(maxsize=None) +def _sha1_by_prefix(db_path: str) -> dict[str, str]: + return {sha1[:12]: sha1 for sha1 in load_database(db_path)["files"]} def _cards_elsewhere(markdown: str) -> str: @@ -3185,11 +3188,9 @@ def _cards_elsewhere(markdown: str) -> str: away = {ident for ident in links if f'id="{ident}"' not in markdown} if not away: return markdown - if not _SHA1_BY_PREFIX: - db = load_database(str(_REPO_ROOT / "database.json")) - _SHA1_BY_PREFIX.update({sha1[:12]: sha1 for sha1 in db["files"]}) + by_prefix = _sha1_by_prefix(str(siteinputs.current().db)) for ident in away: - full = _SHA1_BY_PREFIX[ident.removeprefix("sha1-")] + full = by_prefix[ident.removeprefix("sha1-")] markdown = markdown.replace(f"](#{ident})", f"]({markup.site_href(f'files/?sha1={full}')})") return markdown @@ -3500,10 +3501,14 @@ def _list_pages( f"[part {i}]({other}.md)" for i, other in enumerate(stems, 1)) -@functools.lru_cache(maxsize=1) def _collection_sha1_by_path() -> dict[str, str]: """The sha1 of each file the collection holds, by its path.""" - with open(_REPO_ROOT / "database.json", encoding="utf-8") as handle: + return _sha1_by_path_in(str(siteinputs.current().db)) + + +@functools.lru_cache(maxsize=None) +def _sha1_by_path_in(db_path: str) -> dict[str, str]: + with open(db_path, encoding="utf-8") as handle: files = json.load(handle).get("files", {}) return {str(entry.get("path", "")): sha1 for sha1, entry in files.items()} @@ -12260,6 +12265,12 @@ def main() -> None: def _generate(args: argparse.Namespace) -> None: + # Every page reads these, never the repository's own files. + siteinputs.use(siteinputs.SiteInputs( + db=Path(args.db).resolve(), + emulators=Path(args.emulators_dir).resolve(), + platforms=Path(args.platforms_dir).resolve(), + )) db = load_database(args.db) docs = Path(args.docs_dir) # Files the site serves as they are (data, downloads, images), in the diff --git a/scripts/siteinputs.py b/scripts/siteinputs.py new file mode 100644 index 000000000..7aa3326ff --- /dev/null +++ b/scripts/siteinputs.py @@ -0,0 +1,32 @@ +"""The collection, profiles and platform files the site describes. + +generate_site.py takes them as --db, --emulators-dir and --platforms-dir and +installs them here before writing a page. Every page module reads its inputs +from current(), so a run on another collection describes that collection on +every page. A module used on its own reads the repository's files. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +@dataclass(frozen=True) +class SiteInputs: + db: Path = ROOT / "database.json" + emulators: Path = ROOT / "emulators" + platforms: Path = ROOT / "platforms" + + +_current = [SiteInputs()] + + +def current() -> SiteInputs: + return _current[0] + + +def use(inputs: SiteInputs) -> None: + _current[0] = inputs diff --git a/tests/test_site_inputs.py b/tests/test_site_inputs.py new file mode 100644 index 000000000..ab75d4be7 --- /dev/null +++ b/tests/test_site_inputs.py @@ -0,0 +1,60 @@ +"""generate_site --db reaches every page. + +The run loaded the given database, but the page-finishing pass reloaded the +repository's database.json: a card held only by the given collection stopped +the whole run on a KeyError. +""" + +from __future__ import annotations + +import ast +import json +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site # noqa: E402 +import siteinputs # noqa: E402 + +SHA1 = "0123456789ab" + "c" * 28 + + +class _Inputs(unittest.TestCase): + def use(self, **paths: Path) -> None: + siteinputs.use(siteinputs.SiteInputs(**paths)) + self.addCleanup(siteinputs.use, siteinputs.SiteInputs()) + + +class TheGivenDatabase(_Inputs): + def setUp(self) -> None: + tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None)) + self.addCleanup(shutil.rmtree, tmp) + self.db = tmp / "database.json" + self.db.write_text(json.dumps({"files": {SHA1: {"path": "bios/X/only-here.bin"}}})) + + def test_a_card_held_elsewhere_resolves_in_the_given_collection(self) -> None: + self.use(db=self.db) + page = generate_site._cards_elsewhere("[x](#sha1-0123456789ab) ") + self.assertIn(f"files/?sha1={SHA1}", page) + + def test_paths_are_read_from_the_given_collection(self) -> None: + self.use(db=self.db) + self.assertEqual(generate_site._collection_sha1_by_path(), {"bios/X/only-here.bin": SHA1}) + + +class GenerateInstallsTheInputs(unittest.TestCase): + def test_generate_installs_the_inputs_first(self) -> None: + source = ast.get_source_segment( + (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8"), + next(n for n in ast.parse((REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8")).body + if isinstance(n, ast.FunctionDef) and n.name == "_generate")) + self.assertLess(source.index("siteinputs.use("), source.index("load_database(")) + + +if __name__ == "__main__": + unittest.main() From 9b871db83aad31b060758e4825a685a723801927 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:12:50 +0100 Subject: [PATCH 02/22] fix: read the given profiles and platform files Five generate_site helpers read ./emulators or ./platforms, and has_release_pack read platforms relative to the working directory: run from scripts/, a released setup's get page showed the No ZIP line. siterender's fallbacks and sitediagrams read the repository too. All of them now read siteinputs. platforms/_guides.yml stays the repository's: siteguides loads it at import, before any flag is parsed. --- scripts/generate_site.py | 76 ++++++++++++++++++++++----------------- scripts/sitediagrams.py | 37 +++++++++++++------ scripts/siterender.py | 13 ++++--- tests/test_site_inputs.py | 68 +++++++++++++++++++++++++++++++++-- wiki/testing-guide.md | 2 +- 5 files changed, 145 insertions(+), 51 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 9b83ff328..62f400543 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -663,7 +663,7 @@ def release_line(platform_name: str, record: dict) -> list[str]: f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) and its " f"[signature]({base}/SHA256SUMS.txt.sig).", ] - config = load_platform_config(platform_name, "platforms") + config = load_platform_config(platform_name, str(siteinputs.current().platforms)) page_version = str(config.get("version") or "") pack_version = _pack_version(pack_name) if page_version and pack_version and page_version.lstrip("v") != pack_version.lstrip("v"): @@ -688,7 +688,7 @@ def setup_version(platform_name: str, record: dict | None = None) -> str: platform YAML was read at. Empty when neither says, or when the platform has no YAML to read a version from.""" try: - config = load_platform_config(platform_name, "platforms") + config = load_platform_config(platform_name, str(siteinputs.current().platforms)) except FileNotFoundError: return "" pack_name = release_record.pack_name_for(platform_name, record or {}) @@ -715,8 +715,7 @@ NO_ZIP_FAQ = "wiki/faq.md#why-is-there-no-zip-for-my-setup" def has_release_pack(platform_name: str) -> bool: """Whether a release publishes a ZIP for the platform (`release_pack`).""" - entry = load_platform_registry("platforms").get(platform_name) or {} - return entry.get("release_pack") is True + return platform_name in _release_pack_platforms() def no_zip_line(display: str) -> str: @@ -735,7 +734,7 @@ def get_page_title(platform_name: str, display: str) -> str: the system's own download. A setup that is also an emulator (BizHawk) names it last: "BizHawk BIOS files" is the title of its emulator page.""" kind = "BIOS pack" if has_release_pack(platform_name) else "BIOS files" - if (_REPO_ROOT / "emulators" / f"{platform_name}.yml").is_file(): + if (siteinputs.current().emulators / f"{platform_name}.yml").is_file(): return f"{kind} for {display}" return f"{display} {kind}" @@ -780,7 +779,7 @@ def _pack_buttons(platform_name: str, label: str = "Pack", prefix: str = "../", frontend user asks. A setup with no release pack gets the line that says so, and why.""" if not has_release_pack(platform_name): - display = display or load_platform_config(platform_name, "platforms").get( + display = display or load_platform_config(platform_name, str(siteinputs.current().platforms)).get( "platform", platform_name) return f"{no_zip_line(display)} [Why no ZIP?]({prefix}{NO_ZIP_FAQ})" record = release_record.load_advertised() @@ -805,7 +804,7 @@ def generate_get_page(platform_name: str) -> str: the files of the pack the current release holds, read from release.json, or the command when no release publishes one. The release process never touches it: committing release.json rebuilds it.""" - config = load_platform_config(platform_name, "platforms") + config = load_platform_config(platform_name, str(siteinputs.current().platforms)) display = config.get("platform", platform_name) record = release_record.load_advertised() pack = release_record.pack_for(platform_name, record) @@ -824,7 +823,7 @@ def generate_get_page(platform_name: str) -> str: if released and pack and pack.get("assets"): parts = len(pack["assets"]) release = release_line(platform_name, record) - extract_to = (load_platform_registry("platforms").get(platform_name) or {}).get("extract_to") + extract_to = (load_platform_registry(str(siteinputs.current().platforms)).get(platform_name) or {}).get("extract_to") lines += [ "This address stays the same. It always gives the latest ZIP.", "", @@ -873,7 +872,7 @@ def generate_get_page(platform_name: str) -> str: def generate_get_index() -> str: """get/: the permanent page of every setup, grouped as the home tiles are. A link trimmed to /get/ answered 404.""" - registry = load_platform_registry("platforms") + registry = load_platform_registry(str(siteinputs.current().platforms)) lines = [ "---", "search: exclude", 'description: "The BIOS files of every setup, one page each. Each address stays the same."', @@ -887,7 +886,7 @@ def generate_get_index() -> str: continue lines += [f"## {label}", ""] for name in names: - display = load_platform_config(name, "platforms").get("platform", name) + display = load_platform_config(name, str(siteinputs.current().platforms)).get("platform", name) lines.append(f"- [{get_page_title(name, display)}]({name}.md)") lines.append("") return "\n".join(lines) @@ -1068,10 +1067,13 @@ def _split_contract(config: dict) -> tuple[dict, dict]: return contract, registry -@functools.lru_cache(maxsize=1) def _release_pack_platforms() -> frozenset[str]: - return frozenset(list_release_pack_platforms( - str(_REPO_ROOT / "platforms"), include_archived=True)) + return _release_packs_in(str(siteinputs.current().platforms)) + + +@functools.lru_cache(maxsize=None) +def _release_packs_in(platforms_dir: str) -> frozenset[str]: + return frozenset(list_release_pack_platforms(platforms_dir, include_archived=True)) # Per-entry fields a folder check needs beyond the hashes: an md5 is a ZIP @@ -2105,7 +2107,7 @@ def _short_sha256(item: dict, catalog: str) -> str: def _platforms_without_a_list(platforms_dir: Path | None = None) -> list[str]: """The names of the platforms whose registry says they keep no list.""" - platforms_dir = platforms_dir or _REPO_ROOT / "platforms" + platforms_dir = platforms_dir or siteinputs.current().platforms names = [] for key, entry in sorted(load_platform_registry(str(platforms_dir)).items()): if entry.get("native_list") != "none": @@ -3538,7 +3540,7 @@ def _system_named_link(sys_id: str, prefix: str = "") -> str: def _emulator_named_link(key: str, prefix: str = "") -> str: """An emulator by the name its page carries, linked to that page. A profile with no page (an alias, a test) is named only.""" - profile = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False).get(key, {}) + profile = _profiles_on_disk().get(key, {}) label = _escape_tags(str(profile.get("emulator") or key)) if profile.get("type") in ("alias", "test"): return label @@ -3928,10 +3930,9 @@ def _platform_data_links(name: str) -> str: return ", ".join(links) -@functools.lru_cache(maxsize=1) def _site_profiles() -> dict[str, dict]: """Every emulator profile, read once for the citations of a page.""" - return load_emulator_profiles(str(_REPO_ROOT / "emulators")) + return _profiles_in(str(siteinputs.current().emulators), skip_aliases=True) # Whose code a source reference cites, in one word: a platform page names @@ -4015,13 +4016,17 @@ def _category_evidence_md(evidence: dict, launched: list[str] | None = None) -> return f"No reader found ({cores})" -@functools.lru_cache(maxsize=1) def _profiles_by_core_name() -> dict[str, list[str]]: """Core name as a platform writes it -> the profiles with a page for it. MinUI writes `mednafen_vb`, the profile and its page are `beetle_vb`. """ - profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators")) + return _core_names_in(str(siteinputs.current().emulators)) + + +@functools.lru_cache(maxsize=None) +def _core_names_in(emulators_dir: str) -> dict[str, list[str]]: + profiles = _profiles_in(emulators_dir, skip_aliases=True) with_page = {k for k, v in profiles.items() if v.get("type") not in ("alias", "test")} return { core: sorted(k for k in keys if k in with_page) @@ -4191,7 +4196,7 @@ def _card_route(name: str, display: str, guide: Guide) -> list[str]: sees it is the reader's to name: no guide cites it.""" if guide.on_card or _offline_line(name): return [] - extract_to = (load_platform_registry("platforms").get(name) or {}).get("extract_to") + extract_to = (load_platform_registry(str(siteinputs.current().platforms)).get(name) or {}).get("extract_to") leaf = (extract_to or "bios").rstrip("/").rsplit("/", 1)[-1].lstrip("~") or "bios" tabs = guide.card_run or ("windows", "macos", "linux") args = {key: f"--platform {name} --dest " @@ -4392,7 +4397,7 @@ def generate_handheld_guide(guide: HandheldGuide) -> str: A handheld owner starts from the device name. The systems were named only in a closed note, with a path seen from inside the device.""" uses = GUIDES[guide.uses] - display = load_platform_config(guide.uses, "platforms").get("platform", guide.uses) + display = load_platform_config(guide.uses, str(siteinputs.current().platforms)).get("platform", guide.uses) args = {"windows": f"--platform {guide.uses} --dest E:\\BIOS", "macos": f"--platform {guide.uses} --dest /path/to/card/BIOS", "linux": f"--platform {guide.uses} --dest /path/to/card/BIOS"} @@ -4447,7 +4452,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: lines.append("") for note in guide.notes: lines += [note, ""] - lines += [line for note in extract_notes("platforms", released_only=True) + lines += [line for note in extract_notes(str(siteinputs.current().platforms), released_only=True) if f"The {display} pack " in note for line in (note, "")] if guide.more_folders: @@ -4922,7 +4927,7 @@ def _data_member_digests( from hashing import compute_hashes # noqa: PLC0415 if data_registry is None: - data_registry = load_data_dir_registry(str(root / "platforms")) + data_registry = load_data_dir_registry(str(siteinputs.current().platforms)) found: dict[str, dict[str, str]] = {} for ref, tree in sorted(members.items()): cache = (data_registry.get(ref) or {}).get("local_cache") @@ -5810,7 +5815,7 @@ def _platform_display(name: str) -> str: display = _platform_names.get(name) if display is None: try: - config = load_platform_config(name, str(_REPO_ROOT / "platforms")) + config = load_platform_config(name, str(siteinputs.current().platforms)) except FileNotFoundError: config = {} display = _platform_names[name] = str(config.get("platform") or name) @@ -6607,11 +6612,13 @@ def _reading_pairs(sha1: str, consumers: FileConsumers) -> list[tuple[str, dict] return pairs -@functools.lru_cache(maxsize=1) def _profiles_on_disk() -> dict[str, dict]: - from common import load_emulator_profiles # noqa: PLC0415 + return _profiles_in(str(siteinputs.current().emulators), skip_aliases=False) - return load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False) + +@functools.lru_cache(maxsize=None) +def _profiles_in(emulators_dir: str, skip_aliases: bool) -> dict[str, dict]: + return load_emulator_profiles(emulators_dir, skip_aliases=skip_aliases) def _entry_systems(emulator: str, entry: dict) -> list[str]: @@ -8931,10 +8938,14 @@ def emulator_install_route(name: str, profile: dict) -> tuple[str, list[str], tu return command, setups, oses -@functools.cache def _standalone_cores(platform: str) -> frozenset[str]: """The emulators a setup launches as apps, by its platform file.""" - config = load_platform_config(platform, str(_REPO_ROOT / "platforms")) + return _standalone_cores_in(str(siteinputs.current().platforms), platform) + + +@functools.cache +def _standalone_cores_in(platforms_dir: str, platform: str) -> frozenset[str]: + config = load_platform_config(platform, platforms_dir) return frozenset(str(c) for c in config.get("standalone_cores") or []) @@ -11787,7 +11798,7 @@ def _own_root_folders(registry: dict) -> dict[str, str]: destination carries its root in every path.""" folders = {} for key, entry in registry.items(): - config = load_platform_config(key, "platforms") + config = load_platform_config(key, str(siteinputs.current().platforms)) if config.get("base_destination") == "" and entry.get("extract_to"): folders[key] = entry["extract_to"] return folders @@ -11887,9 +11898,10 @@ def generate_which_pack() -> str: The tiles are the choice. Open tables under them listed every setup a second time and made the page 11,800 px tall on a phone.""" - registry = load_platform_registry("platforms") + registry = load_platform_registry(str(siteinputs.current().platforms)) record = release_record.load_advertised() - display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry} + display = {key: load_platform_config(key, str(siteinputs.current().platforms)).get("platform", key) + for key in registry} all_tabs = ("windows", "macos", "linux", "steamdeck", "android") lines = [ "---", diff --git a/scripts/sitediagrams.py b/scripts/sitediagrams.py index 285d2bdea..2171bb371 100644 --- a/scripts/sitediagrams.py +++ b/scripts/sitediagrams.py @@ -36,6 +36,7 @@ from xml.sax.saxutils import escape ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) +import siteinputs # noqa: E402 import sitemarkup as markup # noqa: E402 PLACEHOLDER = re.compile(r"(?m)^([ \t]*)[ \t]*$") @@ -314,21 +315,29 @@ def svg(figure: Figure, layout: str, suffix: str) -> str: # Data -@lru_cache(maxsize=None) def registry() -> dict: + return _registry_in(str(siteinputs.current().platforms)) + + +@lru_cache(maxsize=None) +def _registry_in(platforms_dir: str) -> dict: import yaml - with open(ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle: + with open(Path(platforms_dir) / "_registry.yml", encoding="utf-8") as handle: return yaml.safe_load(handle).get("platforms", {}) -@lru_cache(maxsize=None) def setups() -> tuple[str, ...]: """The registered setups, archived ones included: each has its own page, pack and file list, so every count of setups on the site is this one.""" + return _setups_in(str(siteinputs.current().platforms)) + + +@lru_cache(maxsize=None) +def _setups_in(platforms_dir: str) -> tuple[str, ...]: from common import list_registered_platforms - return tuple(sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True))) + return tuple(sorted(list_registered_platforms(platforms_dir, include_archived=True))) def setups_named(named: list[str]) -> str: @@ -339,15 +348,19 @@ def setups_named(named: list[str]) -> str: return ", ".join(named) + (f" and {others} more" if others else "") -@lru_cache(maxsize=None) def platform_modes() -> dict[str, tuple[str, str, str | None]]: """name -> (display name, verification mode, hash rule) for every platform.""" + return _platform_modes_in(str(siteinputs.current().platforms)) + + +@lru_cache(maxsize=None) +def _platform_modes_in(platforms_dir: str) -> dict[str, tuple[str, str, str | None]]: from common import load_platform_config out: dict[str, tuple[str, str, str | None]] = {} - for name in setups(): - config = load_platform_config(name, str(ROOT / "platforms")) - rule = registry().get(name, {}).get("hash_rule") or config.get("hash_rule") + for name in _setups_in(platforms_dir): + config = load_platform_config(name, platforms_dir) + rule = _registry_in(platforms_dir).get(name, {}).get("hash_rule") or config.get("hash_rule") out[name] = (config.get("platform", name), config.get("verification_mode", ""), rule) return out @@ -365,13 +378,17 @@ def _folder(name: str) -> str: CHAIN_EXAMPLES = (("batocera", "duckstation"), ("emudeck", "pcsx2"), ("retroarch", "beetle_psx")) -@lru_cache(maxsize=None) def emulator_names() -> dict[str, str]: + return _emulator_names_in(str(siteinputs.current().emulators)) + + +@lru_cache(maxsize=None) +def _emulator_names_in(emulators_dir: str) -> dict[str, str]: import yaml names: dict[str, str] = {} for _, emulator in CHAIN_EXAMPLES: - with open(ROOT / "emulators" / f"{emulator}.yml", encoding="utf-8") as handle: + with open(Path(emulators_dir) / f"{emulator}.yml", encoding="utf-8") as handle: label = str(yaml.safe_load(handle).get("emulator", emulator)) names[emulator] = re.sub(r"\s*\(.*\)$", "", label) return names diff --git a/scripts/siterender.py b/scripts/siterender.py index 9f19a9d02..bca6e60b5 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -7,6 +7,7 @@ import urllib.parse from collections.abc import Callable from pathlib import Path +import siteinputs import sitemarkup as markup @@ -401,7 +402,8 @@ def _build_boards() -> None: from common import load_emulator_profiles - database = _REPO_ROOT / "database.json" + inputs = siteinputs.current() + database = inputs.db if not database.exists(): _archive_boards.clear() _board_names.clear() @@ -409,7 +411,7 @@ def _build_boards() -> None: return with open(database, encoding="utf-8") as handle: db = json.load(handle) - profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False) + profiles = load_emulator_profiles(str(inputs.emulators), skip_aliases=False) _arcade_source[0] = (profiles, db) profiles, db = _arcade_source[0] files = db.get("files", {}) @@ -780,9 +782,10 @@ def _ensure_console_sections() -> None: from common import load_emulator_profiles from generate_site import _group_by_manufacturer - with open(_REPO_ROOT / "database.json", encoding="utf-8") as handle: + inputs = siteinputs.current() + with open(inputs.db, encoding="utf-8") as handle: db = json.load(handle) - profiles = load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False) + profiles = load_emulator_profiles(str(inputs.emulators), skip_aliases=False) set_console_sections(_group_by_manufacturer(db), program_names(profiles)) @@ -1431,7 +1434,7 @@ def system_search_aliases(sys_id: str) -> list[str]: platform_system_names, ) - platforms_dir = str(_REPO_ROOT / "platforms") + platforms_dir = str(siteinputs.current().platforms) index: dict[str, set[str]] = {} for platform in list_registered_platforms(platforms_dir, include_archived=True): config = load_platform_config(platform, platforms_dir) diff --git a/tests/test_site_inputs.py b/tests/test_site_inputs.py index ab75d4be7..57ee16fa4 100644 --- a/tests/test_site_inputs.py +++ b/tests/test_site_inputs.py @@ -1,14 +1,17 @@ -"""generate_site --db reaches every page. +"""generate_site --db, --emulators-dir and --platforms-dir reach every page. The run loaded the given database, but the page-finishing pass reloaded the repository's database.json: a card held only by the given collection stopped -the whole run on a KeyError. +the whole run on a KeyError. The emulator names and engine sections came from +./emulators, and has_release_pack read `platforms` relative to the working +directory, so a get/ page said "No ZIP" for a setup the API marked as released. """ from __future__ import annotations import ast import json +import os import shutil import sys import tempfile @@ -19,6 +22,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT / "scripts")) import generate_site # noqa: E402 +import sitediagrams # noqa: E402 import siteinputs # noqa: E402 SHA1 = "0123456789ab" + "c" * 28 @@ -47,7 +51,65 @@ class TheGivenDatabase(_Inputs): self.assertEqual(generate_site._collection_sha1_by_path(), {"bios/X/only-here.bin": SHA1}) -class GenerateInstallsTheInputs(unittest.TestCase): +class TheGivenProfilesAndPlatforms(_Inputs): + def setUp(self) -> None: + tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp" if (REPO_ROOT / "tmp").is_dir() else None)) + self.addCleanup(shutil.rmtree, tmp) + self.emulators = tmp / "emulators" + self.emulators.mkdir() + (self.emulators / "fixturecore.yml").write_text( + "emulator: Fixture Core\ntype: libretro\nsystems: [fixture-system]\nfiles: []\n") + self.platforms = tmp / "platforms" + shutil.copytree(REPO_ROOT / "platforms", self.platforms) + + def test_emulator_names_come_from_the_given_profiles(self) -> None: + self.use(emulators=self.emulators) + self.assertEqual(set(generate_site._profiles_on_disk()), {"fixturecore"}) + self.assertEqual(generate_site._emulator_named_link("fixturecore"), + "[Fixture Core](emulators/fixturecore.md)") + + def test_release_pack_does_not_depend_on_the_working_directory(self) -> None: + released = generate_site._release_pack_platforms() + self.assertIn("retroarch", released) + here = os.getcwd() + os.chdir(REPO_ROOT / "scripts") + self.addCleanup(os.chdir, here) + self.assertTrue(generate_site.has_release_pack("retroarch")) + self.assertEqual(generate_site.get_page_title("retroarch", "RetroArch"), "RetroArch BIOS pack") + + def test_the_registry_comes_from_the_given_platforms(self) -> None: + registry = self.platforms / "_registry.yml" + registry.write_text(registry.read_text().replace("release_pack: true", "release_pack: false")) + self.use(platforms=self.platforms) + self.assertFalse(generate_site.has_release_pack("retroarch")) + self.assertEqual(sitediagrams.registry()["retroarch"].get("release_pack"), False) + + +class NoPageModuleReadsTheRepositoryInputs(unittest.TestCase): + """A helper that opens the repository's database, profiles or platform + files itself bypasses the flags. siteinputs.current() is the one source.""" + + MODULES = ("generate_site.py", "siterender.py", "sitediagrams.py") + INPUTS = {"database.json", "emulators", "platforms"} + + def test_no_literal_input_path(self) -> None: + found = [] + for module in self.MODULES: + tree = ast.parse((REPO_ROOT / "scripts" / module).read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div) \ + and getattr(node.left, "id", "") in ("_REPO_ROOT", "ROOT") \ + and isinstance(node.right, ast.Constant) and node.right.value in self.INPUTS: + found.append(f"{module}:{node.lineno}") + if isinstance(node, ast.Call) and getattr(node.func, "id", getattr(node.func, "attr", "")) in ( + "load_platform_config", "load_platform_registry", "load_emulator_profiles", + "list_registered_platforms", "load_database", "extract_notes", + "load_data_dir_registry"): + for arg in node.args: + if isinstance(arg, ast.Constant) and arg.value in self.INPUTS: + found.append(f"{module}:{node.lineno}") + self.assertEqual(found, []) + def test_generate_installs_the_inputs_first(self) -> None: source = ast.get_source_segment( (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8"), diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 3ef512d15..3a106e55c 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -65,7 +65,7 @@ Every module in `tests/`, by the area it guards. | Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` | | Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` | | Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` | ## Test architecture From 6a79281e07729d3550f9d706d197920f63ec32f2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:14:45 +0100 Subject: [PATCH 03/22] chore: read every site module in the text guards Three guards named generate_site.py or a fixed list of four modules, while page text now lives in twelve site modules and packreadme.py. The list is a glob. Read that way, the pack README said "all of theirs" twice. --- scripts/packreadme.py | 6 +++--- tests/sitepages.py | 9 +++++++++ tests/test_site_render.py | 9 +++++---- tests/test_site_validation.py | 18 +++++++++++++----- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/scripts/packreadme.py b/scripts/packreadme.py index 06ee483a3..ee5e5e26f 100644 --- a/scripts/packreadme.py +++ b/scripts/packreadme.py @@ -300,8 +300,8 @@ def _build_readme( if fallback_systems: listed = "\n".join(f" {_group_label(s)}" for s in fallback_systems) region_info += ( - "\n These systems have no BIOS in those regions, so all\n" - " of theirs were kept:\n" + "\n These systems have no BIOS in those regions, so every\n" + " BIOS file for them was kept:\n" f"{listed}\n" ) region_info += ( @@ -316,7 +316,7 @@ def _build_readme( slot_info = ( " Where a core declares which BIOS it prefers, only that one was\n" " kept for each system and region. Systems whose cores declare no\n" - " order keep all of theirs.\n" + " order keep every BIOS file.\n" ) if undecidable_slots: slot_info += ( diff --git a/tests/sitepages.py b/tests/sitepages.py index ee8897a55..f11300dd8 100644 --- a/tests/sitepages.py +++ b/tests/sitepages.py @@ -44,3 +44,12 @@ def pages() -> list[Path]: def name(path: Path) -> str: """A page file's docs path, the name tests use for it.""" return path.relative_to(CONTENT).with_suffix(".md").as_posix() + + +def generator_sources() -> list[Path]: + """Every module whose strings reach a page: generate_site.py, each + scripts/site*.py and the pack README writer. Listed by glob, so text + moved out of generate_site.py stays under the guards that read it.""" + scripts = REPO_ROOT / "scripts" + return sorted({*scripts.glob("site*.py"), scripts / "generate_site.py", + scripts / "packreadme.py"}) diff --git a/tests/test_site_render.py b/tests/test_site_render.py index f4b11a4ac..9628bd5ed 100644 --- a/tests/test_site_render.py +++ b/tests/test_site_render.py @@ -50,15 +50,16 @@ class EveryPageAddressesPlatformsAsPeers(unittest.TestCase): text every site generator writes, docstrings and bare identifiers aside.""" WORDS = ("their file", "theirs", "of ours", "corrected", "left out", "drift") - SOURCES = ("generate_site.py", "siterender.py", "sitesources.py", "siteexports.py") - def test_no_page_text_judges_another_project(self): import ast import re + from tests.sitepages import generator_sources + found = [] - for name in self.SOURCES: - tree = ast.parse((REPO_ROOT / "scripts" / name).read_text(encoding="utf-8")) + for source in generator_sources(): + name = source.name + tree = ast.parse(source.read_text(encoding="utf-8")) docstrings = { id(node.body[0].value) for node in ast.walk(tree) if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py index feac2d14a..e7ec40fb4 100644 --- a/tests/test_site_validation.py +++ b/tests/test_site_validation.py @@ -349,9 +349,13 @@ class TheSitemapIsAnnouncedWhereCrawlersLook(unittest.TestCase): config = (root / "website" / "astro.config.mjs").read_text() self.assertIn("site: SITE", config) self.assertIn("sitemap.xml", config) - source = (root / "scripts" / "generate_site.py").read_text() - self.assertNotIn('"robots.txt"),\n "User-agent', source) - self.assertNotIn("Sitemap: https://", source) + from tests.sitepages import generator_sources + + for path in generator_sources(): + source = path.read_text(encoding="utf-8") + with self.subTest(module=path.name): + self.assertNotIn('"robots.txt"),\n "User-agent', source) + self.assertNotIn("Sitemap: https://", source) @@ -409,8 +413,12 @@ class PlainHouseStyle(unittest.TestCase): def test_headings_do_not_repeat_the_site_name(self): # The header bar already names the site, and the browser title gets # it from the theme: "# DuckStation - RetroBIOS" said it twice. - source = (ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8") - self.assertNotRegex(source, r'"# [^"]* - (?:\{SITE_NAME\}|RetroBIOS)') + from tests.sitepages import generator_sources + + for path in generator_sources(): + with self.subTest(module=path.name): + self.assertNotRegex(path.read_text(encoding="utf-8"), + r'"# [^"]* - (?:\{SITE_NAME\}|RetroBIOS)') for page in sorted((ROOT / "wiki").glob("*.md")): first = page.read_text(encoding="utf-8").splitlines()[0] with self.subTest(page=page.name): From 2f721bc640d3ea7425c4dffd4d0e697dea379f17 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:16:44 +0100 Subject: [PATCH 04/22] fix: list unsourceable files as not collected yet Every card read "Not collectable" and the gaps page listed 259 entries as acknowledged gaps verified from source code. unsourceable: only keeps a same-named file from being served in the entry's place, and each entry stays an acquisition target (wiki/profiling.md). The section now names the catalog or romset that lists each file, from the provenance report built with the profiles' needs: XM7's KANJI2.ROM is in No-Intro and in MAME's fm7740sx.zip. The old anchor still leads to the section. --- scripts/generate_site.py | 74 +++++++++++++++++++++++---------- tests/test_audit_regressions.py | 28 ++++++++++++- 2 files changed, 79 insertions(+), 23 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 62f400543..0eece3cfc 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -86,7 +86,7 @@ from generate_readme import ( extract_targets, release_totals, ) -from provenance_report import build_report, distinct_missing +from provenance_report import build_report, distinct_missing, profile_needs import sitemarkup as markup import siteentries import siteinputs @@ -8433,12 +8433,7 @@ def _render_emulator_file( if fnote: lines.append(f"- {data_root_words(_escape_tags(link_prose_citations(profile, str(fnote))), data_href)}") if not in_repo and f.get("unsourceable"): - # The badge alone sent readers after a dump that cannot exist, and the - # reason was only on the gaps page, which the card did not link. - lines.append( - f"- Not collectable: {_escape_tags(str(f['unsourceable']))} " - "([acknowledged gaps](../gaps.md#acknowledged-gaps))" - ) + lines.append(f"- {_unsourceable_line(f)}") lines.extend(_contents_lines(contents)) lines[body_start:] = _search_skipped_apart(lines[body_start:]) lines.append("") @@ -8550,12 +8545,7 @@ def _emulator_card_folds( open_lines.append(data_root_words( _escape_tags(link_prose_citations(profile, str(fnote))), data_href)) if not in_repo and f.get("unsourceable"): - # The badge alone sent readers after a dump that cannot exist, and the - # reason was only on the gaps page, which the card did not link. - open_lines.append( - f"Not collectable: {_escape_tags(str(f['unsourceable']))} " - "([acknowledged gaps](../gaps.md#acknowledged-gaps))" - ) + open_lines.append(_unsourceable_line(f)) lines = [f"- {d}" for d in open_lines] shut_fold = [f"- {d}" for d in hashes + [m for m in more if skipped(m)]] read_fold = [f"- {m}" for m in more if not skipped(m)] @@ -10459,6 +10449,33 @@ def _hash_cell(hashes: dict[str, list[str]]) -> str: ) +def _unsourceable_line(f: dict) -> str: + """Why the collection holds no copy of a file a profile marks + `unsourceable:`, linked to the list of them. The flag only keeps a + same-named file from being served in its place: the file is still + wanted, and "Not collectable" sent readers away from KANJI2.ROM, which + MAME's fm7740sx.zip carries.""" + return (f"Not collected yet: {_escape_tags(str(f['unsourceable']))} " + "([files not collected yet](../gaps.md#not-collected-yet))") + + +def _catalogued_needs(report: dict | None) -> dict[tuple[str, str], list[str]]: + """(profile, file name casefolded) -> where a dump catalog or an + imported romset lists that file, from a provenance report built with + the profiles' needs.""" + out: dict[tuple[str, str], list[str]] = {} + for source, data in sorted((report or {}).items()): + label = _PROVENANCE_LABELS.get(source, source) + for entry in data.get("missing", []): + where = [f"{label} `{_escape_tags(str(entry.get('name', '')))}`"] + where += [f"{_escape_tags(str(hit['source']))} `{_escape_tags(str(hit['archive']))}`" + for hit in entry.get("romsets", [])] + for need in entry.get("needed_by", []): + held = out.setdefault((need["emulator"], str(need["name"]).casefold()), []) + held += [w for w in where if w not in held] + return out + + def _reason_cell(text: str) -> str: """A reason in a gaps table, in the class that keeps its column readable.""" return f'{text}' if text else "-" @@ -10753,9 +10770,14 @@ def generate_gap_analysis( registry: dict | None = None, gap_report: dict | None = None, moved: dict[str, str] | None = None, + catalogued: dict | None = None, ) -> str: """Generate a unified gap analysis page. + ``catalogued`` is a provenance report built with the profiles' needs: + a file the collection does not hold yet names the catalog or romset + that lists it. + With ``moved``, the list of files to source goes to pages of its own, keyed by stem: on the page it made 662 KB of HTML. @@ -11186,7 +11208,7 @@ def generate_gap_analysis( "", ]) - # ---- Section 4: Acknowledged gaps (unsourceable files) ---- + # ---- Section 4: files the collection does not hold yet (unsourceable) ---- all_unsourceable: list[dict] = [] for emu_name, data in sorted(report_all.items()): @@ -11200,19 +11222,26 @@ def generate_gap_analysis( }) if all_unsourceable: + listed = _catalogued_needs(catalogued) + # The old heading's address still leads here. lines.extend([ - "## Acknowledged gaps", + '', "", - f"{len(all_unsourceable)} files documented as unsourceable " - "(verified from source code).", + "## Not collected yet", "", - "| File | Emulator | Reason | Source ref |", - "|------|----------|--------|-----------|", + f"{len(all_unsourceable)} files the collection does not hold. A file " + "with the same name is never served in their place. Each is still " + "wanted, and the last column names a catalog or romset that lists it.", + "", + "| File | Emulator | Reason | Source ref | Listed in |", + "|------|----------|--------|-----------|-----------|", ]) for u in sorted(all_unsourceable, key=lambda x: x["name"]): + where = listed.get((u["emu_key"], str(u["name"]).casefold()), []) lines.append( f"| `{u['name']}` | {u['emulator']} | {_reason_cell(u['reason'])} " - f"| {_source_ref_markdown(profiles[u['emu_key']], u['source_ref'])} |" + f"| {_source_ref_markdown(profiles[u['emu_key']], u['source_ref'])} " + f"| {', '.join(where) or '-'} |" ) lines.append("") @@ -12563,12 +12592,16 @@ def _generate(args: argparse.Namespace) -> None: sitestarlight.MOVED_PAGES.setdefault( f"cross-reference-{pname}.md", f"platforms/{pname}.md") + provenance_report = build_report( + db, load_provenance_snapshots(), needs=profile_needs(profiles, db)) + print("Generating gap analysis page...") gap_pages: dict[str, str] = {} write_if_changed( str(docs / "gaps.md"), tables_out_of_search(generate_gap_analysis( profiles, coverages, db, suppl_names, registry, gap_report, gap_pages, + catalogued=provenance_report, )) + reference_next(), ) for stem, page in gap_pages.items(): @@ -12584,7 +12617,6 @@ def _generate(args: argparse.Namespace) -> None: ) print("Generating dump provenance page...") - provenance_report = build_report(db, load_provenance_snapshots()) provenance_pages: dict[str, str] = {} write_if_changed( str(docs / "provenance.md"), diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 3df8850fe..91110a2d5 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -5143,8 +5143,32 @@ class UnsourceableCardSaysWhy(unittest.TestCase): text = "\n".join(_render_emulator_file( entry, {"files": [entry]}, None, [entry], lambda f: False, "file-x", )) - self.assertIn("Not collectable: written by the firmware editor", text) - self.assertIn("../gaps.md#acknowledged-gaps", text) + self.assertIn("Not collected yet: written by the firmware editor", text) + self.assertIn("../gaps.md#not-collected-yet", text) + self.assertNotIn("collectable", text) + + def test_the_gaps_page_names_where_a_wanted_file_is_listed(self): + """The section read "Acknowledged gaps ... (verified from source + code)" while No-Intro lists XM7's KANJI2.ROM and MAME's fm7740sx.zip + carries it: the flag only keeps a homonym out, the file is wanted.""" + from scripts.generate_site import generate_gap_analysis + + unsourced = {"name": "KANJI2.ROM", "reason": "no dump distributed", "source_ref": ""} + report = {"xm7": {"emulator": "XM7", "gaps": 0, "gap_missing": 0, "gap_bios": 0, + "unsourceable": [unsourced], "gap_details": []}} + catalogued = {"no-intro": {"missing": [{ + "name": "KANJI2.rom", + "romsets": [{"source": "mame", "archive": "fm7740sx.zip"}], + "needed_by": [{"emulator": "xm7", "name": "KANJI2.ROM", "required": True}], + }]}} + page = generate_gap_analysis( + {"xm7": {"emulator": "XM7", "files": []}}, {}, {"files": {}, "indexes": {}}, + gap_report=report, catalogued=catalogued) + self.assertIn("## Not collected yet", page) + self.assertIn('', page) + self.assertIn("| No-Intro `KANJI2.rom`, mame `fm7740sx.zip` |", page) + self.assertNotIn("Acknowledged", page) + self.assertNotIn("verified from source code", page) From 2a0088a0fc03bbe598702aba08da45784a0456c9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:17:11 +0100 Subject: [PATCH 05/22] docs: name the launcher index in installer changes install/launchers/index.json records the SHA-256 of install.py that --add-app checks, and the page listed only the pins of install.sh and install.ps1 as what a change to install.py rewrites. --- tests/test_port.py | 7 +++++++ wiki/installer.md | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_port.py b/tests/test_port.py index 269d4023e..5c873e84b 100644 --- a/tests/test_port.py +++ b/tests/test_port.py @@ -124,6 +124,13 @@ class LauncherIndex(unittest.TestCase): "install/launchers/index.json is stale: run " "python scripts/generate_launcher_index.py") + def test_the_installer_page_says_when_to_rewrite_it(self): + """The list records install.py's SHA-256, and the page that says + what a change to install.py rewrites named only the two pins.""" + page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8") + section = page.split("## Changing the installer", 1)[1].split("\n## ", 1)[0] + self.assertIn("scripts/generate_launcher_index.py", section) + def test_every_member_of_the_archives_is_listed(self): import generate_launcher_index # noqa: PLC0415 diff --git a/wiki/installer.md b/wiki/installer.md index 73719bf16..afcacf4ef 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1702,7 +1702,10 @@ a real 3.8 interpreter, which CI installs beside 3.12 so the check cannot skip. The build scripts need 3.9 and are not bound by this. Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and -`install.ps1` in the same commit. The tests compare the pins with the file. +`install.ps1` in the same commit, and runs `scripts/generate_launcher_index.py`: +`install/launchers/index.json` records the hash of `install.py` that +`--add-app` checks before it writes a launcher on a card. The tests compare +the pins and the list with the file. The tables of options, events, exit codes, keys and card markers, the line menu and the run samples on this page sit between `generated` markers and are From 4f930b5573c905880910bc360337460fee20f3b0 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:19:08 +0100 Subject: [PATCH 06/22] feat: find wanted dumps by hash on the files page files/?hash= read the collection, platforms, emulators and retired files, never the Wanted list: the SHA1 of Redump's ps2-dvd-212k.bin answered that nothing the site knows has it. The lookup now carries a wanted layer from the rows of provenance-wanted.csv, computed once for both, and the page names the catalog that lists the dump. --- schemas/site-api-envelope.schema.json | 6 +++-- scripts/generate_site.py | 27 ++++++++++++++++++--- tests/test_site_exports.py | 34 +++++++++++++++++++++++++++ website/src/scripts/site.ts | 17 +++++++++++++- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/schemas/site-api-envelope.schema.json b/schemas/site-api-envelope.schema.json index adbda67c5..4b9e765fd 100644 --- a/schemas/site-api-envelope.schema.json +++ b/schemas/site-api-envelope.schema.json @@ -393,7 +393,8 @@ "retired", "data-directory", "platform", - "emulator" + "emulator", + "wanted" ] }, "left": { @@ -433,7 +434,8 @@ "description": "On a platform or emulator record: the algorithm of the value its owner wrote, filed under that value and under the file's other hashes. name: the emulator reads the file by its name and declares no hash that matches it." }, "owner": { - "type": "string" + "type": "string", + "description": "On a platform or emulator record: the platform or profile key. On a wanted record: the dump catalog that lists the value (Redump, No-Intro, TOSEC), the collection holding no file with it." }, "system_name": { "type": "string", diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 0eece3cfc..53e2c875f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -1620,6 +1620,7 @@ def generate_data_exports( members = data_directory_members() locations = file_location_items(db, profiles, members) + wanted = _provenance_wanted_rows(db) envelopes = { "platforms.json": ("platforms", platform_items), "emulators.json": ("emulators", emulator_items), @@ -1628,7 +1629,7 @@ def generate_data_exports( } write_lookup_shards( api, file_lookup_records(db, coverages, profiles, locations, members, consumers, - retired_files.load(_REPO_ROOT / "retired.json")), + retired_files.load(_REPO_ROOT / "retired.json"), wanted), generated_at, commit, ) for filename, (kind, items) in envelopes.items(): @@ -1747,7 +1748,7 @@ def generate_data_exports( _record(downloads / "retrobios.sqlite") write_if_changed( str(downloads / "provenance-wanted.csv"), - _csv_document(list(WANTED_FIELDS), _provenance_wanted_rows(db)), + _csv_document(list(WANTED_FIELDS), wanted), ) assets = [ @@ -5010,12 +5011,14 @@ def file_lookup_records( members: dict | None = None, consumers: FileConsumers | None = None, retired: dict | None = None, + wanted: list[dict] | None = None, ) -> list[dict]: """Every hash the site knows, one record per value, for files/?hash=. The collection's own files lead to their card, a platform's declared value to the system section of its page, a profile's to the entry's - card. The site search indexed these values as text, 4 MB of the 21 MB + card, a wanted dump (``wanted``, the provenance-wanted.csv rows) to + its catalog on the provenance page. The site search indexed these values as text, 4 MB of the 21 MB it made every visitor download: the lookup reads one shard instead. """ files = db.get("files", {}) @@ -5088,6 +5091,7 @@ def file_lookup_records( **({"system_name": entry_label} if entry_label else {}), }) records += retired_records(retired or {}, files, locations) + records += wanted_records(wanted or []) records += _held_under_every_hash(records, files, db.get("indexes") or {}) for record in records: if record["layer"] in ("platform", "emulator"): @@ -5099,6 +5103,23 @@ def file_lookup_records( r["value"], r["layer"], r.get("owner", ""), r["name"], r["url"])) +def wanted_records(rows: list[dict]) -> list[dict]: + """A record under each hash of a dump a catalog lists and the collection + lacks, from the rows of provenance-wanted.csv. A pasted hash of a + wanted dump (Redump's ps2-dvd-212k.bin) answered that nothing the site + knows has it, and never said the dump is wanted.""" + records = [] + for row in rows: + label = _PROVENANCE_LABELS.get(row["catalog"], row["catalog"]) + url = _site_path(f"provenance.md#{_slugify_anchor(label)}") + for algorithm in ("sha1", "md5", "crc32"): + value = str(row.get(algorithm) or "").lower() + if value: + records.append({"value": value, "algorithm": algorithm, "layer": "wanted", + "owner": label, "name": row["name"], "url": url}) + return records + + def _read_by_name(records: list[dict], files: dict, consumers: FileConsumers) -> list[dict]: """The emulators the file's card names that no declared hash reached. diff --git a/tests/test_site_exports.py b/tests/test_site_exports.py index 1d96d3f0a..5455a422b 100644 --- a/tests/test_site_exports.py +++ b/tests/test_site_exports.py @@ -832,6 +832,40 @@ class AnArchiveMemberIsFoundByItsCrc(unittest.TestCase): self.assertIn("byLayer('archive-member')", script) +class AWantedDumpIsFoundByItsHash(unittest.TestCase): + """The SHA1 of a PS2 SCPH-30005R BIOS, which Redump lists as + ps2-dvd-212k.bin and the Wanted page asks for, answered that no file + the site knows has it.""" + + def test_each_hash_of_a_wanted_row_is_a_record(self): + import generate_site # noqa: PLC0415 + + row = {"catalog": "redump", "dat": "Sony - PlayStation 2 - BIOS Images", + "name": "ps2-dvd-212k.bin", "description": "", "size": "4194304", + "crc32": "1A2B3C4D", "md5": "", "sha1": "5b346eb9258d710609a9855befd35cab8f3a334a"} + records = generate_site.file_lookup_records( + {"files": {}, "indexes": {}}, {}, {}, [], wanted=[row, {**row, "dat": "other"}]) + self.assertEqual(records, [ + {"value": "1a2b3c4d", "algorithm": "crc32", "layer": "wanted", "owner": "Redump", + "name": "ps2-dvd-212k.bin", "url": "/retrobios/provenance/#redump"}, + {"value": "5b346eb9258d710609a9855befd35cab8f3a334a", "algorithm": "sha1", + "layer": "wanted", "owner": "Redump", "name": "ps2-dvd-212k.bin", + "url": "/retrobios/provenance/#redump"}, + ]) + + def test_the_schema_and_the_page_carry_the_layer(self): + import json as _json # noqa: PLC0415 + + import generate_site # noqa: PLC0415 + + root = Path(__file__).resolve().parents[1] + schema = _json.loads((root / "schemas" / "site-api-envelope.schema.json").read_text()) + self.assertIn('"wanted"', _json.dumps(schema)) + self.assertIn("byLayer('wanted')", (root / "website" / "src" / "scripts" / "site.ts").read_text()) + source = Path(generate_site.__file__).read_text(encoding="utf-8") + self.assertIn('retired_files.load(_REPO_ROOT / "retired.json"), wanted)', source) + + class AFileHasAnAddressByContent(unittest.TestCase): """mt32_control.rom moved from the Commodore page to the Roland page and every link to its card broke: the anchor was content-based, the page diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts index 09d20902f..263309d34 100644 --- a/website/src/scripts/site.ts +++ b/website/src/scripts/site.ts @@ -558,9 +558,12 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched: const setups = new Set(platforms.map((item) => item.owner)); const members = distinct(byLayer('archive-member'), (item) => `${item.sha1}|${item.name}`); + // A dump a catalog lists and the collection lacks: the Wanted list. + const wanted = held.length || members.length ? [] : distinct(byLayer('wanted'), (item) => `${item.owner}|${item.name}`); const counted = [ held.length ? count(held.length, 'file in the collection', 'files in the collection') : members.length ? count(members.length, 'member of a collection archive', 'members of collection archives') + : wanted.length ? 'a wanted dump, not in the collection yet' : 'not in the collection', ...(platforms.length ? [`${count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}`] : []), ...(owners.length ? [count(owners.length, 'emulator that reads it', 'emulators that read it')] : []), @@ -617,8 +620,20 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, matched: ); }); const retired = held.length ? [] : distinct(byLayer('retired'), (item) => item.sha1 ?? item.value); - if (!held.length && !members.length && !retired.length) blocks.push(paragraph('No file in the collection has this hash.')); + if (!held.length && !members.length && !retired.length && !wanted.length) blocks.push(paragraph('No file in the collection has this hash.')); blocks.push(...retired.map(retiredParagraph)); + if (wanted.length) { + const list = document.createElement('ul'); + for (const item of wanted) { + const row = document.createElement('li'); + row.append(link(item.url, item.owner ?? ''), ' lists it as ', code(item.name ?? item.value)); + list.append(row); + } + blocks.push( + paragraph(strong('Wanted.'), ' The collection does not hold this dump yet. If you dumped it yourself, ', link('contributing/', 'add it'), '.'), + list, + ); + } if (members.length) { // A ROM the collection holds inside a set: the set's card, by its name. const list = document.createElement('ul'); From d1535d0787a458caa31c902d7aeba3da15d9ed26 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:20:03 +0100 Subject: [PATCH 07/22] fix: name the file a variant download is saved as A .variants/ copy downloads as advision.zip.1c086064, its storage name: raw.githubusercontent.com sends no Content-Disposition and the download attribute is ignored across origins. A card whose download name differs from the file name now says what to save it as, at a glance. --- scripts/generate_site.py | 15 ++++++++++++++- tests/test_audit_regressions.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 53e2c875f..aaae2d7ad 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -5805,6 +5805,13 @@ def _wanted_list(source: str, label: str, data: dict, by_dat: dict[str, list], _RELEASE_ASSET_NAMES: dict[str, str] | None = None +def _download_name(path: str) -> str: + """The name a browser saves a collection file under: the last segment + of its download URL. Neither host sends a Content-Disposition, and the + download attribute is ignored on another origin.""" + return urllib.parse.unquote(_fetch_url(path).rsplit("/", 1)[-1]) + + def _fetch_url(path: str) -> str: """Where a collection file downloads from: its raw URL, or the large-files release asset when .gitignore keeps it out of git.""" @@ -6179,6 +6186,12 @@ def _system_file_card( # Each fact: its label, its values, whether the search reads it. What a # setup does with the file comes first, the fingerprints last. facts: list[tuple[str, list[str], bool]] = [] + saved = _download_name(f["path"]) if f.get("path") else name + if saved != name: + # A .variants/ copy is stored as advision.zip.1c086064, and the + # browser names the download after the URL: copied as it came, the + # file a platform pins was not found under its name. + facts.append(("Save as", [f"{_code(name)}, the download is named {_code(saved)}"], False)) if plats: # Each link lands on the platform's system section, and says where # that platform puts the file. Out of the search: the card's title @@ -6237,7 +6250,7 @@ def _system_file_card( # What a system card shows at a glance: who lists the file and who reads it. # Its fingerprints and the rest open on demand, so a page of 250 cards reads # as a list of files, not a wall of hashes. -_CARD_KEY_FACTS = ("On the setup's list", "Packed for its emulators by", "Emulators") +_CARD_KEY_FACTS = ("Save as", "On the setup's list", "Packed for its emulators by", "Emulators") _CARD_HASHES = ("SHA1", "MD5", "CRC32", "SHA256") # The words a card's fold names its other facts by. _CARD_MORE_WORDS = {"Romset": "romset", "Collection path": "collection path", diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 91110a2d5..577b0cfff 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -5207,6 +5207,23 @@ class CollectionCardsCarryFullHashes(unittest.TestCase): self.assertIn(f'
route per system.
---
tests/test_doc_pages.py | 17 +++++++++++++++++
wiki/tools.md | 4 ++--
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py
index c29301dde..bb0d0d3eb 100644
--- a/tests/test_doc_pages.py
+++ b/tests/test_doc_pages.py
@@ -1986,3 +1986,20 @@ class ArchivedIsWhatTheToolsDo(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+
+
+class TheCatalogRowsStateFacts(unittest.TestCase):
+ """tools.md said "redump.org serves stale DATs" and "Dat-o-Matic blocks
+ automation", verdicts on two preservation projects, and gave Redump's
+ /static/bios links, which the scraper no longer reads."""
+
+ def test_dates_and_routes_not_verdicts(self):
+ text = (WIKI / "tools.md").read_text(encoding="utf-8")
+ rows = [line for line in text.splitlines()
+ if line.startswith(("| Redump |", "| No-Intro |"))]
+ self.assertEqual(len(rows), 2)
+ for word in ("stale", "blocks"):
+ self.assertNotIn(word, " ".join(rows))
+ scraper = (ROOT / "scripts" / "scraper" / "redump_dat_scraper.py").read_text(encoding="utf-8")
+ self.assertIn("/bios/", scraper)
+ self.assertIn("`/bios/`", rows[0])
diff --git a/wiki/tools.md b/wiki/tools.md
index dab60e5b0..c7dfae1dc 100644
--- a/wiki/tools.md
+++ b/wiki/tools.md
@@ -1643,8 +1643,8 @@ names the profiles read.
| Catalog | Acquisition | Notes |
|---------|------------|-------|
-| Redump | direct fetch from redump.info `/static/bios` | 4 BIOS DATs. redump.org serves stale DATs |
-| No-Intro | local pack, imported with `dat_pack_importer` | Dat-o-Matic blocks automation, and the `hugo19941994/auto-datfile-generator` mirror rebuilds daily |
+| Redump | direct fetch from redump.info, one `/bios/` route per system | 4 BIOS DATs. redump.org serves BIOS DATs dated 2010 to 2022, redump.info the current ones |
+| No-Intro | local pack, imported with `dat_pack_importer` | Dat-o-Matic serves its DATs through a browser session, and the `hugo19941994/auto-datfile-generator` mirror rebuilds daily |
| TOSEC | local pack, imported with `dat_pack_importer` | annual pack from tosecdev.org, 138 Firmware DATs |
The join is by SHA1, then by MD5 + size. A match sets the `provenance` field
From da9e1f568cc0aa0cece1da6ee67e88536edfd153 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 08:27:32 +0100
Subject: [PATCH 15/22] fix: refuse an offline export with no originals
pipeline.py --offline --with-export, listed as a standard command, passes
no --fetch to step 2e: on a fresh clone the database, verify and truth
were built before the export failed on every platform whose own file it
patches. The run is refused before step 0 when .cache/upstream-native/
holds nothing for such a platform, and tools.md says how to fill it.
---
scripts/pipeline.py | 27 ++++++++++++++++++++++++++
tests/test_tool_messages.py | 38 +++++++++++++++++++++++++++++++++++++
wiki/advanced-usage.md | 3 ++-
wiki/tools.md | 7 +++++++
4 files changed, 74 insertions(+), 1 deletion(-)
diff --git a/scripts/pipeline.py b/scripts/pipeline.py
index 2c2f23ede..90976b050 100644
--- a/scripts/pipeline.py
+++ b/scripts/pipeline.py
@@ -883,6 +883,32 @@ def _refuse_offline_without_caches(
)
+def _refuse_offline_export_without_originals(
+ parser: argparse.ArgumentParser, args: argparse.Namespace,
+ cache: str = ".cache/upstream-native",
+) -> None:
+ """An offline run with --with-export needs each patched file cached.
+
+ Seven formats carry code, so step 2e patches the platform's own file,
+ which --offline does not fetch. On a fresh clone the database, verify
+ and the truth model were built before step 2e failed on every one.
+ """
+ if not (args.offline and args.with_export):
+ return
+ from exporter import discover_exporters # noqa: PLC0415
+
+ lacking = sorted(
+ name for name, exporter in discover_exporters().items()
+ if exporter.needs_original() and not any(Path(cache, name).glob("*"))
+ )
+ if lacking:
+ parser.error(
+ f"--with-export patches each platform's own file, and {cache}/ holds none "
+ f"for {', '.join(lacking)}: run python scripts/export_native.py --all "
+ "--refresh-cache once, or drop --offline"
+ )
+
+
def _truth_steps(args: argparse.Namespace, results: dict) -> bool:
"""Steps 2c to 2e: truth model, its diff, the native export."""
all_ok = True
@@ -1010,6 +1036,7 @@ def main() -> None:
args = parser.parse_args()
_refuse_unapplied_flags(parser, args)
_refuse_offline_without_caches(parser, args)
+ _refuse_offline_export_without_originals(parser, args)
if not args.offline:
# Read once and put in the environment every step inherits: the
# scrapers and refresh_data_dirs went anonymous unless the user had
diff --git a/tests/test_tool_messages.py b/tests/test_tool_messages.py
index 7f043a22b..3f1a8abbd 100644
--- a/tests/test_tool_messages.py
+++ b/tests/test_tool_messages.py
@@ -175,6 +175,44 @@ class AnOfflineRunWithNoCacheStopsFirst(unittest.TestCase):
parser.error.assert_not_called()
+class AnOfflineExportWithNoOriginalsStopsFirst(unittest.TestCase):
+ """pipeline.py --offline --with-export, which tools.md listed as a
+ standard command, built the database, verify and the truth model on a
+ fresh clone, then failed step 2e on every platform whose own file it
+ patches: --offline never passes --fetch."""
+
+ def _args(self, **flags):
+ import argparse
+
+ return argparse.Namespace(**{"offline": True, "with_export": True, **flags})
+
+ def test_refused_before_any_step(self):
+ from exporter import discover_exporters
+
+ patched = sorted(n for n, e in discover_exporters().items() if e.needs_original())
+ self.assertIn("batocera", patched)
+ with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
+ parser = mock.Mock()
+ pipeline._refuse_offline_export_without_originals(parser, self._args(), tmp)
+ message = parser.error.call_args.args[0]
+ self.assertIn("export_native.py --all --refresh-cache", message)
+ self.assertIn("batocera", message)
+ for args in (self._args(offline=False), self._args(with_export=False)):
+ parser = mock.Mock()
+ pipeline._refuse_offline_export_without_originals(parser, args, tmp)
+ parser.error.assert_not_called()
+ for name in patched:
+ (Path(tmp) / name).mkdir()
+ (Path(tmp) / name / "original").write_text("x")
+ parser = mock.Mock()
+ pipeline._refuse_offline_export_without_originals(parser, self._args(), tmp)
+ parser.error.assert_not_called()
+
+ def test_the_tools_page_says_so(self):
+ text = (REPO_ROOT / "wiki" / "tools.md").read_text(encoding="utf-8")
+ self.assertIn("export_native.py --all --refresh-cache", text)
+
+
class TheTruthDiffShowsWhereAndAsksNothing(unittest.TestCase):
"""The markdown wrote `**Remove** hiscore.dat (phantom)` beside six
`**Add** hiscore.dat`, none with a path, for files profiled cores read."""
diff --git a/wiki/advanced-usage.md b/wiki/advanced-usage.md
index c17cc07ac..d54969839 100644
--- a/wiki/advanced-usage.md
+++ b/wiki/advanced-usage.md
@@ -534,7 +534,8 @@ Include truth generation and diffing in the pipeline:
python scripts/pipeline.py --offline --with-truth
```
-Include truth + native format export:
+Include truth + native format export. Offline, the platforms' own files
+must already be cached ([tools](tools.md#pipeline)):
```bash
python scripts/pipeline.py --offline --with-export
diff --git a/wiki/tools.md b/wiki/tools.md
index c7dfae1dc..2c4b4290a 100644
--- a/wiki/tools.md
+++ b/wiki/tools.md
@@ -28,6 +28,13 @@ python scripts/pipeline.py --check-buildbot # online run + buildbot fresh
`python scripts/refresh_data_dirs.py` once first. A run that builds packs with no
data directory cached is refused before step 0.
+`--with-export` online passes `--fetch` to step 2e, which downloads each
+platform's own file and patches it. Offline that file must already be in
+`.cache/upstream-native/`: on a fresh clone run
+`python scripts/export_native.py --all --refresh-cache` once first. An offline
+run with `--with-export` and an empty cache for any platform it patches is
+refused before step 0.
+
Pipeline steps:
| Step | Description | Skipped by |
From f255efca0ffc726dfa371c342595b9ef524fb4db Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 08:27:59 +0100
Subject: [PATCH 16/22] docs: list pipeline flag refusals in one table
tools.md said --full-narrowings is refused with --skip-packs only, while
pipeline.py also refuses it with --source platform or truth, and
advanced-usage.md kept a second, shorter list. The tools table now
carries every pair, checked against _refuse_unapplied_flags, and
advanced-usage links to it.
---
tests/test_tool_messages.py | 39 +++++++++++++++++++++++++++++++++++++
wiki/advanced-usage.md | 2 +-
wiki/tools.md | 2 +-
3 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/tests/test_tool_messages.py b/tests/test_tool_messages.py
index 3f1a8abbd..1ba332604 100644
--- a/tests/test_tool_messages.py
+++ b/tests/test_tool_messages.py
@@ -213,6 +213,45 @@ class AnOfflineExportWithNoOriginalsStopsFirst(unittest.TestCase):
self.assertIn("export_native.py --all --refresh-cache", text)
+class ThePipelineFlagTableSaysEveryRefusal(unittest.TestCase):
+ """tools.md said --full-narrowings is refused with --skip-packs only, and
+ advanced-usage.md kept its own shorter list: `--source truth
+ --full-narrowings` failed with nothing on either page saying so."""
+
+ PAIRS = {"--source": "platform", "--all-variants": True, "--full-narrowings": True,
+ "--skip-packs": True}
+
+ def _refused(self, flags: dict) -> bool:
+ import argparse
+
+ args = argparse.Namespace(source=None, all_variants=False, full_narrowings=False,
+ skip_packs=False)
+ for flag, value in flags.items():
+ setattr(args, flag.lstrip("-").replace("-", "_"), value)
+ parser = mock.Mock()
+ pipeline._refuse_unapplied_flags(parser, args)
+ return parser.error.called
+
+ def test_each_refused_pair_is_in_the_row_of_a_flag(self):
+ import itertools
+
+ text = (REPO_ROOT / "wiki" / "tools.md").read_text(encoding="utf-8")
+ rows = {line.split("`")[1].split()[0]: line for line in text.splitlines()
+ if line.startswith("| `--")}
+ for (a, va), (b, vb) in itertools.combinations(self.PAIRS.items(), 2):
+ if not self._refused({a: va, b: vb}):
+ continue
+ with self.subTest(pair=(a, b)):
+ self.assertTrue(f"`{b}" in rows.get(a, "") or f"`{a}" in rows.get(b, ""),
+ f"{a} with {b} is refused, and neither row says so")
+ self.assertIn("`--source truth`", rows["--full-narrowings"])
+
+ def test_one_list_of_refusals(self):
+ text = (REPO_ROOT / "wiki" / "advanced-usage.md").read_text(encoding="utf-8")
+ self.assertNotIn("are refused with", text)
+ self.assertIn("tools.md#pipeline", text)
+
+
class TheTruthDiffShowsWhereAndAsksNothing(unittest.TestCase):
"""The markdown wrote `**Remove** hiscore.dat (phantom)` beside six
`**Add** hiscore.dat`, none with a path, for files profiled cores read."""
diff --git a/wiki/advanced-usage.md b/wiki/advanced-usage.md
index d54969839..9f230fe2e 100644
--- a/wiki/advanced-usage.md
+++ b/wiki/advanced-usage.md
@@ -543,7 +543,7 @@ python scripts/pipeline.py --offline --with-export
### Combining flags
-These flags compose (`--source` and `--all-variants` are refused with `--skip-packs`):
+These flags compose, except the pairs the [pipeline flags](tools.md#pipeline) table marks as refused:
```bash
python scripts/pipeline.py --offline --skip-packs --skip-docs --with-truth --target switch
diff --git a/wiki/tools.md b/wiki/tools.md
index 2c4b4290a..0e62fec66 100644
--- a/wiki/tools.md
+++ b/wiki/tools.md
@@ -76,7 +76,7 @@ Pipeline flags:
| `--target TARGET` | filter verify, packs and truth by hardware target |
| `--source {platform,truth,full}` | pack file source, passed through to `generate_pack.py`. Refused with `--skip-packs` |
| `--all-variants` | build the 6 source x required combinations. Refused with `--skip-packs` and with `--source` |
-| `--full-narrowings` | step 5 compares every region, target and system of each platform instead of three of each. Refused with `--skip-packs` |
+| `--full-narrowings` | step 5 compares every region, target and system of each platform instead of three of each. Refused with `--skip-packs` and with `--source platform` or `--source truth` |
| `--check-buildbot` | run step 2b. Additive, and ignored under `--offline` |
| `--with-truth` | run steps 2c and 2d |
| `--with-export` | run steps 2c, 2d and 2e |
From 328bfbe53b505a5227a71afc574805f6e629b09d Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 08:37:16 +0100
Subject: [PATCH 17/22] chore: run the catalog row test from its file
The class sat after unittest.main(), so running test_doc_pages.py on its
own skipped it.
---
tests/test_doc_pages.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py
index bb0d0d3eb..5e6eb2c96 100644
--- a/tests/test_doc_pages.py
+++ b/tests/test_doc_pages.py
@@ -1984,9 +1984,6 @@ class ArchivedIsWhatTheToolsDo(unittest.TestCase):
self.assertNotIn("list_platforms", workflow.read_text(encoding="utf-8"))
-if __name__ == "__main__":
- unittest.main()
-
class TheCatalogRowsStateFacts(unittest.TestCase):
"""tools.md said "redump.org serves stale DATs" and "Dat-o-Matic blocks
@@ -2003,3 +2000,8 @@ class TheCatalogRowsStateFacts(unittest.TestCase):
scraper = (ROOT / "scripts" / "scraper" / "redump_dat_scraper.py").read_text(encoding="utf-8")
self.assertIn("/bios/", scraper)
self.assertIn("`/bios/`", rows[0])
+
+
+if __name__ == "__main__":
+ unittest.main()
+
From 23d878c52673eb9080320b2596117cca0919cd82 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 08:37:19 +0100
Subject: [PATCH 18/22] chore: stub the export cache check in a test
test_a_targeted_export_reads_a_full_model runs pipeline.py --offline
--with-export, which is now refused when .cache/upstream-native/ is
empty, as in any fresh checkout. The test is about the truth directory
the export reads.
---
tests/test_audit_regressions.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 5263d0c54..6776263b8 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -1140,9 +1140,12 @@ class PipelineRegressions(unittest.TestCase):
argv = ["pipeline.py", "--offline", "--skip-packs", "--skip-docs",
"--with-export", "--target", "switch"]
+ # The originals the offline export patches are not cached in a
+ # checkout: the run would be refused before any step.
with mock.patch.object(sys, "argv", argv), mock.patch.object(
pipeline, "run", side_effect=fake_run
- ), contextlib.redirect_stdout(io.StringIO()), contextlib.suppress(SystemExit):
+ ), mock.patch.object(pipeline, "_refuse_offline_export_without_originals"), \
+ contextlib.redirect_stdout(io.StringIO()), contextlib.suppress(SystemExit):
pipeline.main()
truth_runs = [c for c in commands if "scripts/generate_truth.py" in c]
export = next(c for c in commands if "scripts/export_native.py" in c)
From ef6607302d333d2e3b98592062d2fd6bbc21a59c Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 09:11:02 +0100
Subject: [PATCH 19/22] chore: rebuild the site when siteinputs changes
Every page module reads its inputs from scripts/siteinputs.py, so the
deploy workflow names it with the other site modules.
---
.github/workflows/deploy-site.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index 145b8dd4b..67130fe8f 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -49,6 +49,7 @@ on:
- "scripts/sitediagrams.py"
- "scripts/siteentries.py"
- "scripts/siteguides.py"
+ - "scripts/siteinputs.py"
- "scripts/sitemarkup.py"
- "scripts/sitemeasurements.py"
- "scripts/siteexports.py"
From 856cb5618fcbed70c834a74da1b8be521e25bce5 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 09:14:42 +0100
Subject: [PATCH 20/22] fix: link a build file only where it is recorded
Every source link on the site leads to a path the source record saw at
a commit. The Build file row linked pin_build files unchecked, eight of
them at commits the record did not hold. build_source_paths now records
the build files read at a commit, and the row links only those: a tag
can move and stays plain text. The record run also picked up two cited
paths it had not seen before (pom2 CMakeLists.txt, pcem configure.ac).
---
scripts/generate_site.py | 13 ++++++++-----
scripts/sitesources.py | 19 +++++++++++++++++++
tests/test_audit_regressions.py | 27 +++++++++++++++++++++++----
website/src/data/source-paths.json | 14 ++++++++++++++
4 files changed, 64 insertions(+), 9 deletions(-)
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index fe43620af..127c8aa3e 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -8198,17 +8198,20 @@ def _exact_size(size: int) -> str:
def _build_file_links(pin_build: list[dict]) -> list[str]:
"""Each platform build file that holds the revision, linked at the
- revision it was read at. The prose of pin_frozen named the file as
- plain text, while pin_build carries all a pinned link needs."""
- from sitesources import _browse_repo, _source_permalink # noqa: PLC0415
+ commit it was read at when the source record holds it. The prose of
+ pin_frozen named the file as plain text."""
+ from sitesources import _browse_repo, _source_permalink, tree_holds # noqa: PLC0415
links = []
for build in pin_build:
repo = _browse_repo(str(build["repo"]))
path = str(build["path"])
+ revision = str(build["revision"])
text = f"{_escape_tags(_platform_display(str(build['platform'])))} {_code(path)}"
- if repo is not None:
- text = f"[{text}]({_source_permalink(repo, str(build['revision']), path, None, None)})"
+ # Linked where the record saw the file at that commit, as every
+ # source link is: a tag can move, and is never recorded.
+ if repo is not None and tree_holds(repo, revision, path):
+ text = f"[{text}]({_source_permalink(repo, revision, path, None, None)})"
links.append(f"{text} at {_code(str(build['revision']))} sets {_code(str(build['variable']))}")
return links
diff --git a/scripts/sitesources.py b/scripts/sitesources.py
index f42de04ae..c28b7c5f1 100644
--- a/scripts/sitesources.py
+++ b/scripts/sitesources.py
@@ -321,9 +321,28 @@ def build_source_paths(
# Recorded even when it holds no cited path, so a pinned tree
# missing from the record is one no run has read.
held.setdefault(tree_key(repo, pin), set()).update(paths & tree)
+ for repo, pin, path in build_files(profile):
+ tree = (_cached_tree(repo, pin, cache_dirs, fetch)
+ or _listed_directories(repo, pin, {path}, cache_dirs, fetch))
+ if tree is None:
+ unread.add(tree_key(repo, pin))
+ continue
+ held.setdefault(tree_key(repo, pin), set()).update({path} & tree)
return {key: sorted(paths) for key, paths in sorted(held.items())}, sorted(unread)
+def build_files(profile: dict) -> list[tuple[upstream.Repo, str, str]]:
+ """The platform build files `pin_build` names at a commit, as (repo,
+ revision, path). A tag can move, so a build read at a tag is not linked."""
+ found = []
+ for build in profile.get("pin_build") or []:
+ repo = _browse_repo(str(build.get("repo", "")))
+ revision = str(build.get("revision", ""))
+ if repo is not None and re.fullmatch(r"[0-9a-f]{40}", revision):
+ found.append((repo, revision, str(build.get("path", ""))))
+ return found
+
+
def keep_unread(
held: dict[str, list[str]], unread: list[str], previous: dict[str, list[str]]
) -> dict[str, list[str]]:
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 6776263b8..da75706d6 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -5222,11 +5222,30 @@ class AHeldRevisionLinksItsBuildFile(unittest.TestCase):
"platform": "knulli", "repo": "https://github.com/knulli-cfw/knulli-linux",
"revision": "20260511", "path": "package/emulators/advanced_drastic/advanced_drastic.mk",
"variable": "ADVANCED_DRASTIC_VERSION", "value": "eaf41ab9"}]})
+ # A tag can move: named, never linked.
self.assertIn(
- "| Build file | [KNULLI `package/emulators/advanced_drastic/advanced_drastic.mk`]"
- "(https://github.com/knulli-cfw/knulli-linux/blob/20260511/package/emulators/"
- "advanced_drastic/advanced_drastic.mk) at `20260511` sets `ADVANCED_DRASTIC_VERSION` |",
- rows)
+ "| Build file | KNULLI `package/emulators/advanced_drastic/advanced_drastic.mk` "
+ "at `20260511` sets `ADVANCED_DRASTIC_VERSION` |", rows)
+
+ def test_a_build_read_at_a_recorded_commit_links(self):
+ from scripts.generate_site import _build_rows
+
+ pin = "46128cd252cdecae852a4d0bafa2aaf9d03ab563"
+ rows = _build_rows({"pin_build": [{
+ "platform": "retropie", "repo": "https://github.com/RetroPie/RetroPie-Setup",
+ "revision": pin, "path": "scriptmodules/emulators/advmame-1.4.sh",
+ "variable": "rp_module_repo", "value": "x"}]})
+ self.assertIn(
+ f"[RetroPie `scriptmodules/emulators/advmame-1.4.sh`](https://github.com/RetroPie/"
+ f"RetroPie-Setup/blob/{pin}/scriptmodules/emulators/advmame-1.4.sh)", rows[0])
+
+ def test_the_record_holds_the_build_files(self):
+ import sitesources # noqa: PLC0415
+
+ files = sitesources.build_files({"pin_build": [
+ {"repo": "https://github.com/o/r", "revision": "a" * 40, "path": "p.mk"},
+ {"repo": "https://github.com/o/r", "revision": "v1", "path": "q.mk"}]})
+ self.assertEqual([(f[1], f[2]) for f in files], [("a" * 40, "p.mk")])
class AVariantCardSaysWhatToSaveItAs(unittest.TestCase):
diff --git a/website/src/data/source-paths.json b/website/src/data/source-paths.json
index d591448f7..d0f00e7a2 100644
--- a/website/src/data/source-paths.json
+++ b/website/src/data/source-paths.json
@@ -424,6 +424,11 @@
"crates/wqxemu/src/keypad.rs",
"crates/wqxemu/src/main.rs"
],
+ "github.com/AmberELEC/AmberELEC@a21975bfb1a3af68f66e07a931b994b523e1e6e2": [
+ "packages/games/emulators/lzdoom/package.mk",
+ "packages/games/emulators/piemu/package.mk",
+ "packages/games/libretro/flycast2021le/package.mk"
+ ],
"github.com/Amjad50/plastic@74152adb3acd890d3abee2ff5906408f26a199e2": [
"plastic_core/src/cartridge/mod.rs",
"plastic_core/src/display/color.rs",
@@ -1584,6 +1589,13 @@
"imagine/src/font/FreetypeFont.cc",
"imagine/src/fs/ArchiveFS.cc"
],
+ "github.com/RetroPie/RetroPie-Setup@46128cd252cdecae852a4d0bafa2aaf9d03ab563": [
+ "scriptmodules/emulators/advmame-1.4.sh",
+ "scriptmodules/emulators/ppsspp-1.5.4.sh",
+ "scriptmodules/ports/alephone.sh",
+ "scriptmodules/ports/love-0.10.2.sh",
+ "scriptmodules/ports/lzdoom.sh"
+ ],
"github.com/RetroPie/mame4all-pi@c6bfd2aef331b5a5e157c15d8bf8a77393c85689": [
"readme.txt",
"src/cheat.cpp",
@@ -3475,6 +3487,7 @@
"src/ui/video.rs"
],
"github.com/habib256/pom2@de86e8e35df96fe6ffced56dfb727bbd567f8568": [
+ "CMakeLists.txt",
"packaging/bundle.manifest",
"packaging/fujinet/stage.py",
"src/AppleParallelCard.cpp",
@@ -8930,6 +8943,7 @@
"nes.c"
],
"github.com/sarah-walker-pcem/pcem@10739b9618e388a5bf8c21e752f12b3952fa83e1": [
+ "configure.ac",
"src/mem.c",
"src/nvr.c",
"src/ps1.c",
From adcfa283fb125a2bd853c4517a78a77e9aa0e5c7 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 09:14:47 +0100
Subject: [PATCH 21/22] docs: keep the FAQ under its word ceiling
The --core answer added 14 words to a page held at 450. It now names the
command in as many words as the line it replaced, and the --region line
goes: what --region keeps is on the installer page.
---
wiki/faq.md | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/wiki/faq.md b/wiki/faq.md
index 70548bfc3..f3429d353 100644
--- a/wiki/faq.md
+++ b/wiki/faq.md
@@ -25,15 +25,13 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --dry-run
## Do I need every file of a console?
-No. `--core` keeps what your emulator reads, here PCSX for PlayStation:
+No. `--core pcsx1` keeps what PCSX reads:
```sh
curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --system sony-playstation --core pcsx1
```
-`--region us` drops another region's BIOS where a US one exists.
-
## Does the BIOS set the game's language?
Rarely. It comes from the game. Some follow the console's setting, like Flycast's **Language**
From 1396350795df5b80bc28b81b60e5e6d28f232baa Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 6 Nov 2026 10:14:02 +0100
Subject: [PATCH 22/22] fix: count build file trees as pinned
check_source_paths read the trees profiles pin from source and upstream
only, so the two platform trees the record now holds for pin_build
files read as orphaned and the record as stale.
---
scripts/check_freshness.py | 5 +++++
tests/test_site_integration.py | 15 +++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/scripts/check_freshness.py b/scripts/check_freshness.py
index b8e03f28b..075c45641 100644
--- a/scripts/check_freshness.py
+++ b/scripts/check_freshness.py
@@ -1492,6 +1492,11 @@ def check_source_paths(profiles: dict) -> Finding:
sitesources.tree_key(repo, pin)
for profile in profiles.values() if sitesources.cited_paths(profile)
for repo, pin in sitesources.profile_trees(profile)
+ } | {
+ # The platform build files pin_build names, linked from a page too.
+ sitesources.tree_key(repo, pin)
+ for profile in profiles.values()
+ for repo, pin, _path in sitesources.build_files(profile)
}
try:
recorded = set(json.loads(sitesources.SOURCE_PATHS.read_text(encoding="utf-8")))
diff --git a/tests/test_site_integration.py b/tests/test_site_integration.py
index 91a3dad0d..a3371662a 100644
--- a/tests/test_site_integration.py
+++ b/tests/test_site_integration.py
@@ -117,6 +117,21 @@ class SourcePathsFreshness(unittest.TestCase):
finding = check_freshness.check_source_paths(profiles)
self.assertEqual(finding.status, check_freshness.OK, finding.detail)
+ def test_a_recorded_build_file_tree_is_pinned(self):
+ """The record holds the platform trees pin_build reads at a commit,
+ and the check called them orphaned."""
+ import sitesources # noqa: PLC0415
+
+ pin = "a" * 40
+ profile = {"pin_build": [{"repo": "https://github.com/o/r", "revision": pin,
+ "path": "p.mk"}]}
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "source-paths.json"
+ path.write_text(json.dumps({f"github.com/o/r@{pin}": ["p.mk"]}), encoding="utf-8")
+ with mock.patch.object(sitesources, "SOURCE_PATHS", path):
+ finding = check_freshness.check_source_paths({"p": profile})
+ self.assertEqual(finding.status, check_freshness.OK, finding.detail)
+
def test_a_moved_pin_is_stale(self):
profile = _profile("geolith")
moved = dict(profile, source_commit="f" * 40, upstream_commit="e" * 40)