#!/usr/bin/env python3 """Generate MkDocs site pages from database.json, platform configs, and emulator profiles. Reads the same data sources as verify.py and generate_pack.py to produce a complete documentation site. Zero manual content. Usage: python scripts/generate_site.py python scripts/generate_site.py --db database.json --platforms-dir platforms """ from __future__ import annotations from siterender import WIKI_SRC_DIR import argparse import csv import hashlib import io import json import os import re import shutil import sqlite3 import sys import urllib.error import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, os.path.dirname(__file__)) from common import ( compute_composition, count_catalog_matched, list_registered_platforms, load_database, load_emulator_profiles, load_provenance_snapshots, require_yaml, resolve_platform_cores, unique_emulator_profiles, write_if_changed as _write_artifact, yaml_load, ) from nativemode import reads_file_contents yaml = require_yaml() from generate_readme import compute_coverage, contributing_text, release_totals from profile_sync import source_ref_values, split_source_ref from provenance_report import build_report import release_record import upstream DOCS_DIR = "docs" SITE_NAME = "RetroBIOS" REPO_URL = "https://github.com/Abdess/retrobios" RELEASE_URL = f"{REPO_URL}/releases/latest" SITE_URL = "https://abdess.github.io/retrobios/" GENERATED_DIRS = ["platforms", "systems", "emulators", "wiki", "api", "downloads"] def _content_check_ceiling(profiles: dict) -> str: """How far content checking can reach across every profiled entry.""" hashed = sized = neither = 0 for profile in unique_emulator_profiles(profiles).values(): for f in profile.get("files", []) or []: if any( f.get(k) for k in ("md5", "sha1", "crc32", "sha256", "known_hash_adler32") ): hashed += 1 elif any(f.get(k) for k in ("size", "min_size", "max_size")): sized += 1 else: neither += 1 total = hashed + sized + neither if not total: return "" return ( f"Across every profiled entry, {hashed:,} of {total:,} " f"({hashed / total * 100:.0f}%) carry a hash the code checks, " f"{sized:,} ({sized / total * 100:.0f}%) only a size, and " f"{neither:,} ({neither / total * 100:.0f}%) neither. That last share " "is the ceiling of the method: where an emulator validates nothing, " "reading its source establishes which file it loads, never whether " "the content is the right dump. The provenance field answers that " "other question, for the files a dump catalog indexes." ) CLS_LABELS = { "official_port": "Official ports", "community_fork": "Community forks", "pure_libretro": "Pure libretro", "game_engine": "Game engines", "enhanced_fork": "Enhanced forks", "frozen_snapshot": "Frozen snapshots", "embedded_hle": "Embedded HLE", "launcher": "Launchers", "unclassified": "Unclassified", "other": "Other", } def _build_system_page_map_from_data( manufacturers: dict, coverages: dict, db: dict, ) -> None: """Build system_id -> (manufacturer_slug, console_name) mapping. Uses platform file paths to trace system_id -> bios directory -> manufacturer page. """ db.get("files", {}) db.get("indexes", {}).get("by_name", {}) # Build reverse index: filename -> (manufacturer, console) from bios/ structure file_to_console: dict[str, tuple[str, str]] = {} for mfr, consoles in manufacturers.items(): for console, entries in consoles.items(): for entry in entries: file_to_console[entry["name"]] = (mfr, console) # Build normalized console name index for fuzzy matching console_norm: dict[str, tuple[str, str]] = {} for mfr, consoles in manufacturers.items(): slug = mfr.lower().replace(" ", "-") mfr_norm = mfr.lower().replace(" ", "-") for console in consoles: norm = console.lower().replace(" ", "-") entry = (slug, console) console_norm[norm] = entry console_norm[f"{mfr_norm}-{norm}"] = entry # Short aliases: strip common manufacturer prefix words for prefix in ( f"{mfr_norm}-", "nintendo-", "sega-", "sony-", "snk-", "nec-", ): if norm.startswith(prefix.replace(f"{mfr_norm}-", "")): pass # already covered by norm key = f"{prefix}{norm}" console_norm[key] = entry # Map system_id -> (manufacturer, console) via platform file entries for cov in coverages.values(): config = cov["config"] for sys_id, system in config.get("systems", {}).items(): if sys_id in _system_page_map: continue # Strategy 1: trace via file paths in DB for fe in system.get("files", []): fname = fe.get("name", "") if fname in file_to_console: mfr, console = file_to_console[fname] slug = mfr.lower().replace(" ", "-") _system_page_map[sys_id] = (slug, console) break if sys_id in _system_page_map: continue # Strategy 2: fuzzy match system_id against console directory names if sys_id in console_norm: _system_page_map[sys_id] = console_norm[sys_id] else: # Try partial match: "nintendo-wii" matches "Wii" under "Nintendo" parts = sys_id.split("-") for i in range(len(parts)): suffix = "-".join(parts[i:]) if suffix in console_norm: _system_page_map[sys_id] = console_norm[suffix] break def _timestamp() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # Home page def generate_home( db: dict, coverages: dict, profiles: dict, registry: dict | None = None ) -> str: total_files = db.get("total_files", 0) total_size = db.get("total_size", 0) ts = _timestamp() unique = unique_emulator_profiles(profiles) emulator_count = len(unique) # Classification stats classifications: dict[str, int] = {} for p in unique.values(): cls = p.get("core_classification", "other") if cls not in CLS_LABELS or cls == "unclassified": cls = "other" classifications[cls] = classifications.get(cls, 0) + 1 # Count total systems across all profiles all_systems = set() for p in unique.values(): all_systems.update(p.get("systems", [])) lines = [ '
', "", f"# {SITE_NAME}", "", "BIOS and firmware metadata checked the way each platform checks it, " "cross-referenced against revision-pinned emulator source code.", "", "
", "", '
', "", '
', f'{total_files:,}', f'{COLLECTION_FILES_LABEL}', "
", "", '
', f'{len(coverages)}', 'Platforms', "
", "", '
', f'{emulator_count}', 'Emulators profiled', "
", "", '
', f'{_fmt_size(total_size)}', 'Total size', "
", "", "
", "", composition_sentence(db), "", ] # Platforms FIRST (main action) lines.extend( [ "## Platforms", "", "| Icon | Platform | Files in the pack | Checked by | Download |", "|---|----------|------:|-----------|----------|", ] ) mode_icons = {"md5": "MD5", "sha1": "SHA1", "existence": "exists"} for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): display = cov["platform"] logo_url = (registry or {}).get(name, {}).get("logo", "") logo_md = ( f"![{display}]({logo_url}){{ width=20 loading=lazy }}" if logo_url else "" ) mode_label = mode_icons.get(cov["mode"], cov["mode"]) lines.append( f"| {logo_md} | [{display}](platforms/{name}.md) | " f"{_released_files(name)} | {mode_label} | " f"[Pack]({RELEASE_URL}){{ .md-button .md-button--primary }} |" ) lines.extend( [ "", "Checked by is the test each platform runs on its own, replicated " "from its source code. RetroArch only looks for the filename; " "Batocera compares MD5 checksums, the fingerprint of a file's " "contents. " "[How each mode works](wiki/verification-modes.md).", ] ) catalog_matched = count_catalog_matched(db) if catalog_matched: system_files = compute_composition(db)["systems"]["files"] lines.extend( [ "", f"**{catalog_matched:,}** of {system_files:,} system files are " "byte-identical to a dump catalogued by No-Intro, Redump, or " "TOSEC, and say so on their system page. " "[What that means](provenance.md).", ] ) # Quick start (collapsible -- secondary info) lines.extend( [ "", '??? info "Where to extract"', "", " | Platform | Extract to |", " |----------|-----------|", " | RetroArch | `system/` |", " | Batocera | `/userdata/bios/` |", " | BizHawk | `Firmware/` |", " | EmuDeck | `~/Emulation/bios/` |", " | Lakka | `/storage/system/` |", " | MiSTer FPGA | `/media/fat/games/` |", " | ROCKNIX | `/storage/roms/bios/` |", " | Recalbox | `/recalbox/share/bios/` |", " | RetroBat | `bios/` |", " | RetroDECK | `~/retrodeck/` |", " | RetroPie | `~/RetroPie/BIOS/` |", " | RomM | `bios/{platform_slug}/` |", "", " The RetroDECK pack already carries its own `bios/` folder, so it " "extracts one level above it. Every other pack extracts straight into " "the BIOS folder. [Full instructions per setup](which-pack.md).", "", ] ) # Emulator classification breakdown lines.extend( [ "## Emulator profiles", "", "| Classification | Count |", "|---------------|-------|", ] ) for cls, count in sorted(classifications.items(), key=lambda x: -x[1]): label = CLS_LABELS.get(cls, cls) lines.append(f"| [{label}](emulators/index.md#{cls}) | {count} |") # Methodology (collapsible) lines.extend( [ "", '??? abstract "Methodology"', "", " Platform lists are checked against emulator source code, file " "by file where a profile exists ([how far that reaches](gaps.md)). " "Documentation and metadata can drift from actual runtime behavior, " "so the source is the primary reference.", "", " 1. **Upstream emulator source** -- what the original project " "loads (Dolphin, PCSX2, Mednafen...)", " 2. **Libretro core source** -- the RetroArch port, which may " "adapt paths or add files", " 3. **`.info` declarations** -- metadata that platforms rely on, " "checked for accuracy", "", ] ) # Quick links lines.extend( [ "---", "", "[Systems](systems/index.md){ .md-button } " "[Emulators](emulators/index.md){ .md-button } " "[Cross-reference](cross-reference.md){ .md-button } " "[Gap Analysis](gaps.md){ .md-button } " "[Dump provenance](provenance.md){ .md-button } " "[Data & API](data.md){ .md-button } " "[Contributing](contributing.md){ .md-button .md-button--primary }", "", f'
Generated on {ts}.
', ] ) return "\n".join(lines) + "\n" def compute_stats(db: dict, coverages: dict, profiles: dict) -> dict: unique = unique_emulator_profiles(profiles) systems: set[str] = set() for p in unique.values(): systems.update(p.get("systems", [])) return { "schema_version": 1, "generated_at": _timestamp(), "files": db.get("total_files", 0), "size_bytes": db.get("total_size", 0), "platforms": len(coverages), "emulators": len(unique), "systems": len(systems), "catalog_matched": count_catalog_matched(db), "source": REPO_URL, "downloads": RELEASE_URL, } def _released_files(platform_name: str) -> str: """File count of the pack the latest release serves, as a table cell.""" files, _size = release_totals(platform_name, release_record.load_record()) return f"{files:,}" if files else "-" # The home tile counts the whole collection. Labelled "Files" above a table # of pack buttons, it read as what one pack contains. COLLECTION_FILES_LABEL = "Files collected" def composition_sentence(db: dict) -> str: comp = compute_composition(db) return ( f"The collection holds {comp['systems']['files']:,} console and " f"computer system files, {comp['arcade']['files']:,} arcade ROM sets " f"(`Arcade/`), and {comp['game_data']['files']:,} game and engine " "data files (`Game Engines/`, `RPG Maker/`, `ScummVM/` and the game " "publishers' trees). No pack holds " "them all: each carries what its platform's emulators load." ) def generate_stats(stats: dict) -> str: return json.dumps(stats, indent=2) + "\n" def _json_text(value) -> str: """Stable scalar representation for CSV and SQLite exports.""" if value is None: return "" if isinstance(value, (dict, list, tuple, bool)): return json.dumps(value, ensure_ascii=False, sort_keys=True) return str(value) def _csv_document(fieldnames: list[str], rows: list[dict]) -> str: stream = io.StringIO(newline="") writer = csv.DictWriter( stream, fieldnames=fieldnames, extrasaction="ignore", lineterminator="\n" ) writer.writeheader() writer.writerows(rows) return stream.getvalue() def _sha256_path(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _platform_export_rows(coverages: dict) -> tuple[list[dict], list[dict]]: """Normalized platform summaries and one row per declared file.""" items: list[dict] = [] file_rows: list[dict] = [] for key, coverage in sorted(coverages.items()): config = coverage["config"] items.append({ "id": key, "name": coverage["platform"], "coverage": { field: coverage.get(field, 0) for field in ( "total", "present", "verified", "untested", "missing", "core_present", "core_missing", "core_unsourceable", ) }, "contract": config, }) for system_id, system in sorted(config.get("systems", {}).items()): for entry in system.get("files", []) or []: file_rows.append({ "platform_id": key, "platform": coverage["platform"], "system": system_id, "name": entry.get("name", ""), "destination": entry.get( "destination", entry.get("dest", entry.get("name", "")) ), "required": bool(entry.get("required", True)), "region": _json_text(entry.get("region")), "variant_group": _json_text(entry.get("variant_group")), "size": entry.get("size"), "sha1": _json_text(entry.get("sha1")), "sha256": _json_text(entry.get("sha256")), "md5": _json_text(entry.get("md5")), "crc32": _json_text(entry.get("crc32")), }) return items, file_rows def _emulator_export_items(profiles: dict) -> list[dict]: return [ {"id": key, "profile": profile} for key, profile in sorted(profiles.items()) ] def build_emulator_gap_report( profiles: dict, coverages: dict, db: dict, data_names: set[str] | None = None, ) -> dict: """Files a core loads that no platform declares, per emulator profile. The gap analysis page and the published gaps export must not compute this twice and drift; both read this one report. """ from common import _norm_system_id, expand_platform_declared_names from cross_reference import cross_reference as run_cross_reference all_declared: set[str] = set() declared: dict[str, set[str]] = {} for _name, cov in coverages.items(): config = cov["config"] # Enrich with alias resolution (MD5 -> SHA1 -> canonical name + aliases) all_declared.update(expand_platform_declared_names(config, db)) for sys_id, system in config.get("systems", {}).items(): for fe in system.get("files", []): fname = fe.get("name", "") if fname: declared.setdefault(_norm_system_id(sys_id), set()).add(fname) unique_profiles = { k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } return run_cross_reference( unique_profiles, declared, db, data_names=data_names, all_declared=all_declared, ) def _gap_export_rows(coverages: dict, gap_report: dict | None = None) -> list[dict]: """Every gap the site reports, both layers, in one table. ``platform`` rows are anomalies against a platform's own BIOS list. ``emulator`` rows are files a profiled core loads that no platform declares, which is the larger number the gap analysis page leads with. A `layer` column keeps the two apart instead of publishing only one. """ rows: list[dict] = [] for key, coverage in sorted(coverages.items()): for detail in coverage.get("details", []): if detail.get("status") == "ok" and not detail.get("discrepancy"): continue rows.append({ "layer": "platform", "platform_id": key, "platform": coverage["platform"], "emulator": "", "system": detail.get("system", ""), "name": detail.get("name", ""), "status": detail.get("status", ""), "required": bool(detail.get("required", True)), "in_repo": "", "reason": detail.get("reason", ""), "discrepancy": detail.get("discrepancy", ""), }) for emu_key, data in sorted((gap_report or {}).items()): systems = ";".join(str(s) for s in data.get("systems", []) or []) for gap in data.get("gap_details", []) or []: rows.append({ "layer": "emulator", "platform_id": "", "platform": "", "emulator": data.get("emulator", emu_key), "system": systems, "name": gap.get("name", ""), "status": gap.get("source", ""), "required": bool(gap.get("required", False)), "in_repo": bool(gap.get("in_repo", False)), "reason": gap.get("note", ""), "discrepancy": "", }) for entry in data.get("unsourceable", []) or []: rows.append({ "layer": "emulator", "platform_id": "", "platform": "", "emulator": data.get("emulator", emu_key), "system": systems, "name": entry.get("name", ""), "status": "unsourceable", "required": bool(entry.get("required", False)), "in_repo": False, "reason": entry.get("reason", ""), "discrepancy": "", }) return rows def _cross_reference_export_rows(coverages: dict, profiles: dict) -> list[dict]: from common import resolve_platform_cores unique = { key: value for key, value in profiles.items() if value.get("type") not in ("alias", "test") } rows: list[dict] = [] for platform_id, coverage in sorted(coverages.items()): for profile_id in sorted(resolve_platform_cores(coverage["config"], unique)): profile = unique[profile_id] cores = profile.get("cores") or [profile_id] systems = profile.get("systems") or [""] for core in cores: for system in systems: rows.append({ "platform_id": platform_id, "platform": coverage["platform"], "profile_id": profile_id, "emulator": profile.get("emulator", profile_id), "core": core, "system": system, "classification": profile.get("core_classification", ""), "type": profile.get("type", ""), "source": _json_text(profile.get("source")), "upstream": _json_text(profile.get("upstream")), "profiled_commit": _json_text(profile.get("source_commit", "")), "file_count": len(profile.get("files", []) or []), }) return rows def _write_sqlite_export( destination: Path, db: dict, platform_items: list[dict], platform_files: list[dict], emulator_items: list[dict], gap_rows: list[dict], ) -> None: """Build a queryable, deterministic snapshot without embedding binaries.""" temp_dir = Path("tmp") / "site" temp_dir.mkdir(parents=True, exist_ok=True) temp_path = temp_dir / f"retrobios-{os.getpid()}.sqlite" temp_path.unlink(missing_ok=True) destination.parent.mkdir(parents=True, exist_ok=True) connection = sqlite3.connect(temp_path) try: connection.executescript(""" PRAGMA journal_mode = OFF; PRAGMA synchronous = OFF; CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE files ( sha1 TEXT PRIMARY KEY, path TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL, md5 TEXT NOT NULL, sha256 TEXT NOT NULL, crc32 TEXT NOT NULL, adler32 TEXT NOT NULL ); CREATE TABLE file_provenance ( sha1 TEXT NOT NULL, catalog TEXT NOT NULL, details_json TEXT NOT NULL, PRIMARY KEY (sha1, catalog), FOREIGN KEY (sha1) REFERENCES files(sha1) ); CREATE TABLE platforms ( id TEXT PRIMARY KEY, name TEXT NOT NULL, total INTEGER NOT NULL, present INTEGER NOT NULL, verified INTEGER NOT NULL, missing INTEGER NOT NULL, contract_json TEXT NOT NULL ); CREATE TABLE platform_files ( platform_id TEXT NOT NULL, system TEXT NOT NULL, name TEXT NOT NULL, destination TEXT NOT NULL, required INTEGER NOT NULL, region TEXT, variant_group TEXT, size INTEGER, sha1 TEXT, sha256 TEXT, md5 TEXT, crc32 TEXT ); CREATE TABLE emulators ( id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT, classification TEXT, source TEXT, upstream TEXT, source_commit TEXT, profile_json TEXT NOT NULL ); CREATE TABLE emulator_systems ( emulator_id TEXT NOT NULL, system TEXT NOT NULL, PRIMARY KEY (emulator_id, system) ); CREATE TABLE emulator_files ( emulator_id TEXT NOT NULL, system TEXT, name TEXT NOT NULL, path TEXT, required INTEGER NOT NULL, mode TEXT, region TEXT, size TEXT, sha1 TEXT, sha256 TEXT, md5 TEXT, crc32 TEXT, source_ref TEXT ); CREATE TABLE gaps ( layer TEXT NOT NULL, platform_id TEXT, platform TEXT, emulator TEXT, system TEXT, name TEXT NOT NULL, status TEXT NOT NULL, required INTEGER NOT NULL, in_repo TEXT, reason TEXT, discrepancy TEXT ); CREATE INDEX files_name_idx ON files(name); CREATE INDEX files_md5_idx ON files(md5); CREATE INDEX files_sha256_idx ON files(sha256); CREATE INDEX platform_files_name_idx ON platform_files(name); CREATE INDEX emulator_files_name_idx ON emulator_files(name); CREATE INDEX gaps_status_idx ON gaps(layer, status); """) metadata = { "schema_version": "1", "generated_at": str(db.get("generated_at") or _timestamp()), "source": REPO_URL, "scope": "metadata only; no BIOS or firmware payload bytes", } connection.executemany( "INSERT INTO metadata(key, value) VALUES (?, ?)", sorted(metadata.items()), ) for sha1, entry in sorted(db.get("files", {}).items()): connection.execute( "INSERT INTO files VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( sha1, entry.get("path", ""), entry.get("name", ""), entry.get("size", 0), entry.get("md5", ""), entry.get("sha256", ""), entry.get("crc32", ""), entry.get("adler32", ""), ), ) for catalog, details in sorted((entry.get("provenance") or {}).items()): connection.execute( "INSERT INTO file_provenance VALUES (?, ?, ?)", (sha1, catalog, _json_text(details)), ) for item in platform_items: coverage = item["coverage"] connection.execute( "INSERT INTO platforms VALUES (?, ?, ?, ?, ?, ?, ?)", ( item["id"], item["name"], coverage.get("total", 0), coverage.get("present", 0), coverage.get("verified", 0), coverage.get("missing", 0), _json_text(item["contract"]), ), ) connection.executemany( "INSERT INTO platform_files VALUES " "(:platform_id, :system, :name, :destination, :required, :region, " ":variant_group, :size, :sha1, :sha256, :md5, :crc32)", platform_files, ) for item in emulator_items: profile = item["profile"] connection.execute( "INSERT INTO emulators VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( item["id"], profile.get("emulator", item["id"]), profile.get("type", ""), profile.get("core_classification", ""), _json_text(profile.get("source")), _json_text(profile.get("upstream")), # source_commit is a string or, when the builds live in # separate repositories, an object keyed by build mode. # SQLite cannot bind the object form. _json_text(profile.get("source_commit", "")), _json_text(profile), ), ) for system in sorted(set(profile.get("systems", []) or [])): connection.execute( "INSERT INTO emulator_systems VALUES (?, ?)", (item["id"], system), ) for entry in profile.get("files", []) or []: connection.execute( "INSERT INTO emulator_files VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( item["id"], _json_text(entry.get("system")), entry.get("name", ""), entry.get("path", ""), int(bool(entry.get("required", False))), entry.get("mode", ""), _json_text(entry.get("region")), _json_text(entry.get("size")), _json_text(entry.get("sha1")), _json_text(entry.get("sha256")), _json_text(entry.get("md5")), _json_text(entry.get("crc32")), _json_text(entry.get("source_ref")), ), ) connection.executemany( "INSERT INTO gaps VALUES " "(:layer, :platform_id, :platform, :emulator, :system, :name, " ":status, :required, :in_repo, :reason, :discrepancy)", gap_rows, ) connection.commit() connection.execute("VACUUM") finally: connection.close() os.replace(temp_path, destination) def generate_data_exports( docs: Path, db: dict, coverages: dict, profiles: dict, stats: dict, gap_report: dict | None = None, ) -> list[dict]: """Create versioned static API, CSV and SQLite metadata snapshots.""" api = docs / "api" / "v1" downloads = docs / "downloads" schemas_dest = api / "schemas" for directory in (api, downloads, schemas_dest): directory.mkdir(parents=True, exist_ok=True) generated_at = str(db.get("generated_at") or _timestamp()) platform_items, platform_files = _platform_export_rows(coverages) emulator_items = _emulator_export_items(profiles) gap_rows = _gap_export_rows(coverages, gap_report) cross_rows = _cross_reference_export_rows(coverages, profiles) envelopes = { "platforms.json": ("platforms", platform_items), "emulators.json": ("emulators", emulator_items), "gaps.json": ("verification-and-coverage-gaps", gap_rows), } for filename, (kind, items) in envelopes.items(): document = { "schema_version": 1, "generated_at": generated_at, "kind": kind, "count": len(items), "items": items, } write_if_changed( str(api / filename), json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n", ) write_if_changed(str(api / "stats.json"), generate_stats(stats)) write_if_changed( str(api / "database.json"), json.dumps(db, ensure_ascii=False, indent=2) + "\n", ) schema_names = ( "database.schema.json", "emulator.schema.json", "platform.schema.json", "site-api-envelope.schema.json", "stats.schema.json", ) for schema_name in schema_names: shutil.copy2(Path("schemas") / schema_name, schemas_dest / schema_name) _record(schemas_dest / schema_name) file_rows = [] for sha1, entry in sorted(db.get("files", {}).items()): file_rows.append({ "sha1": sha1, "path": entry.get("path", ""), "name": entry.get("name", ""), "size": entry.get("size", 0), "md5": entry.get("md5", ""), "sha256": entry.get("sha256", ""), "crc32": entry.get("crc32", ""), "adler32": entry.get("adler32", ""), "provenance_catalogs": ";".join( sorted((entry.get("provenance") or {}).keys()) ), }) write_if_changed( str(downloads / "files.csv"), _csv_document( [ "sha1", "path", "name", "size", "md5", "sha256", "crc32", "adler32", "provenance_catalogs", ], file_rows, ), ) write_if_changed( str(downloads / "platform-files.csv"), _csv_document( [ "platform_id", "platform", "system", "name", "destination", "required", "region", "variant_group", "size", "sha1", "sha256", "md5", "crc32", ], platform_files, ), ) write_if_changed( str(downloads / "cross-reference.csv"), _csv_document( [ "platform_id", "platform", "profile_id", "emulator", "core", "system", "classification", "type", "source", "upstream", "profiled_commit", "file_count", ], cross_rows, ), ) write_if_changed( str(downloads / "gaps.csv"), _csv_document( [ "layer", "platform_id", "platform", "emulator", "system", "name", "status", "required", "in_repo", "reason", "discrepancy", ], gap_rows, ), ) _write_sqlite_export( downloads / "retrobios.sqlite", db, platform_items, platform_files, emulator_items, gap_rows, ) _record(downloads / "retrobios.sqlite") assets = [ (api / "database.json", "Content database", "application/json", "schemas/database.schema.json"), (api / "platforms.json", "Platform contracts", "application/json", "schemas/site-api-envelope.schema.json"), (api / "emulators.json", "Emulator profiles", "application/json", "schemas/site-api-envelope.schema.json"), (api / "gaps.json", "Verification and coverage gaps", "application/json", "schemas/site-api-envelope.schema.json"), (api / "stats.json", "Project statistics", "application/json", "schemas/stats.schema.json"), (downloads / "files.csv", "File hashes CSV", "text/csv", None), (downloads / "platform-files.csv", "Platform declarations CSV", "text/csv", None), (downloads / "cross-reference.csv", "Cross-reference CSV", "text/csv", None), (downloads / "gaps.csv", "Verification and coverage gaps CSV", "text/csv", None), (downloads / "retrobios.sqlite", "SQLite snapshot", "application/vnd.sqlite3", None), ] catalog_items: list[dict] = [] for path, title, media_type, schema in assets: relative = path.relative_to(docs).as_posix() item = { "title": title, "url": relative, "media_type": media_type, "bytes": path.stat().st_size, "sha256": _sha256_path(path), } if schema: item["schema"] = schema catalog_items.append(item) catalog = { "schema_version": 1, "generated_at": generated_at, "kind": "catalog", "count": len(catalog_items), "items": catalog_items, } write_if_changed( str(api / "catalog.json"), json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n", ) return catalog_items def generate_data_page(exports: list[dict]) -> str: lines = [ f"# Data & API - {SITE_NAME}", "", "RetroBIOS publishes the same metadata used by its verifier, pack builder " "and website as versioned static files. No account or API key is required.", "", "[API catalog](api/v1/catalog.json){ .md-button .md-button--primary } " "[SQLite snapshot](downloads/retrobios.sqlite){ .md-button }", "", "## Stable JSON endpoints", "", "| Dataset | Endpoint | Schema | Size |", "|---------|----------|--------|-----:|", ] for item in exports: if item["media_type"] != "application/json": continue schema = ( f"[JSON Schema](api/v1/{item['schema']})" if item.get("schema") else "-" ) lines.append( f"| {item['title']} | [`{item['url']}`]({item['url']}) | " f"{schema} | {_fmt_size(item['bytes'])} |" ) lines.extend([ "", "Every JSON document carries `schema_version`. Breaking changes use a new " "URL prefix (`/api/v2/`); fields may only be added compatibly within v1.", "", "## Bulk downloads", "", "| Export | Format | Size | SHA256 |", "|--------|--------|-----:|--------|", ]) for item in exports: if item["media_type"] == "application/json": continue lines.append( f"| [{item['title']}]({item['url']}) | `{item['media_type']}` | " f"{_fmt_size(item['bytes'])} | `{item['sha256']}` |" ) lines.extend([ "", "The SQLite file contains indexed tables for content hashes, provenance " "catalog matches, platform declarations, emulator profiles and current " "gaps. It contains metadata only, never BIOS or firmware bytes.", "", "The gaps dataset carries both layers behind one `layer` column: " "`platform` rows are anomalies against a platform's own BIOS list, " "`emulator` rows are files a profiled core loads that no platform " "declares. The two answer different questions and are not comparable " "totals.", "", "## Semantics and limits", "", "Treat four questions separately: content identity (hashes), presence in " "the collection, acceptance by a specific emulator, and catalog provenance. " "One does not imply the others. In particular, presence, a matching dump " "catalog, or emulator compatibility is not a statement about copyright, " "ownership, or redistribution rights.", "", f"The data can be newer than the [latest published pack]({RELEASE_URL}); " "pack publication is manual and only occurs after all release gates pass.", "", "See the [data model](wiki/data-model.md), [verification modes]" "(wiki/verification-modes.md), and [methodology](wiki/architecture.md) " "before interpreting aggregate counts.", "", ]) return "\n".join(lines) def _page_title(markdown: str, path: Path) -> str: match = re.search(r"^#\s+(.+?)\s*$", markdown, flags=re.MULTILINE) if match: title = re.sub(r"<[^>]+>", "", match.group(1)) return title.replace(f" - {SITE_NAME}", "").strip() return path.stem.replace("-", " ").title() def _browser_title(relative: Path, title: str) -> str: """Return a concise, unique browser/search title for a generated page.""" key = relative.as_posix() index_titles = { "index.md": SITE_NAME, "platforms/index.md": "Platforms", "systems/index.md": "Systems", "emulators/index.md": "Emulators", "wiki/index.md": "Guide and methodology", } if key in index_titles: return index_titles[key] if key.startswith("emulators/"): return f"{title} emulator firmware" if key.startswith("systems/"): return f"{title} systems" return title def _plain_markdown(text: str) -> str: text = re.sub(r"<[^>]+>", " ", text) text = re.sub(r"!\[([^]]*)\]\([^)]*\)", r"\1", text) text = re.sub(r"\[([^]]+)\]\([^)]*\)", r"\1", text) text = re.sub(r"[`*_~]", "", text) return re.sub(r"\s+", " ", text).strip() def _page_description(markdown: str, relative: Path, title: str) -> str: key = relative.as_posix() specific = { "index.md": "Source-traced BIOS and firmware metadata, platform verification, emulator profiles, gaps and reproducible retrogaming data exports.", "data.md": "Versioned RetroBIOS JSON API, CSV exports, SQLite snapshot, schemas, checksums and data interpretation guidance.", "cross-reference.md": "Cross-reference from retrogaming platforms to emulator cores, systems, upstream projects and profiled firmware files.", "gaps.md": "Current RetroBIOS verification gaps, platform-to-emulator divergences, missing files and documented source limitations.", "provenance.md": "Hash-based comparison of RetroBIOS metadata with No-Intro, Redump and TOSEC catalog snapshots.", "which-pack.md": "One-line automatic RetroBIOS installation and platform-specific pack destinations, with verification and release caveats.", } if key in specific: return specific[key] if key.startswith("platforms/") and relative.stem != "index": return f"{title}: declared BIOS contract, verification mode, coverage, destinations and emulator complement." if key.startswith("emulators/") and relative.stem != "index": return f"{title}: source-pinned emulator firmware profile with paths, hashes, requirements and validation behavior." if key.startswith("systems/") and relative.stem != "index": return f"{title}: indexed firmware files, hashes, variants, provenance and platform or emulator usage." paragraphs = re.split(r"\n\s*\n", markdown) for paragraph in paragraphs: stripped = paragraph.strip() if ( not stripped or stripped.startswith(("#", "|", "```", "???", "<", "- ", "* ")) ): continue plain = _plain_markdown(stripped) if len(plain) >= 35: return plain[:157].rstrip(" ,;:-") + ("..." if len(plain) > 157 else "") return f"{title}: RetroBIOS source-traced retrogaming firmware reference." def decorate_markdown_pages(docs: Path) -> None: """Add per-page descriptions and machine-readable structured data.""" for path in sorted(docs.rglob("*.md")): relative = path.relative_to(docs) if relative.parts and relative.parts[0] == "superpowers": continue markdown = path.read_text(encoding="utf-8") stripped = _undecorated(markdown) if stripped is not markdown: markdown = stripped elif markdown.startswith("---\n"): continue title = _page_title(markdown, relative) browser_title = _browser_title(relative, title) description = _page_description(markdown, relative, title) if relative.name == "index.md": page_path = "" if len(relative.parts) == 1 else "/".join(relative.parts[:-1]) + "/" else: page_path = relative.with_suffix("").as_posix() + "/" canonical = urllib.parse.urljoin(SITE_URL, page_path) schema_type = "WebSite" if relative.as_posix() == "index.md" else "TechArticle" if relative.as_posix() == "data.md": schema_type = "Dataset" structured = { "@context": "https://schema.org", "@type": schema_type, "name": title, "description": description, "url": canonical, "isPartOf": { "@type": "WebSite", "name": SITE_NAME, "url": SITE_URL, }, } structured_json = json.dumps( structured, ensure_ascii=False, separators=(",", ":") ).replace("\n' f"{structured_json}\n" "\n\n" ) write_decorated(path, front_matter + markdown) # Platform pages def generate_platform_index(coverages: dict, registry: dict | None = None) -> str: total_present = sum(c["present"] for c in coverages.values()) lines = [ f"# Platforms - {SITE_NAME}", "", f"{len(coverages)} supported platforms with " f"{total_present:,} verified files.", "", "| Platform | On its BIOS list | Files in the pack | Checked by | Status | Download |", "|----------|------:|------:|-----------|--------|----------|", ] mode_labels = { "md5": 'MD5', "sha1": 'SHA1', "existence": 'existence', } archived_any = False for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): display = cov["platform"] mode_html = mode_labels.get( cov["mode"], f'{cov["mode"]}', ) status = (registry or {}).get(name, {}).get("status", "active") if status == "archived": archived_any = True status_html = 'archived' else: status_html = 'active' lines.append( f"| [{display}]({name}.md) | " f"{cov['present']:,} | {_released_files(name)} | " f"{mode_html} | {status_html} | " f"[Pack]({RELEASE_URL}){{ .md-button .md-button--primary }} |" ) lines.extend( [ "", "The BIOS list is what the platform publishes. The pack adds the " "files its emulators load and the data directories they read, " "which is the count an extraction shows. " "Checked by is the test each platform runs on its own, replicated " "from its source code. " "[How each mode works](../wiki/verification-modes.md).", ] ) if archived_any: lines.extend( [ "", "An archived platform keeps its configuration and still gets a " "pack, but upstream is no longer scraped on a schedule.", ] ) return "\n".join(lines) + "\n" def _render_platform_systems( by_system: dict, config_files: dict, emulator_files: dict ) -> list[str]: """One collapsible block per system, listing every file it needs. Split out of generate_platform_page: the block is most of that function's branching, since each file renders a different set of hashes, sizes and provenance depending on what the platform declares. """ lines: list[str] = [] # Per-system detail sections (collapsible for large platforms) use_collapsible = len(by_system) > 10 for sys_id, files in sorted(by_system.items()): ok_count = sum(1 for f in files if f["status"] == "ok") total = len(files) sys_emus = [] if emulator_files: for emu_name, emu_data in emulator_files.items(): if sys_id in emu_data.get("systems", set()): sys_emus.append(emu_name) sys_link = _system_link(sys_id, "../") anchor = sys_id.replace(" ", "-") if use_collapsible: status_tag = "OK" if ok_count == total else f"{total - ok_count} issues" lines.append(f'') lines.append(f'??? note "{sys_id} ({ok_count}/{total} - {status_tag})"') lines.append("") pad = " " else: lines.append(f"## {sys_link}") lines.append("") pad = "" lines.append(f"{pad}{ok_count}/{total} files verified") if sys_emus: emu_links = ", ".join(_emulator_link(e, "../") for e in sorted(sys_emus)) lines.append(f"{pad}Emulators: {emu_links}") lines.append("") # File listing for f in sorted(files, key=lambda x: x["name"]): status = f["status"] fname = f["name"] cfg_entry = config_files.get(fname, {}) sha1 = cfg_entry.get("sha1", f.get("sha1", "")) md5 = cfg_entry.get("md5", f.get("expected_md5", "")) size = cfg_entry.get("size", f.get("size", 0)) if status == "ok": status_display = "OK" elif status == "untested": reason = f.get("reason", "") status_display = f"untested: {reason}" if reason else "untested" elif status == "missing": status_display = "**missing**" else: status_display = status size_str = _fmt_size(size) if size else "" details = [status_display] if size_str: details.append(size_str) lines.append(f"{pad}- `{fname}` - {', '.join(details)}") # Show full hashes on a sub-line (useful for copy-paste) if sha1 or md5: hash_parts = [] if sha1: hash_parts.append(f"SHA1: `{sha1}`") if md5: hash_parts.append(f"MD5: `{md5}`") lines.append(f"{pad} {' | '.join(hash_parts)}") lines.append("") return lines def generate_platform_page( name: str, cov: dict, registry: dict | None = None, emulator_files: dict | None = None, ) -> str: config = cov["config"] display = cov["platform"] mode = cov["mode"] pct = _pct(cov["present"], cov["total"]) logo_url = (registry or {}).get(name, {}).get("logo", "") logo_md = ( f"![{display}]({logo_url}){{ width=48 align=right }}\n\n" if logo_url else "" ) homepage = config.get("homepage", "") version = config.get("version", "") hash_type = config.get("hash_type", "") base_dest = config.get("base_destination", "") pct_val = cov["present"] / cov["total"] * 100 if cov["total"] else 0 mode_badge = ( "rb-badge-success" if reads_file_contents(mode) else "rb-badge-info" ) lines = [ f"# {display} - {SITE_NAME}", "", logo_md, ] if (registry or {}).get(name, {}).get("status") == "archived": lines.extend( [ '!!! warning "Archived platform"', "", " The configuration is kept and packs are still built, but " "upstream is no longer scraped on a schedule, so the file list " "reflects the last sync rather than today's upstream.", "", ] ) # Stat cards lines.extend( [ '
', "", '
', f'{cov["present"]}/{cov["total"]}', f'Coverage ({pct})', "
", "", '
', f'{cov["verified"]}', 'Verified', "
", "", '
', f'{cov["missing"]}', 'Missing', "
", "", '
', f'' f'{mode}', 'Checked by', "
", "", "
", "", "| | |", "|---|---|", ] ) if hash_type: lines.append(f"| Hash type | {hash_type} |") if version: lines.append(f"| Version | {version} |") if base_dest: lines.append(f"| BIOS path | `{base_dest}/` |") if homepage: lines.append(f"| Homepage | [{homepage}]({homepage}) |") contrib_list = (registry or {}).get(name, {}).get("contributed_by", []) if contrib_list: for cb in contrib_list: username = cb.get("username", "") contribution = cb.get("contribution", "") pr = cb.get("pr") pr_link = f" ([#{pr}]({REPO_URL}/pull/{pr}))" if pr else "" lines.append( f"| Contributed by | [@{username}](https://github.com/{username})" f" - {contribution}{pr_link} |" ) lines.extend( [ "", f"[Download {display} Pack]({RELEASE_URL})" "{ .md-button .md-button--primary }", "", ] ) # Build lookup from config file entries (has hashes/sizes) config_files: dict[str, dict] = {} for sys_id, system in config.get("systems", {}).items(): for fe in system.get("files", []): fname = fe.get("name", "") if fname: config_files[fname] = fe # Group details by system by_system: dict[str, list] = {} for d in cov["details"]: sys_id = d.get("system", "unknown") by_system.setdefault(sys_id, []).append(d) # System summary table (quick navigation) lines.extend( [ "## Systems overview", "", "| System | Files | Status | Emulators |", "|--------|-------|--------|-----------|", ] ) for sys_id, files in sorted(by_system.items()): ok_count = sum(1 for f in files if f["status"] == "ok") total = len(files) non_ok = total - ok_count if non_ok == 0: status = 'OK' else: status = ( f'' f'{non_ok} issue{"s" if non_ok > 1 else ""}' ) sys_emus = [] if emulator_files: for emu_name, emu_data in emulator_files.items(): if sys_id in emu_data.get("systems", set()): sys_emus.append(emu_name) emu_str = ", ".join(sys_emus[:3]) if len(sys_emus) > 3: emu_str += f" +{len(sys_emus) - 3}" anchor = sys_id.replace(" ", "-") lines.append( f"| [{sys_id}](#{anchor}) | {ok_count}/{total} | {status} | {emu_str} |" ) lines.append("") lines.extend( _render_platform_systems(by_system, config_files, emulator_files) ) lines.append(f"*Generated on {_timestamp()}*") return "\n".join(lines) + "\n" # System pages def _group_by_manufacturer(db: dict) -> dict[str, dict[str, list]]: """Group files by manufacturer -> console -> files.""" manufacturers: dict[str, dict[str, list]] = {} for sha1, entry in db.get("files", {}).items(): path = entry.get("path", "") parts = path.split("/") if len(parts) < 3 or parts[0] != "bios": continue manufacturer = parts[1] console = parts[2] manufacturers.setdefault(manufacturer, {}).setdefault(console, []).append(entry) return manufacturers def generate_systems_index(manufacturers: dict) -> str: total_mfr = len(manufacturers) total_consoles = sum(len(c) for c in manufacturers.values()) total_files = sum( len(files) for consoles in manufacturers.values() for files in consoles.values() ) lines = [ f"# Systems - {SITE_NAME}", "", f"{total_mfr} manufacturers, {total_consoles} consoles, " f"{total_files:,} files in the repository.", "", "| Manufacturer | Consoles | Files |", "|-------------|----------|-------|", ] for mfr in sorted(manufacturers.keys()): consoles = manufacturers[mfr] file_count = sum(len(files) for files in consoles.values()) slug = mfr.lower().replace(" ", "-") lines.append(f"| [{mfr}]({slug}.md) | {len(consoles)} | {file_count} |") return "\n".join(lines) + "\n" _PROVENANCE_LABELS = {"redump": "Redump", "no-intro": "No-Intro", "tosec": "TOSEC"} _PROVENANCE_HOMES = { "redump": "http://redump.org/", "no-intro": "https://no-intro.org/", "tosec": "https://www.tosecdev.org/", } def _prov_title(data: dict) -> str: """Tooltip text for a provenance badge.""" parts = [data.get("dat", ""), data.get("description", "")] return ": ".join(p for p in parts if p).replace('"', """) def generate_provenance_page(db: dict, report: dict) -> str: """Page explaining the verified dump badges and listing catalog gaps.""" matched_files = count_catalog_matched(db) total_files = compute_composition(db)["systems"]["files"] lines = [ f"# Dump provenance - {SITE_NAME}", "", f"**{matched_files:,}** of {total_files:,} system files match an entry " "in a dump-preservation catalog. Those files carry a " 'Verified dump badge on ' "the [system pages](systems/index.md). Arcade ROM sets and engine data " "sit outside what those catalogs index, so they are left out of the " "ratio rather than counted as failures.", "", "## What the badge means", "", "The badge says the file is byte-identical to a dump catalogued by " "No-Intro, Redump, or TOSEC. Matching is done on SHA1, falling back to " "MD5 plus size for older catalog entries that predate SHA1. Filenames " "are never used: the same dump is `fdsbios.nes` here, " "`[BIOS] Family Computer Disk System (Japan) (En) (Rev 1).bin` at " "No-Intro, and " "`Nintendo Famicom Disk System BIOS (198x)(Nintendo)(JP)(en).bin` at " "TOSEC.", "", "## What it does not mean", "", "A file without a badge is not inferior and works exactly the same. " "Emulator behaviour is decided by [verification]" "(wiki/verification-modes.md), which reads the emulator source code, " "not by catalog membership. Plenty of files this project ships are " "outside any catalog by nature: composites a core assembles for itself " "(the MiSTer X68000 `boot.rom` is `cgrom.dat` joined to `iplrom.dat`), " "modern console firmware updates, game data such as `prboom.wad`, and " "arcade sets tracked by MAME driver source instead.", "", "When the two views disagree, this project follows the code, because " "that is what decides whether your emulator boots. The reasoning is in " "the [FAQ](wiki/faq.md#are-these-files-verified-against-original-" "hardware-dumps).", "", "## Coverage", "", "| Catalog | In collection | Covered DATs | Snapshot |", "|---------|--------------|-------------|----------|", ] for source, data in report.items(): label = _PROVENANCE_LABELS.get(source, source) home = _PROVENANCE_HOMES.get(source, "") in_scope = data["matched"] + len(data["missing"]) pct = 100 * data["matched"] / in_scope if in_scope else 0 name = f"[{label}]({home})" if home else label lines.append( f"| {name} | {data['matched']:,}/{in_scope:,} ({pct:.0f}%) | " f"{len(data['covered_dats'])} | {data['imported_at']} |" ) lines.extend( [ "", "Coverage counts only DATs the collection already reaches: a DAT " "counts as covered once at least one of its entries is held. " "No-Intro tags every non-game dump `[BIOS]`, including digital " "title distribution such as the Wii U and 3DS CDN catalogues, " "which this project does not ship.", "", ] ) out_of_scope = {s: d["out_of_scope"] for s, d in report.items() if d["out_of_scope"]} if out_of_scope: detail = ", ".join( f"{_PROVENANCE_LABELS.get(s, s)} {c:,}" for s, c in sorted(out_of_scope.items()) ) lines.extend( [ f"Entries in DATs the collection does not cover at all are " f"excluded from those numbers ({detail}).", "", ] ) total_missing = sum(len(d["missing"]) for d in report.values()) 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 " "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 " "[Contributing](contributing.md).", "", ] ) for source, data in report.items(): if not data["missing"]: continue label = _PROVENANCE_LABELS.get(source, source) by_dat: dict[str, list] = {} for entry in data["missing"]: by_dat.setdefault(entry.get("dat", ""), []).append(entry) lines.extend([f"### {label}", ""]) for dat in sorted(by_dat): entries = by_dat[dat] lines.extend([f'??? note "{dat} ({len(entries)})"', ""]) lines.append(" | Name | Description | SHA1 |") lines.append(" |------|-------------|------|") for entry in sorted(entries, key=lambda e: e["name"]): sha1 = entry.get("sha1") or "-" # A pipe in catalog text would split the markdown table row name = entry["name"].replace("|", "-") desc = (entry.get("description") or "").replace("|", "-") lines.append(f" | `{name}` | {desc} | `{sha1}` |") lines.append("") lines.append(f'
Generated on {_timestamp()}.
') return "\n".join(lines) + "\n" def generate_system_page( manufacturer: str, consoles: dict[str, list], platform_files: dict[str, set], emulator_files: dict[str, dict], ) -> str: manufacturer.lower().replace(" ", "-") lines = [ f"# {manufacturer} - {SITE_NAME}", "", ] for console_name in sorted(consoles.keys()): files = consoles[console_name] icon_md = system_icon_markdown(manufacturer, console_name) lines.append(f"## {icon_md}{console_name}") lines.append("") # Separate main files from variants main_files = [f for f in files if "/.variants/" not in f["path"]] variant_files = [f for f in files if "/.variants/" in f["path"]] for f in sorted(main_files, key=lambda x: x["name"]): name = f["name"] sha1_full = f.get("sha1", "unknown") md5_full = f.get("md5", "unknown") size = _fmt_size(f.get("size", 0)) # Cross-reference plats = sorted(p for p, names in platform_files.items() if name in names) emus = sorted( e for e, data in emulator_files.items() if name in data.get("files", set()) ) # Truncated hashes for readability sha1_short = sha1_full[:12] if sha1_full != "unknown" else "-" md5_short = md5_full[:12] if md5_full != "unknown" else "-" lines.append('
') lines.append("") lines.append( f'**`{name}`** ' f'{size}' ) lines.append("") lines.append( f'- SHA1: `{sha1_short}...`' ) lines.append( f'- MD5: `{md5_short}...`' ) if plats: plat_badges = " ".join( f'' f"[{p}](../platforms/{p}.md)" for p in plats ) lines.append(f"- Platforms: {plat_badges}") if emus: emu_links = [_emulator_link(e, "../") for e in emus] lines.append(f"- Emulators: {', '.join(emu_links)}") provenance = f.get("provenance", {}) if provenance: prov_badges = " ".join( f'' f"[{_PROVENANCE_LABELS.get(s, s)}](../provenance.md#{s})" for s, data in sorted(provenance.items()) ) lines.append(f"- Verified dump: {prov_badges}") lines.append("") lines.append("
") lines.append("") if variant_files: lines.append( f'??? note "Variants ({len(variant_files)})"' ) lines.append("") for v in sorted(variant_files, key=lambda x: x["name"]): vname = v["name"] vmd5 = v.get("md5", "unknown") vmd5_short = vmd5[:12] if vmd5 != "unknown" else "-" lines.append( f' - `{vname}` ' f'' f"MD5: {vmd5_short}..." ) lines.append("") lines.append("") lines.append(f'
Generated on {_timestamp()}.
') return "\n".join(lines) + "\n" # Emulator pages def generate_emulators_index(profiles: dict) -> str: unique = { k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } aliases = {k: v for k, v in profiles.items() if v.get("type") == "alias"} # Group by classification by_class: dict[str, list[tuple[str, dict]]] = {} for name in sorted(unique.keys()): p = unique[name] cls = p.get("core_classification", "other") by_class.setdefault(cls, []).append((name, p)) total_files = sum(len(p.get("files", [])) for p in unique.values()) lines = [ f"# Emulators - {SITE_NAME}", "", f"**{len(unique)}** emulator profiles, **{total_files}** files total, **{len(aliases)}** aliases.", "", "| Classification | Count | Description |", "|---------------|-------|-------------|", ] cls_desc = { "official_port": "Same author maintains both standalone and libretro", "community_fork": "Third-party port to libretro", "pure_libretro": "Built for libretro, no standalone version", "game_engine": "Game engine reimplementation", "enhanced_fork": "Fork with added features", "frozen_snapshot": "Frozen at an old version", "embedded_hle": "All ROMs compiled into binary", "launcher": "Launches an external emulator", "other": "Unclassified", } cls_order = [ "official_port", "community_fork", "pure_libretro", "game_engine", "enhanced_fork", "frozen_snapshot", "embedded_hle", "launcher", "other", ] for cls in cls_order: entries = by_class.get(cls, []) if not entries: continue label = CLS_LABELS.get(cls, cls) desc = cls_desc.get(cls, "") lines.append(f"| [{label}](#{cls}) | {len(entries)} | {desc} |") lines.append("") for cls in cls_order: entries = by_class.get(cls, []) if not entries: continue label = CLS_LABELS.get(cls, cls) desc = cls_desc.get(cls, "") lines.extend( [ f'## {label} {{ #{cls} }}', "", f"*{desc}* -- {len(entries)} profiles", "", "| Engine | Systems | Files |", "|--------|---------|-------|", ] ) for name, p in entries: emu_name = p.get("emulator", name) systems = p.get("systems", []) files = p.get("files", []) sys_str = ", ".join(systems[:3]) if len(systems) > 3: sys_str += f" +{len(systems) - 3}" file_count = len(files) file_str = str(file_count) if file_count else "-" lines.append(f"| [{emu_name}]({name}.md) | {sys_str} | {file_str} |") lines.append("") if aliases: lines.extend(["## Aliases", ""]) lines.append("| Core | Points to |") lines.append("|------|-----------|") for name in sorted(aliases.keys()): parent = aliases[name].get( "alias_of", aliases[name].get("bios_identical_to", "unknown") ) lines.append(f"| {name} | [{parent}]({parent}.md) |") lines.append("") return "\n".join(lines) + "\n" def _file_badges(f: dict, in_repo: bool) -> list[str]: """The status chips shown beside a file name. Fourteen independent optional fields, each contributing at most one chip. Kept apart from the rest of the row so the field-by-field rendering stays readable. """ required = f.get("required", False) hle = f.get("hle_fallback", False) mode = f.get("mode", "") category = f.get("category", "") region = f.get("region", "") storage = f.get("storage", "") bundled = f.get("bundled", False) embedded = f.get("embedded", False) has_builtin = f.get("has_builtin", False) archive = f.get("archive", "") ftype = f.get("type", "") badges = [] if required: badges.append( 'required' ) else: badges.append( 'optional' ) if not in_repo: badges.append( 'missing' ) elif in_repo: badges.append( 'in repo' ) if hle: badges.append( 'HLE fallback' ) if mode: badges.append( f'{mode}' ) if category and category != "bios": badges.append( f'{category}' ) if region: region_str = ( ", ".join(region) if isinstance(region, list) else str(region) ) badges.append( f'{region_str}' ) if storage and storage != "embedded": badges.append( f'{storage}' ) if bundled: badges.append( 'bundled' ) if embedded: badges.append( 'embedded' ) if has_builtin: badges.append( 'built-in fallback' ) if archive: badges.append( f'in {archive}' ) if ftype and ftype != "bios": badges.append( f'{ftype}' ) return badges def _render_emulator_file( f: dict, profile: dict, platform_files: dict | None, files: list, _file_available, ) -> list[str]: """Render one row of an emulator profile's file table. Split out of generate_emulator_page, where it was a 228-line loop body carrying most of that function's branching: thirty-odd optional fields, each with its own badge, hash line or note. """ lines: list[str] = [] fname = f.get("name", "") required = f.get("required", False) in_repo = _file_available(f) source_ref = f.get("source_ref", "") mode = f.get("mode", "") hle = f.get("hle_fallback", False) aliases = f.get("aliases", []) category = f.get("category", "") validation = f.get("validation", []) size = f.get("size") fnote = f.get("note", f.get("notes", "")) storage = f.get("storage", "") fmd5 = f.get("md5", "") fsha1 = f.get("sha1", "") fcrc32 = f.get("crc32", "") fsha256 = f.get("sha256", "") fadler32 = f.get("known_hash_adler32", "") fmin = f.get("min_size") fmax = f.get("max_size") desc = f.get("description", "") region = f.get("region", "") archive = f.get("archive", "") fpath = f.get("path", "") fsystem = f.get("system", "") priority = f.get("priority") fast_boot = f.get("fast_boot") bundled = f.get("bundled", False) embedded = f.get("embedded", False) has_builtin = f.get("has_builtin", False) contents = f.get("contents", []) config_key = f.get("config_key", "") dest = f.get("dest", f.get("destination", "")) ftype = f.get("type", "") fpattern = f.get("pattern", "") region_check = f.get("region_check") size_note = f.get("size_note", "") size_range = f.get("size_range", "") badges = _file_badges(f, in_repo) badge_str = " ".join(badges) border_cls = ( "rb-file-entry-required" if required else "rb-file-entry-optional" ) lines.append( f'
' ) lines.append("") lines.append(f"**`{fname}`** {badge_str}") if desc: lines.append(f"
{_escape_tags(str(desc))}") lines.append("") details = [] if fpath and fpath != fname: details.append(f"Path: `{fpath}`") if fsystem: details.append(f"System: {_system_link(fsystem, '../')}") if size: if isinstance(size, list): size_str = " / ".join(_fmt_size(s) for s in size) else: size_str = _fmt_size(size) if fmin or fmax: bounds = [] if fmin: bounds.append(f"min {_fmt_size(fmin)}") if fmax: bounds.append(f"max {_fmt_size(fmax)}") size_str += f" ({', '.join(bounds)})" details.append(f"Size: {size_str}") elif fmin or fmax: bounds = [] if fmin: bounds.append(f"min {_fmt_size(fmin)}") if fmax: bounds.append(f"max {_fmt_size(fmax)}") details.append(f"Size: {', '.join(bounds)}") if fsha1: details.append(f"SHA1: {_hash_spans(fsha1)}") if fmd5: details.append(f"MD5: {_hash_spans(fmd5)}") if fcrc32: details.append(f"CRC32: `{fcrc32}`") if fsha256: details.append(f"SHA256: {_hash_spans(fsha256)}") if fadler32: details.append(f"Adler32: `{fadler32}`") if aliases: details.append(f"Aliases: {', '.join(f'`{a}`' for a in aliases)}") if priority is not None: details.append(f"Priority: {priority}") if fast_boot is not None: details.append(f"Fast boot: {'yes' if fast_boot else 'no'}") if validation: if isinstance(validation, list): details.append(f"Validation: {', '.join(validation)}") elif isinstance(validation, dict): for scope, checks in validation.items(): details.append(f"Validation ({scope}): {', '.join(checks)}") if source_ref: details.append( f"Source: {_source_ref_markdown(profile, source_ref)}" ) if platform_files: plats = sorted( p for p, names in platform_files.items() if fname in names ) if plats: plat_links = [_platform_link(p, p, "../") for p in plats] details.append(f"Platforms: {', '.join(plat_links)}") if dest and dest != fname and dest != fpath: details.append(f"Destination: `{dest}`") if config_key: details.append(f"Config key: `{config_key}`") if fpattern: details.append(f"Pattern: `{fpattern}`") if region_check is not None: details.append(f"Region check: {'yes' if region_check else 'no'}") if size_note: details.append(f"Size note: {size_note}") if size_range: details.append(f"Size range: {size_range}") if details: for d in details: lines.append(f"- {d}") if fnote: lines.append(f"- {_escape_tags(str(fnote))}") if contents: lines.append(f"- Contents ({len(contents)} entries):") for c in contents[:10]: if isinstance(c, dict): cname = c.get("name", "") cdesc = c.get("description", "") csize = c.get("size", "") parts = [f"`{cname}`"] if cdesc: parts.append(cdesc) if csize: parts.append(_fmt_size(csize)) lines.append(f" - {' -'.join(parts)}") else: lines.append(f" - {c}") if len(contents) > 10: lines.append(f" - ... and {len(contents) - 10} more") lines.append("") lines.append("
") lines.append("") return lines def _render_metadata_rows(profile: dict) -> list[str]: """Scalar profile fields, one table row each. Anything structured belongs in a collapsible block below, not in a row: a dict rendered into a table cell is unreadable. """ lines: list[str] = [] for field, label in [ ("core", "Core ID"), ("core_name", "Core name"), ("bios_size", "BIOS size"), ("bios_directory", "BIOS directory"), ("bios_detection", "BIOS detection"), ("bios_selection", "BIOS selection"), ("firmware_file", "Firmware file"), ("firmware_source", "Firmware source"), ("firmware_install", "Firmware install"), ("firmware_detection", "Firmware detection"), ("resources_directory", "Resources directory"), ("rom_path", "ROM path"), ("game_count", "Game count"), ("verification", "Checked by"), ("analysis_date", "Analysis date"), ("analysis_commit", "Analysis commit"), ]: val = profile.get(field) if val is None or val == "" or isinstance(val, (dict, list)): continue if isinstance(val, str) and val.startswith("http"): lines.append(f"| {label} | [{val}]({val}) |") else: lines.append(f"| {label} | {val} |") if profile.get("source_ref"): lines.append( f"| Source ref | {_source_ref_markdown(profile, profile['source_ref'])} |" ) return lines def _render_platform_details(profile: dict) -> list[str]: """The per-platform block, one level of nesting deep.""" lines: list[str] = [] platform_details = profile.get("platform_details") if platform_details and isinstance(platform_details, dict): lines.extend(['???+ info "Platform details"', ""]) for pk, pv in platform_details.items(): if isinstance(pv, dict): lines.append(f" **{pk}:**") for sk, sv in pv.items(): lines.append(f" - {sk}: {_plain_value(sv)}") else: lines.append(f" **{pk}:** {_plain_value(pv)}") lines.append("") return lines def _plain_value(value: object) -> str: """A scalar or a list of scalars as a reader writes it, not as Python does.""" if isinstance(value, bool): return "yes" if value else "no" if isinstance(value, (list, tuple)): return ", ".join(_plain_value(item) for item in value) return _escape_tags(str(value)) def _render_structured_blocks(profile: dict) -> list[str]: """Every remaining structured field, as a collapsible section.""" lines: list[str] = [] _structured_blocks = [ ("analysis", "Source analysis"), ("memory_layout", "Memory layout"), ("regions", "Regions"), ("nvm_layout", "NVM layout"), ("model_kickstart_map", "Model kickstart map"), ("builtin_boot_roms", "Built-in boot ROMs"), ("common_bios_filenames", "Common BIOS filenames"), ("valid_bios_crc32", "Valid BIOS CRC32"), ("dev_flash", "dev_flash"), ("dev_flash2", "dev_flash2"), ("dev_flash3", "dev_flash3"), ("firmware_modules", "Firmware modules"), ("firmware_titles", "Firmware titles"), ("fallback_fonts", "Fallback fonts"), ("io_devices", "I/O devices"), ("partitions", "Partitions"), ("mlc_structure", "MLC structure"), ("machine_directories", "Machine directories"), ("machine_properties", "Machine properties"), ("whdload_kickstarts", "WHDLoad kickstarts"), ("bios_identical_to", "BIOS identical to"), ("pack_structure", "Pack structure"), ("firmware_version", "Firmware version"), ] for field, label in _structured_blocks: val = profile.get(field) if val is None: continue lines.append(f'???+ abstract "{label}"') lines.append("") start = len(lines) _render_yaml_value(lines, val, indent=4) lines[start:] = [_escape_tags(line) for line in lines[start:]] lines.append("") return lines def _hash_spans(value: str | list) -> str: """One abbreviated hash, or each of the hashes a profile accepts.""" values = [value] if isinstance(value, str) else [str(v) for v in value] return ", ".join( f'`{v[:12]}...`' for v in values ) def _availability_check(db: dict, data_names): """A predicate answering whether the collection holds a file. It answers the way cross_reference does. A page that called a file missing while the gap report called it held would describe a different collection on two pages of the same site. """ from cross_reference import entry_source by_name = db.get("indexes", {}).get("by_name", {}) by_name_lower = {k.lower(): k for k in by_name} by_path_suffix = db.get("indexes", {}).get("by_path_suffix", {}) index = { "by_name": by_name, "by_name_lower": by_name_lower, "by_path_suffix": by_path_suffix, "by_md5": db.get("indexes", {}).get("by_md5", {}), "by_sha256": db.get("indexes", {}).get("by_sha256", {}), "by_crc32": db.get("indexes", {}).get("by_crc32", {}), "db_files": db.get("files", {}), "data_names": data_names, } def _file_available(f: dict) -> bool: """Check if a file is available using the same resolution as cross_reference.""" if not f.get("name"): return False if f.get("storage", "") in ("release", "large_file"): return True return entry_source(f, index) is not None return _file_available def generate_emulator_page( name: str, profile: dict, db: dict, platform_files: dict | None = None, data_names: set[str] | None = None, ) -> str: if profile.get("type") == "alias": parent = profile.get("alias_of", profile.get("bios_identical_to", "unknown")) return ( f"# {name} - {SITE_NAME}\n\n" f"This core uses the same firmware as **{parent}**.\n\n" f"See [{parent}]({parent}.md) for details.\n" ) emu_name = profile.get("emulator", name) emu_type = profile.get("type", "unknown") classification = profile.get("core_classification", "") source_raw = profile.get("source", "") source = str(source_raw) if not isinstance(source_raw, dict) else "" upstream_raw = profile.get("upstream", "") upstream = str(upstream_raw) if not isinstance(upstream_raw, dict) else "" version = profile.get("core_version", "unknown") profile.get("display_name", emu_name) profiled = profile.get("profiled_date", "unknown") systems = profile.get("systems", []) cores = profile.get("cores", [name]) files = profile.get("files", []) notes_raw = profile.get("notes", profile.get("note", "")) notes = ( str(notes_raw).strip() if notes_raw and not isinstance(notes_raw, dict) else "" ) exclusion = profile.get("exclusion_note", "") data_dirs = profile.get("data_directories", []) lines = [ f"# {emu_name} - {SITE_NAME}", "", '
', "", "| | |", "|---|---|", f"| Type | {emu_type} |", ] if classification: cls_display = CLS_LABELS.get(classification, classification) lines.append(f"| Classification | {cls_display} |") if isinstance(source_raw, dict): parts = [] for k, v in source_raw.items(): if isinstance(v, str) and v.startswith("http"): parts.append(f"[{k}]({v})") else: parts.append(f"{k}: {v}") lines.append(f"| Source | {', '.join(parts)} |") elif source: if source.startswith("http"): lines.append(f"| Source | [{source}]({source}) |") else: lines.append(f"| Source | {source} |") if isinstance(upstream_raw, dict): parts = [] for k, v in upstream_raw.items(): if isinstance(v, str) and v.startswith("http"): parts.append(f"[{k}]({v})") else: parts.append(f"{k}: {v}") lines.append(f"| Upstream | {', '.join(parts)} |") elif upstream and upstream != source: if upstream.startswith("http"): lines.append(f"| Upstream | [{upstream}]({upstream}) |") else: lines.append(f"| Upstream | {upstream} |") lines.append(f"| Version | {version} |") lines.append(f"| Profiled | {profiled} |") if cores: lines.append(f"| Cores | {', '.join(str(c) for c in cores)} |") if systems: sys_links = [_system_link(s, "../") for s in systems] lines.append(f"| Systems | {', '.join(sys_links)} |") mame_ver = profile.get("mame_version", "") if mame_ver: lines.append(f"| MAME version | {mame_ver} |") author = profile.get("author", "") if author: lines.append(f"| Author | {author} |") based_on = profile.get("based_on", "") if based_on: lines.append(f"| Based on | {based_on} |") lines.extend(_render_metadata_rows(profile)) lines.append("") lines.append("
") lines.append("") lines.extend(_render_platform_details(profile)) lines.extend(_render_structured_blocks(profile)) # Notes if notes: indented = _admonition_body(notes) lines.extend(['???+ note "Technical notes"', f" {indented}", ""]) if not files: lines.append("No BIOS or firmware files required.") if exclusion: lines.extend( [ "", '!!! info "Why no files"', f" {_escape_tags(str(exclusion))}", ] ) else: _file_available = _availability_check(db, data_names) # Stats by category bios_files = [f for f in files if f.get("category", "bios") == "bios"] game_data = [f for f in files if f.get("category") == "game_data"] bios_zips = [f for f in files if f.get("category") == "bios_zip"] in_repo_count = sum(1 for f in files if _file_available(f)) missing_count = len(files) - in_repo_count req_count = sum(1 for f in files if f.get("required")) opt_count = len(files) - req_count hle_count = sum(1 for f in files if f.get("hle_fallback")) parts = [f"**{len(files)} files**"] parts.append(f"{req_count} required, {opt_count} optional") parts.append(f"{in_repo_count} in repo, {missing_count} missing") if hle_count: parts.append(f"{hle_count} with HLE fallback") lines.append(" | ".join(parts)) if game_data or bios_zips: cats = [] if bios_files: cats.append(f"{len(bios_files)} BIOS") if game_data: cats.append(f"{len(game_data)} game data") if bios_zips: cats.append(f"{len(bios_zips)} BIOS ZIPs") lines.append(f"Categories: {', '.join(cats)}") lines.append("") # File table for f in files: lines.extend( _render_emulator_file( f, profile, platform_files, files, _file_available ) ) # Data directories if data_dirs: lines.extend(["## Data directories", ""]) for dd in data_dirs: ref = dd.get("ref", "") dest = dd.get("destination", "") lines.append(f"- `{ref}` >`{dest}`") lines.append("") lines.extend([f"*Generated on {_timestamp()}*"]) return "\n".join(lines) + "\n" # Contributing page def _render_problem_files(platform_problems: list) -> list[str]: """Files a platform declares that its own check then rejects. Split out of generate_gap_analysis, which reached complexity 56. This section is the one that stands alone: it reads platform_problems and nothing else, where the sections around it share gap_report and the resolved core list. """ lines: list[str] = [] # ---- Section 2: Problem files ---- missing_files: dict[str, dict] = {} untested_files: dict[str, dict] = {} mismatch_files: dict[str, dict] = {} for p in platform_problems: fname = p["name"] if p["status"] == "missing": entry = missing_files.setdefault(fname, { "name": fname, "required": p["required"], "platforms": [], "reason": p["reason"], }) entry["platforms"].append(p["platform"]) if p["required"]: entry["required"] = True elif p["status"] == "untested": entry = untested_files.setdefault(fname, { "name": fname, "required": p["required"], "platforms": [], "reason": p["reason"], }) entry["platforms"].append(p["platform"]) if p.get("discrepancy"): entry = mismatch_files.setdefault(fname, { "name": fname, "platforms": [], "discrepancy": p["discrepancy"], }) entry["platforms"].append(p["platform"]) total_problems = len(missing_files) + len(untested_files) + len(mismatch_files) if total_problems > 0: lines.extend([ "## Problem Files", "", f"{len(missing_files)} missing, {len(untested_files)} untested, " f"{len(mismatch_files)} hash mismatch.", "", ]) if missing_files: lines.extend([ f'### Missing ' f"{len(missing_files)} files", "", "| File | Required | Platforms |", "|------|----------|-----------|", ]) for fname in sorted(missing_files): f = missing_files[fname] req = "yes" if f["required"] else "no" plats = ", ".join(sorted(set(f["platforms"]))) lines.append(f"| `{fname}` | {req} | {plats} |") lines.append("") if untested_files: lines.extend([ f'### Untested ' f"{len(untested_files)} files", "", "Present but hash not verified.", "", "| File | Platforms | Reason |", "|------|----------|--------|", ]) for fname in sorted(untested_files): f = untested_files[fname] plats = ", ".join(sorted(set(f["platforms"]))) lines.append(f"| `{fname}` | {plats} | {f['reason']} |") lines.append("") if mismatch_files: lines.extend([ f'### Hash Mismatch ' f"{len(mismatch_files)} files", "", "Platform says OK but emulator validation disagrees.", "", "| File | Platforms | Discrepancy |", "|------|----------|-------------|", ]) for fname in sorted(mismatch_files): f = mismatch_files[fname] plats = ", ".join(sorted(set(f["platforms"]))) lines.append(f"| `{fname}` | {plats} | {f['discrepancy']} |") lines.append("") return lines def generate_gap_analysis( profiles: dict, coverages: dict, db: dict, data_names: set[str] | None = None, registry: dict | None = None, gap_report: dict | None = None, ) -> str: """Generate a unified gap analysis page. Combines verification results (from coverages/verify.py) with source provenance (from cross_reference) into a single truth dashboard. Sections: 1. Verification status -- aggregated across all platforms 2. Problem files -- missing, untested, hash mismatch 3. Core complement -- emulator files not declared by any platform """ from cross_reference import cross_reference as run_cross_reference from common import resolve_platform_cores # ---- Section 1: aggregate verify results across all platforms ---- total_verified = 0 total_untested = 0 total_missing_verify = 0 total_files_verify = 0 platform_problems: list[dict] = [] for pname, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): total_verified += cov["verified"] total_untested += cov["untested"] total_missing_verify += cov["missing"] total_files_verify += cov["total"] for d in cov["details"]: if d["status"] != "ok" or d.get("discrepancy"): platform_problems.append({ "platform": cov["platform"], "platform_key": pname, "name": d["name"], "status": d["status"], "required": d.get("required", True), "reason": d.get("reason", ""), "discrepancy": d.get("discrepancy", ""), "system": d.get("system", ""), }) pct_verified = ( f"{total_verified / total_files_verify * 100:.0f}%" if total_files_verify else "0%" ) lines = [ f"# Gap Analysis - {SITE_NAME}", "", "Unified view of BIOS verification, file provenance, and coverage gaps.", "", "[Download gaps CSV](downloads/gaps.csv){ .md-button } " "[Open gaps API](api/v1/gaps.json){ .md-button } " "[All data exports](data.md){ .md-button }", "", '
', "", '
', f'{total_files_verify:,}', 'Total files (all platforms)', "
", "", '
', f'{total_verified:,}', f'Verified ({pct_verified})', "
", "", '
', f'{total_untested:,}', 'Untested', "
", "", '
', f'{total_missing_verify:,}', 'Missing', "
", "", "
", "", ] # ---- Verification per platform ---- lines.extend([ "## What Each Pack Contains", "", "| Platform | On its BIOS list | Files its emulators load | Checked by |", "|----------|-----------------:|-------------------------:|------------|", ]) mode_labels = { "md5": "MD5 hash", "sha1": "SHA1 hash", "existence": "file presence", } def _ratio(done: int, total: int) -> str: """A collected-over-needed cell, flagged when short.""" if not total: return "-" cell = f"{done:,}/{total:,}" if done < total: return f'{cell}' return cell for pname, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): display = cov["platform"] core_total = ( cov["core_present"] + cov["core_missing"] + cov["core_unsourceable"] ) lines.append( f"| [{display}](platforms/{pname}.md) " f"| {_ratio(cov['present'], cov['total'])} " f"| {_ratio(cov['core_present'], core_total)} " f"| {mode_labels.get(cov['mode'], cov['mode'])} |" ) lines.extend([ "", "Each fraction is what the pack has over what is needed, counting " "required and optional files alike since both ship. The first column " "is the BIOS list the platform publishes. The second counts files its " "emulators load that this list never mentions, found by reading their " "source code, and it is routinely several times larger; a short " "fraction is flagged and named in the sections below. That second " "number is a floor, not a ceiling: an emulator that accepts any file " "handed to it names none in its code, so nothing there can be counted. " "Checked by is the test the platform runs on its own, replicated here " "from its source code " "([how each one works](wiki/verification-modes.md)).", "", "## Corroboration Against Emulator Source", "", "| Platform | On its BIOS list | Documented in a profile | Content the code checks |", "|----------|-----------------:|------------------------:|------------------------:|", ]) for pname, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): gt = cov["ground_truth"] if not gt.get("applicable", True): 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})" gt_pct = f"{gt['with_validation'] / gt['total'] * 100:.0f}%" gt_cell = f"{gt['with_validation']} ({gt_pct})" else: prof_cell = gt_cell = "0" lines.append( f"| [{cov['platform']}](platforms/{pname}.md) " f"| {gt['total']:,} | {prof_cell} | {gt_cell} |" ) lines.extend([ "", "Both columns count the files on the platform's own BIOS list, the " "same number as the first column of the table above. Documented in a " "profile means an emulator profile, written from source, describes the " "file. Content the code checks is stricter: the emulator verifies a " "size or hash for it, and this tool repeats that check. The gap " "between the two is " "not a defect, it is what the emulator code does: many emulators load " "a file without ever checking its content, and no amount of profiling " "can invent a check the code does not perform. A dash means no " "profiled emulator applies to the platform, whose own source is then " "the only authority.", "", _content_check_ceiling(profiles), "", ]) # ---- Section 1b: platform lists vs emulator source ---- from truth import diff_platform_truth, generate_platform_truth div_rows = [] for pname, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): truth_data = generate_platform_truth( pname, cov["config"], (registry or {}).get(pname, {}), profiles, db ) s = diff_platform_truth(truth_data, cov["config"])["summary"] if s["systems_compared"] == 0: div_rows.append( f"| [{cov['platform']}](platforms/{pname}.md) | - | - | - | - |" ) else: div_rows.append( f"| [{cov['platform']}](platforms/{pname}.md) " f"| {s['total_missing']} " f"| {s['total_extra_phantom'] + s['total_extra_unprofiled']} " f"| {s['total_hash_mismatch']} " f"| {s['total_required_mismatch']} |" ) lines.extend([ "## Platform Lists vs Emulator Source", "", "Platform file lists are scraped as-is from each upstream project. " "Emulator profiles are read from source code. The two do not always " "agree, and this table counts the differences. Packs follow the " "platform contract; these numbers show where that contract diverges " "from what the code loads.", "", "| Platform | Missing from list | Phantom | Hash conflict | Required status |", "|----------|------------------:|--------:|--------------:|----------------:|", *div_rows, "", "- **Missing from list**: a profiled emulator loads the file, the platform list does not mention it", "- **Phantom**: on the platform list, loaded by no profiled emulator", "- **Hash conflict**: the platform list and the emulator source expect different hashes", "- **Required status**: required/optional differs between list and code", "", "A dash means no profiled emulator overlaps the platform's systems, " "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 " "corrected list can be regenerated in its native format " "(System.dat, es_bios.xml, batocera-systems.json, ...).", "", ]) lines.extend(_render_problem_files(platform_problems)) # ---- Section 3: Core complement (cross-reference provenance) ---- unique_profiles = { k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } relevant_set: set[str] = set() for _name, cov in coverages.items(): matched = resolve_platform_cores(cov["config"], unique_profiles) relevant_set.update(matched) if gap_report is None: gap_report = build_emulator_gap_report(profiles, coverages, db, data_names) report_all = gap_report src_totals: dict[str, int] = {"bios": 0, "data": 0, "large_file": 0, "missing": 0} total_undeclared = 0 emulator_gaps = [] for emu_name, data in sorted(report_all.items()): if data["gaps"] == 0: continue total_undeclared += data["gaps"] for key in src_totals: src_totals[key] += data.get(f"gap_{key}", 0) emulator_gaps.append((emu_name, data)) if total_undeclared > 0: total_available = ( src_totals["bios"] + src_totals["data"] + src_totals["large_file"] ) pct_available = ( f"{total_available / total_undeclared * 100:.0f}%" if total_undeclared else "0%" ) lines.extend([ "## Core Complement", "", f"Files loaded by emulators but not declared by any platform. " f"{total_undeclared:,} files across {len(emulator_gaps)} emulators, " f"{total_available:,} available ({pct_available}), " 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. " "Whatever is not in the collection is an acquisition target, named " "per emulator below.", "", "### Provenance", "", "| Source | Count | Description |", "|--------|------:|-------------|", f"| bios/ | {src_totals['bios']} | In repository (database.json) |", f"| data/ | {src_totals['data']} | Data directories (buildbot, GitHub) |", f"| release | {src_totals['large_file']} " "| GitHub release assets (large files) |", f"| missing | {src_totals['missing']} | Not available, needs sourcing |", "", "### Per Emulator", "", "| Emulator | Undeclared | bios | data | release | Missing |", "|----------|----------:|-----:|-----:|--------:|--------:|", ]) for emu_name, data in sorted(emulator_gaps, key=lambda x: -x[1]["gaps"]): display = data["emulator"] m = data.get("gap_missing", 0) missing_str = ( f'{m}' if m > 0 else '0' ) lines.append( f"| [{display}](emulators/{emu_name}.md) " f"| {data['gaps']} " f"| {data.get('gap_bios', 0)} " f"| {data.get('gap_data', 0)} " f"| {data.get('gap_large_file', 0)} " f"| {missing_str} |" ) lines.append("") # List truly missing files with platform impact emu_to_platforms: dict[str, set[str]] = {} unique_profiles = { k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } for pname in coverages: config = coverages[pname]["config"] matched = resolve_platform_cores(config, unique_profiles) for emu_name in matched: emu_to_platforms.setdefault(emu_name, set()).add(pname) all_src_missing: set[str] = set() src_missing_details: list[dict] = [] for emu_name, data in emulator_gaps: for g in data["gap_details"]: if g["source"] == "missing" and g["name"] not in all_src_missing: all_src_missing.add(g["name"]) src_missing_details.append({ "name": g["name"], "emulator": data["emulator"], "emu_key": emu_name, "required": g["required"], "source_ref": g["source_ref"], }) if src_missing_details: req_src = [m for m in src_missing_details if m["required"]] lines.extend([ f"### Files to Source ({len(src_missing_details)} unique, " f"{len(req_src)} required)", "", "| File | Emulator | Required | Affects platforms | Source ref |", "|------|----------|----------|------------------|-----------|", ]) for m in sorted( src_missing_details, key=lambda x: (not x["required"], x["name"]), ): plats = sorted(emu_to_platforms.get(m["emu_key"], set())) plat_badges = ( " ".join( f'{p}' for p in plats ) if plats else "-" ) req = "yes" if m["required"] else "no" lines.append( f"| `{m['name']}` | {m['emulator']} | {req} | " f"{plat_badges} | " f"{_source_ref_markdown(profiles[m['emu_key']], m['source_ref'])} |" ) lines.append("") # ---- Section 4: Acknowledged gaps (unsourceable files) ---- all_unsourceable: list[dict] = [] for emu_name, data in sorted(report_all.items()): for u in data.get("unsourceable", []): all_unsourceable.append({ "name": u["name"], "emulator": data["emulator"], "emu_key": emu_name, "reason": u["reason"], "source_ref": u.get("source_ref", ""), }) if all_unsourceable: lines.extend([ "## Acknowledged Gaps", "", f"{len(all_unsourceable)} files documented as unsourceable " "(verified from source code).", "", "| File | Emulator | Reason | Source ref |", "|------|----------|--------|-----------|", ]) for u in sorted(all_unsourceable, key=lambda x: x["name"]): lines.append( f"| `{u['name']}` | {u['emulator']} | {u['reason']} " f"| {_source_ref_markdown(profiles[u['emu_key']], u['source_ref'])} |" ) lines.append("") lines.extend(["", f'
Generated on {_timestamp()}.
']) return "\n".join(lines) + "\n" def _render_xref_by_platform( coverages: dict, unique: dict ) -> list[str]: """Which cores each platform pulls in, and what they need.""" lines: list[str] = [] # Per platform for pname in sorted(coverages.keys(), key=lambda x: coverages[x]["platform"]): cov = coverages[pname] display = cov["platform"] config = cov["config"] platform_cores = config.get("cores", []) lines.append(f'??? abstract "{display}"') lines.append("") lines.append( f" [Open {display} platform profile](platforms/{pname}.md)" "{ .md-button }" ) lines.append("") # The cores the builder and verify resolve: a hand copy here dropped # the libretro set a list naming retroarch pulls in, and the system # fallback EmuDeck relies on. matched = { key: unique[key] for key in resolve_platform_cores(config, unique) if key in unique } if platform_cores == "all_libretro": lines.append(f" **{len(matched)} cores** (all libretro)") else: lines.append(f" **{len(matched)} cores**") lines.append("") lines.append(" | Core | Classification | Systems | Files | Upstream |") lines.append(" |------|---------------|---------|-------|----------|") for emu_name in sorted(matched.keys()): p = matched[emu_name] emu_display = p.get("emulator", emu_name) cls_raw = p.get("core_classification", "-") cls = CLS_LABELS.get(cls_raw, cls_raw) p.get("type", "") upstream_raw2 = p.get("upstream", "") upstream = str(upstream_raw2) if not isinstance(upstream_raw2, dict) else "" source_raw2 = p.get("source", "") source = str(source_raw2) if not isinstance(source_raw2, dict) else "" systems = p.get("systems", []) files = p.get("files", []) sys_str = ", ".join(systems[:3]) if len(systems) > 3: sys_str += f" +{len(systems) - 3}" file_count = len(files) # Count mode divergences libretro_only = sum(1 for f in files if f.get("mode") == "libretro") standalone_only = sum(1 for f in files if f.get("mode") == "standalone") file_str = str(file_count) if libretro_only or standalone_only: parts = [] if libretro_only: parts.append(f"{libretro_only} libretro-only") if standalone_only: parts.append(f"{standalone_only} standalone-only") file_str += f" ({', '.join(parts)})" upstream_display = "-" if upstream and upstream.startswith("http"): upstream_short = upstream.replace("https://github.com/", "") upstream_display = f"[{upstream_short}]({upstream})" elif upstream: upstream_display = upstream elif source and source.startswith("http"): source_short = source.replace("https://github.com/", "") upstream_display = f"[{source_short}]({source})" elif source: upstream_display = source lines.append( f" | [{emu_display}](emulators/{emu_name}.md) | {cls} | " f"{sys_str} | {file_str} | {upstream_display} |" ) lines.append("") return lines def _render_xref_by_upstream( coverages: dict, unique: dict ) -> list[str]: """The same relation read the other way: one row per upstream.""" lines: list[str] = [] # Reverse view: by upstream emulator lines.extend( [ "## By upstream emulator", "", "| Upstream | Cores | Classification | Platforms |", "|----------|-------|---------------|-----------|", ] ) # Group profiles by upstream by_upstream: dict[str, list[str]] = {} for emu_name, p in sorted(unique.items()): raw_up = p.get("upstream", p.get("source", "")) up_str = str(raw_up) if not isinstance(raw_up, dict) else "" if up_str: by_upstream.setdefault(up_str, []).append(emu_name) # Build platform membership per core platform_membership: dict[str, set[str]] = {} for pname, cov in coverages.items(): for key in resolve_platform_cores(cov["config"], unique): platform_membership.setdefault(key, set()).add(pname) for upstream_url in sorted(by_upstream.keys()): cores = by_upstream[upstream_url] upstream_short = upstream_url.replace("https://github.com/", "") classifications = set() all_plats: set[str] = set() for c in cores: raw_cls = unique[c].get("core_classification", "-") classifications.add(CLS_LABELS.get(raw_cls, raw_cls)) all_plats.update(platform_membership.get(c, set())) cls_str = ", ".join(sorted(classifications)) plat_str = ", ".join(sorted(all_plats)) if all_plats else "-" core_links = ", ".join(f"[{c}](emulators/{c}.md)" for c in sorted(cores)) if upstream_url.startswith("http"): upstream_cell = f"[{upstream_short}]({upstream_url})" else: upstream_cell = upstream_short lines.append( f"| {upstream_cell} | {core_links} | " f"{cls_str} | {plat_str} |" ) return lines def generate_cross_reference( coverages: dict, profiles: dict, ) -> str: """Generate cross-reference: Platform -> Core -> Systems -> Upstream.""" unique = { k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } total_cores = len(unique) total_upstreams = len({ p.get("upstream", p.get("source", "")) for p in unique.values() if p.get("upstream") or p.get("source") }) lines = [ f"# Cross-reference - {SITE_NAME}", "", f"Platform > Core > Systems > Upstream emulator. " f"{total_cores} cores across {len(coverages)} platforms, " f"tracing back to {total_upstreams} upstream projects.", "", "The libretro core is a port of the upstream emulator. " "Files, features, and validation may differ between the two.", "", "[Download cross-reference CSV](downloads/cross-reference.csv){ .md-button } " "[Open emulator API](api/v1/emulators.json){ .md-button } " "[All data exports](data.md){ .md-button }", "", ] lines.extend(_render_xref_by_platform(coverages, unique)) lines.extend(_render_xref_by_upstream(coverages, unique)) lines.extend(["", f"*Generated on {_timestamp()}*"]) return "\n".join(lines) + "\n" def generate_contributing() -> str: return contributing_text( lambda page: f"{page}.md", "# Contributing - RetroBIOS", "on this site" ) # Wiki pages # index, architecture, tools, profiling are maintained as wiki/ sources # and copied verbatim by main(). Only data-model is generated dynamically. def generate_wiki_data_model(db: dict, profiles: dict) -> str: """Generate data model documentation from actual database structure.""" files_count = len(db.get("files", {})) by_md5 = len(db.get("indexes", {}).get("by_md5", {})) by_name = len(db.get("indexes", {}).get("by_name", {})) by_crc32 = len(db.get("indexes", {}).get("by_crc32", {})) by_path = len(db.get("indexes", {}).get("by_path_suffix", {})) by_sha256 = len(db.get("indexes", {}).get("by_sha256", {})) lines = [ f"# Data model - {SITE_NAME}", "", "## database.json", "", f"Primary key: SHA1. **{files_count}** file entries.", "", "Each entry:", "", "```json", "{", ' "path": "bios/Nintendo/GameCube/GC/USA/IPL.bin",', ' "name": "IPL.bin",', ' "size": 2097152,', ' "sha1": "...",', ' "md5": "...",', ' "sha256": "...",', ' "crc32": "...",', ' "adler32": "...",', ' "provenance": {', ' "redump": {"dat": "...", "name": "...", "description": "..."}', " }", "}", "```", "", "`provenance` maps each catalog that lists the file to the DAT and entry " "it was matched against. It is present only when the file matches a " "snapshot under `provenance/`; the join runs by SHA1 first, then by " "MD5 + size. See [dump provenance](../provenance.md).", "", "### Indexes", "", "| Index | Entries | Purpose |", "|-------|---------|---------|", f"| `by_md5` | {by_md5} | MD5 to SHA1 lookup (Batocera, Recalbox verification) |", f"| `by_name` | {by_name} | filename to SHA1 list (name-based resolution) |", f"| `by_crc32` | {by_crc32} | CRC32 to SHA1 lookup |", f"| `by_path_suffix` | {by_path} | relative path to SHA1 (regional variant disambiguation) |", f"| `by_sha256` | {by_sha256} | SHA256 to SHA1 lookup (emulator profile validation) |", "", "### File resolution order", "", "`resolve_local_file` tries these steps in order:", "", "1. SHA1 exact match; every other declared hash must agree with the record", "2. SHA256 exact match, with the same all-declarations-must-agree rule", "3. CRC32 plus declared size, only when no stronger hash is present", "4. MD5 direct lookup (including explicitly supported truncated MD5 values)", "5. Path suffix lookup for regional variants; with hashes it is accepted only if those hashes match", "6. Name and alias lookup only when no content hash was declared", "7. Candidate inspection for composite ZIP MD5 or direct MD5; a named candidate with the wrong content returns `hash_mismatch`", "8. `zipped_file` content match via the inner-ROM MD5 index", "9. MAME clone fallback, only for declarations without a content hash", "10. Data-directory scan; declared hashes are computed over the candidate before it is accepted", "11. Agnostic size/path fallback, only for declarations without a content hash", "", "A filename or destination can never override a declared hash. The first " "evidence-compatible match wins; otherwise the resolver reports a mismatch " "or absence. Steps and their return codes are described in " "[verification modes](verification-modes.md#file-resolution-chain).", "", "## Platform YAML", "", "Scraped from upstream sources. Structure:", "", "```yaml", "platform: Batocera", "verification_mode: md5 # how the platform checks files", "hash_type: md5 # hash type in file entries", "base_destination: bios # root directory for BIOS files", "systems:", " system-id:", " files:", " - name: filename", " destination: path/in/bios/dir", " md5: expected_hash", " sha1: expected_hash", " required: true", "```", "", "Supports inheritance (`inherits: retroarch`) and shared groups", "(`includes: [group_name]` referencing `_shared.yml`).", "", "`base_destination` is the prefix the pack applies to every entry. It is", "empty when the upstream destinations already carry their own root, which", "is why the RetroDECK pack ships `bios/` and `roms/` at its top level.", "", "## Emulator YAML", "", f"**{len(profiles)}** profile files, **{len(unique_emulator_profiles(profiles))}** " "distinct emulators once aliases are folded in. Source-verified from " "emulator code.", "", "See the [profiling guide](profiling.md) for the full field reference.", "", "## Static API and bulk exports", "", "The website publishes versioned JSON, CSV and SQLite metadata generated " "from these same structures. Start with the [Data & API](../data.md) " "catalog; each downloadable artifact carries a SHA256 in " "`api/v1/catalog.json`.", "", ] return "\n".join(lines) + "\n" # Build cross-reference indexes def _build_platform_file_index(coverages: dict) -> dict[str, set]: """Map platform_name -> set of declared file names.""" index = {} for name, cov in coverages.items(): names = set() config = cov["config"] for system in config.get("systems", {}).values(): for fe in system.get("files", []): names.add(fe.get("name", "")) index[name] = names return index def _build_emulator_file_index(profiles: dict) -> dict[str, dict]: """Map emulator_name -> {files: set, systems: set} for cross-reference.""" index = {} for name, profile in profiles.items(): if profile.get("type") == "alias": continue index[name] = { "files": {f.get("name", "") for f in profile.get("files", [])}, "systems": set(profile.get("systems", [])), } return index # mkdocs.yml nav generator def generate_which_pack() -> str: """Generate the 'Which pack?' decision page.""" rel = RELEASE_URL return f"""\ # Download Some retro consoles need firmware files (commonly called BIOS) to run games. Without them, the emulator either refuses to start the game or runs it with reduced accuracy. RetroBIOS maps those requirements to source-traced emulator profiles and verifies local content against the evidence each platform exposes. This page picks the right pack for a setup. For BIOS directory paths per platform, verification, and the CLI, see [Getting started](wiki/getting-started.md). ## Quick install One line detects the platform and BIOS directory, downloads only missing or incorrect files, verifies their hashes, and installs them atomically. The small bootstrap checks the Python installer against an embedded SHA-256 before running it, and the installer reads its file list from that same revision. **Linux / Mac / Steam Deck:** ```sh curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh ``` **Windows (PowerShell):** ```powershell irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1 | iex ``` That is the complete default flow. Extra copies into detected standalone emulator directories are deliberately opt-in with `--standalone-copies`, so automatic setup never writes outside the selected platform tree unexpectedly. Use `python install.py --check` from a checkout for a read-only verification. Options, environment overrides, how each platform is detected and what the installer is allowed to write: [Installer](wiki/installer.md). --- ## Manual download Pick the pack that matches the setup from the [latest release]({rel}), download it, and extract the files into the BIOS folder listed below. The metadata and website can be newer than that release: pack publication is manual and only happens after the release gates pass. A pack over 2 GB comes in several parts, and every part is needed. How to open them depends on their name: - `PackName.part1of2.zip`, `PackName.part2of2.zip`: each part is an ordinary ZIP. Extract them all into the same folder. - `PackName.zip.001`, `PackName.zip.002` (releases up to v2026.09.04): slices of one ZIP, none of which opens on its own. Put them in one folder and open the `.001` with 7-Zip or PeaZip, or join them first: - Linux/macOS: `cat PackName.zip.0* > PackName.zip` - Windows: `cmd /c copy /b PackName.zip.001+PackName.zip.002 PackName.zip` ### Steam Deck | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | [EmuDeck](https://www.emudeck.com/) | Installs and configures multiple emulators, adds each game to the Steam library | [EmuDeck]({rel}) | `~/Emulation/bios/` | | [RetroDECK](https://retrodeck.net/) | Single Flatpak app, all emulators bundled, one-click install from Discover | [RetroDECK]({rel}) | `~/retrodeck/` (the pack carries its own `bios/`) | | RetroArch standalone | Installed from Discover, Steam, or Flatpak | [RetroArch]({rel}) | Open RetroArch > Settings > Directory > System, that is the folder | ### Windows | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | [RetroArch](https://www.retroarch.com/) | Multi-system emulator, loads different cores for each console | [RetroArch]({rel}) | The `system` folder next to `retroarch.exe` | | [RetroBat](https://www.retrobat.org/) | Windows frontend with EmulationStation, includes RetroArch and standalone emulators | [RetroBat]({rel}) | The `bios` folder inside the RetroBat installation | | [BizHawk](https://tasvideos.org/BizHawk) | Accuracy-focused multi-system emulator, popular for speedruns and TAS | [BizHawk]({rel}) | The `Firmware` folder inside the BizHawk installation | | [LaunchBox](https://www.launchbox-app.com/) | Game library manager and launcher, uses RetroArch or standalone emulators behind the scenes | [RetroArch]({rel}) | Open RetroArch (via LaunchBox) > Settings > Directory > System | ### Linux | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | RetroArch (native) | Installed via package manager or AppImage | [RetroArch]({rel}) | `~/.config/retroarch/system/` | | RetroArch (Flatpak) | Installed from Flathub | [RetroArch]({rel}) | `~/.var/app/org.libretro.RetroArch/config/retroarch/system/` | | [Batocera](https://batocera.org/) | Bootable OS dedicated to gaming, runs from USB or full install, supports PC and SBC | [Batocera]({rel}) | `/userdata/bios/` | | [Recalbox](https://www.recalbox.com/) | Bootable OS for retro gaming, streamlined interface, auto-configured | [Recalbox]({rel}) | `/recalbox/share/bios/` | ### macOS | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | [RetroArch](https://www.retroarch.com/) | Multi-system emulator | [RetroArch]({rel}) | `~/Library/Application Support/RetroArch/system/` | ### Raspberry Pi and single-board computers | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | [RetroPie](https://retropie.org.uk/) | The classic Pi emulation setup, largest community, most online guides | [RetroPie]({rel}) | `~/RetroPie/BIOS/` | | [Lakka](https://www.lakka.tv/) | Lightweight RetroArch OS, minimal config, boots straight into the UI | [RetroArch]({rel}) | `/storage/system/` | | [Batocera](https://batocera.org/) | Easy setup, works on Pi 3/4/5 and many other boards (Odroid, etc.) | [Batocera]({rel}) | `/userdata/bios/` | | [Recalbox](https://www.recalbox.com/) | Plug-and-play experience, good for a first build | [Recalbox]({rel}) | `/recalbox/share/bios/` | ### Handhelds | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | Android (Retroid Pocket, Odin, etc.) | Most Android handhelds run RetroArch | [RetroArch]({rel}) | `RetroArch/system/` on internal storage or SD card | | [ROCKNIX](https://rocknix.org/) | Linux OS for ARM and x86 handhelds (RG35XX, RG552, Deck) | [ROCKNIX]({rel}) | `/storage/roms/bios/`, over SSH or the network share | | [Batocera](https://batocera.org/) | Also images many handhelds | [Batocera]({rel}) | `/userdata/bios/` | ### FPGA | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | [MiSTer FPGA](https://mister-devel.github.io/MkDocs_MiSTer/) | Hardware-level recreation of consoles on a DE10-Nano board | [MiSTer FPGA]({rel}) | `/media/fat/games/`, one subfolder per core | ### Self-hosted ROM manager | Setup | What it is | Pack | Extract to | |-------|-----------|------|-----------| | [RomM](https://github.com/rommapp/romm) | Web-based ROM manager, plays games in the browser via EmulatorJS | [RomM]({rel}) | The `bios` folder in the RomM library, one subfolder per system | --- ## One pack per platform The [latest release]({rel}) carries one pack for each platform, and that pack holds everything the platform runs: the BIOS list it publishes, plus every file its emulator cores load, read from their source code. There is no lighter variant to choose, because a pack that leaves out a file a core needs means a game that does not boot, with no message saying why. It is not a guarantee either: source profiles can document files nobody has dumped or that only the user can provide, all of them visible in the [gap analysis](gaps.md). Want less than everything? The installer takes `--target switch` to install only what one machine's cores need. From a clone of the repository, `python scripts/generate_pack.py --platform retroarch --region us` keeps one BIOS per region and `--required-only` the bare minimum each core needs to start; `--help` lists every way to build your own. --- ## After extraction Launch a game and use the emulator's firmware status screen where available. Most supported frontends find files in the documented directory automatically; standalone emulators may still need their BIOS path configured. If a game still asks for a missing file, check the [platforms section](platforms/index.md) for the full file list, or the [emulators section](emulators/index.md) for what each core expects. """ def generate_mkdocs_nav( coverages: dict, manufacturers: dict, profiles: dict, ) -> list: """Generate the nav section for mkdocs.yml.""" platform_nav = [{"Overview": "platforms/index.md"}] for name in sorted(coverages.keys(), key=lambda x: coverages[x]["platform"]): display = coverages[name]["platform"] platform_nav.append({display: f"platforms/{name}.md"}) system_nav = [{"Overview": "systems/index.md"}] for mfr in sorted(manufacturers.keys()): slug = mfr.lower().replace(" ", "-") system_nav.append({mfr: f"systems/{slug}.md"}) unique_profiles = { k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test") } # Group emulators by classification for nav by_class: dict[str, list[tuple[str, str]]] = {} for name in sorted(unique_profiles.keys()): p = unique_profiles[name] cls = p.get("core_classification", "other") display = p.get("emulator", name) by_class.setdefault(cls, []).append((display, f"emulators/{name}.md")) # Classification display names cls_labels = { "pure_libretro": "Pure libretro", "official_port": "Official ports", "community_fork": "Community forks", "frozen_snapshot": "Frozen snapshots", "enhanced_fork": "Enhanced forks", "game_engine": "Game engines", "embedded_hle": "Embedded HLE", "launcher": "Launchers", "other": "Other", } emu_nav: list = [{"Overview": "emulators/index.md"}] for cls in [ "official_port", "community_fork", "pure_libretro", "game_engine", "enhanced_fork", "frozen_snapshot", "embedded_hle", "launcher", "other", ]: entries = by_class.get(cls, []) if not entries: continue label = cls_labels.get(cls, cls) sub = [{display: path} for display, path in entries] emu_nav.append({f"{label} ({len(entries)})": sub}) wiki_nav = [ {"Overview": "wiki/index.md"}, {"First install": "wiki/first-install.md"}, {"Getting started": "wiki/getting-started.md"}, {"Installer": "wiki/installer.md"}, {"FAQ": "wiki/faq.md"}, {"Troubleshooting": "wiki/troubleshooting.md"}, {"Architecture": "wiki/architecture.md"}, {"Tools": "wiki/tools.md"}, {"Advanced usage": "wiki/advanced-usage.md"}, {"Verification modes": "wiki/verification-modes.md"}, {"Data model": "wiki/data-model.md"}, {"Profiling guide": "wiki/profiling.md"}, {"Adding a platform": "wiki/adding-a-platform.md"}, {"Adding a scraper": "wiki/adding-a-scraper.md"}, {"Testing guide": "wiki/testing-guide.md"}, {"Release process": "wiki/release-process.md"}, {"Community tools": "wiki/community-tools.md"}, ] return [ {"Home": "index.md"}, {"Download": "which-pack.md"}, {"Platforms": platform_nav}, {"Systems": system_nav}, {"Emulators": emu_nav}, {"Cross-reference": "cross-reference.md"}, {"Gap Analysis": "gaps.md"}, {"Dump provenance": "provenance.md"}, {"Data & API": "data.md"}, {"Wiki": wiki_nav}, {"Contributing": "contributing.md"}, ] # Main def main(): parser = argparse.ArgumentParser( description="Generate MkDocs site from project data" ) parser.add_argument("--db", default="database.json") parser.add_argument("--platforms-dir", default="platforms") parser.add_argument("--emulators-dir", default="emulators") parser.add_argument("--docs-dir", default=DOCS_DIR) args = parser.parse_args() db = load_database(args.db) docs = Path(args.docs_dir) # Ensure output dirs for d in GENERATED_DIRS: (docs / d).mkdir(parents=True, exist_ok=True) # Copy stylesheet if source exists css_src = Path("docs_assets") / "extra.css" css_dest = docs / "stylesheets" / "extra.css" if css_src.exists(): css_dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(css_src, css_dest) js_src = Path("docs_assets") / "site.js" js_dest = docs / "javascripts" / "site.js" if js_src.exists(): js_dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(js_src, js_dest) # mkdocs writes sitemap.xml but no robots.txt, so nothing points a crawler # at it. Everything published here is meant to be indexed. write_if_changed( str(docs / "robots.txt"), "User-agent: *\nAllow: /\n\nSitemap: https://abdess.github.io/retrobios/sitemap.xml\n", ) # Copy branding assets images_dest = docs / "assets" / "images" images_dest.mkdir(parents=True, exist_ok=True) assets_src = Path(".github") / "assets" for name, dest_name in [("logo.png", "logo.png"), ("favicon.png", "favicon.png")]: src = assets_src / name if src.exists(): shutil.copy2(src, images_dest / dest_name) registry_path = Path(args.platforms_dir) / "_registry.yml" registry = {} if registry_path.exists(): with open(registry_path) as f: registry = (yaml_load(f) or {}).get("platforms", {}) platform_names = list_registered_platforms( args.platforms_dir, include_archived=True ) from common import load_data_dir_registry from cross_reference import _build_supplemental_index data_registry = load_data_dir_registry(args.platforms_dir) suppl_names = _build_supplemental_index() print("Computing platform coverage...") coverages = {} for name in sorted(platform_names): try: cov = compute_coverage( name, args.platforms_dir, db, data_registry, suppl_names, emulators_dir=args.emulators_dir, ) coverages[name] = cov print( f" {cov['platform']}: {cov['present']}/{cov['total']} ({_pct(cov['present'], cov['total'])})" ) except FileNotFoundError as e: print(f" {name}: skipped ({e})", file=sys.stderr) print("Loading emulator profiles...") profiles = load_emulator_profiles(args.emulators_dir, skip_aliases=False) unique_count = sum(1 for p in profiles.values() if p.get("type") != "alias") print( f" {len(profiles)} profiles ({unique_count} unique, {len(profiles) - unique_count} aliases)" ) # Build cross-reference indexes platform_files = _build_platform_file_index(coverages) emulator_files = _build_emulator_file_index(profiles) # Generate home print("Generating home page...") write_if_changed( str(docs / "index.md"), generate_home(db, coverages, profiles, registry) ) stats = compute_stats(db, coverages, profiles) stats["composition"] = compute_composition(db) write_if_changed(str(docs / "stats.json"), generate_stats(stats)) # Computed once: the gap analysis page and the published gaps export must # report the same files. gap_report = build_emulator_gap_report(profiles, coverages, db, suppl_names) print("Generating static API and bulk data exports...") exports = generate_data_exports( docs, db, coverages, profiles, stats, gap_report ) write_if_changed(str(docs / "data.md"), generate_data_page(exports)) # Build system_id -> manufacturer page map (needed by all generators) print("Building system cross-reference map...") manufacturers = _group_by_manufacturer(db) _build_system_page_map_from_data(manufacturers, coverages, db) print(f" {len(_system_page_map)} system IDs mapped to pages") # Generate platform pages print("Generating platform pages...") write_if_changed( str(docs / "platforms" / "index.md"), generate_platform_index(coverages, registry), ) for name, cov in coverages.items(): write_if_changed( str(docs / "platforms" / f"{name}.md"), generate_platform_page(name, cov, registry, emulator_files), ) # Generate system pages print("Generating system pages...") prime_system_icons({ _icon_name(mfr, console) for mfr, consoles in manufacturers.items() for console in consoles }) write_if_changed( str(docs / "systems" / "index.md"), generate_systems_index(manufacturers) ) for mfr, consoles in manufacturers.items(): slug = mfr.lower().replace(" ", "-") page = generate_system_page(mfr, consoles, platform_files, emulator_files) write_if_changed(str(docs / "systems" / f"{slug}.md"), page) # Generate emulator pages print("Generating emulator pages...") write_if_changed( str(docs / "emulators" / "index.md"), generate_emulators_index(profiles) ) public_profiles = { name: profile for name, profile in profiles.items() if profile.get("type") not in ("alias", "test") } for name, profile in public_profiles.items(): page = generate_emulator_page(name, profile, db, platform_files, suppl_names) write_if_changed(str(docs / "emulators" / f"{name}.md"), page) # Generate cross-reference page print("Generating cross-reference page...") write_if_changed( str(docs / "cross-reference.md"), generate_cross_reference(coverages, profiles) ) # Generate gap analysis page print("Generating gap analysis page...") write_if_changed( str(docs / "gaps.md"), generate_gap_analysis( profiles, coverages, db, suppl_names, registry, gap_report ), ) # Generate dump provenance page print("Generating dump provenance page...") provenance_report = build_report(db, load_provenance_snapshots()) write_if_changed( str(docs / "provenance.md"), generate_provenance_page(db, provenance_report) ) # Wiki pages: copy manually maintained sources + generate dynamic ones print("Generating wiki pages...") wiki_dest = docs / "wiki" wiki_dest.mkdir(parents=True, exist_ok=True) wiki_src = Path(WIKI_SRC_DIR) if wiki_src.is_dir(): for src_file in wiki_src.glob("*.md"): shutil.copy2(src_file, wiki_dest / src_file.name) _record(wiki_dest / src_file.name) # data-model.md is generated (contains live DB stats) write_if_changed( str(wiki_dest / "data-model.md"), generate_wiki_data_model(db, profiles) ) # Generate which-pack page print("Generating which-pack page...") write_if_changed(str(docs / "which-pack.md"), generate_which_pack()) # Generate contributing print("Generating contributing page...") write_if_changed(str(docs / "contributing.md"), generate_contributing()) print("Adding page metadata and structured data...") decorate_markdown_pages(docs) # Update mkdocs.yml nav section only (avoid yaml.dump round-trip mangling quotes) print("Updating mkdocs.yml nav...") nav = generate_mkdocs_nav(coverages, manufacturers, profiles) nav_yaml = yaml.dump( {"nav": nav}, default_flow_style=False, sort_keys=False, allow_unicode=True ) # Rewrite mkdocs.yml entirely (static config + generated nav) mkdocs_static = """\ site_name: RetroBIOS site_description: Source-verified BIOS and firmware packs for RetroArch, Batocera, Recalbox, Lakka, RetroPie, EmuDeck, RetroBat, RetroDECK, RomM, BizHawk, ROCKNIX and MiSTer FPGA. site_url: https://abdess.github.io/retrobios/ repo_url: https://github.com/Abdess/retrobios repo_name: Abdess/retrobios # Almost every page is generated from platforms/, emulators/ and database.json, # so a per-page edit link would point at a file that does not exist. edit_uri: '' # Local implementation plans are preserved in docs/ for development sessions, # but are not part of the public reference site. exclude_docs: | superpowers/** copyright: MIT for the tooling. BIOS and firmware files are third-party system software, preserved for personal backup, archival and interoperability. theme: name: material # Open Graph tags live in overrides/main.html: the social plugin that # would otherwise emit them needs Pillow and CairoSVG only to render a # preview image the pages do not need. custom_dir: docs_assets/overrides palette: - media: (prefers-color-scheme) toggle: icon: material/brightness-auto name: Switch to light mode - media: '(prefers-color-scheme: light)' scheme: default toggle: icon: material/brightness-7 name: Switch to dark mode - media: '(prefers-color-scheme: dark)' scheme: slate toggle: icon: material/brightness-4 name: Switch to auto font: false logo: assets/images/logo.png favicon: assets/images/favicon.png icon: logo: material/chip features: - navigation.instant - navigation.instant.prefetch - navigation.instant.progress - navigation.tabs - navigation.tabs.sticky - navigation.sections - navigation.top - navigation.tracking - navigation.indexes # 400+ pages: pruning keeps the navigation out of every page's HTML. - navigation.prune - navigation.footer - search.suggest - search.highlight - search.share - content.code.copy - content.tabs.link - toc.follow extra_css: - stylesheets/extra.css extra_javascript: - javascripts/site.js extra: social: - icon: fontawesome/brands/github link: https://github.com/Abdess/retrobios name: RetroBIOS on GitHub markdown_extensions: - abbr - admonition - attr_list - def_list - footnotes - meta - md_in_html - tables - toc: permalink: true - pymdownx.details - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.keys - pymdownx.superfences: custom_fences: - name: mermaid class: mermaid format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true plugins: - search # Link rot fails the build: deploy-site.yml runs `mkdocs build --strict`. # omitted_files stays at its default (info) so a stale local docs/ does not # break a local build; CI regenerates docs/ from scratch anyway. validation: absolute_links: warn unrecognized_links: warn anchors: warn """ write_if_changed("mkdocs.yml", mkdocs_static + nav_yaml) total_pages = ( 1 # home + 1 + len(coverages) # platform index + detail + 1 + len(manufacturers) # system index + detail + 1 # cross-reference + 1 + sum( 1 for profile in profiles.values() if profile.get("type") not in ("alias", "test") ) # emulator detail pages (aliases/tests remain metadata-only) + 1 # gap analysis + 1 # which-pack + len(list(Path("wiki").glob("*.md"))) # wiki pages copied verbatim + 1 # generated wiki/data-model + 1 # contributing + 1 # data and API ) stale = _sweep_generated(docs, GENERATED_DIRS) print(f"\nGenerated {total_pages} pages in {args.docs_dir}/") if stale: print(f"Removed {stale} page(s) nothing produced this run") from sitewrite import ( # noqa: E402,F401 _produced, _record, _undecorated, write_if_changed, write_decorated, _sweep_generated, ) from sitesources import ( # noqa: E402,F401 _repo_pins, _forge_sources, _source_permalink, _source_ref_markdown, ) from siterender import ( # noqa: E402,F401 _by_mode, _render_yaml_value, _slugify_anchor, _system_page_map, _system_link, _platform_link, _emulator_link, _fmt_size, _pct, _admonition_body, _escape_tags, _icon_name, _icon_url, system_icon_markdown, prime_system_icons, _icon_available, SYSTEM_ICON_BASE, ICON_CACHE_PATH, ) if __name__ == "__main__": main()