#!/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 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, GAME_DATA_TOPS, list_registered_platforms, load_database, load_emulator_profiles, load_provenance_snapshots, parse_md5_list, require_yaml, unique_emulator_profiles, write_if_changed, yaml_load, ) yaml = require_yaml() from generate_readme import compute_coverage, manifest_totals from profile_sync import source_ref_values, split_source_ref from provenance_report import build_report 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"] WIKI_SRC_DIR = "wiki" # manually maintained wiki sources SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png" ICON_CACHE_PATH = Path(".cache") / "system_icons.json" # Icon names confirmed to exist upstream. A name absent from this map has not # been checked yet; a name mapped to False has no icon and gets none rendered, # because a heading with a broken image reads worse than a heading without one. _icon_available: dict[str, bool] = {} def _by_mode(value, mode: str) -> str: """Read a profile field that may be keyed by build mode.""" if isinstance(value, dict): return str(value.get(mode) or "") if mode else "" return str(value or "") def _repo_pins(profile: dict, field: str, label: str) -> list[tuple[str, str, str]]: """Pair each declared repository URL with its own revision. A profile whose builds live in separate repositories keys both the URL and the revision by build mode. Reading them apart would pin a libretro fork to the standalone revision and produce a permalink into a tree that never held the cited line. Each tuple is (url, pin, source_commit fallback for that same mode). """ raw = profile.get(field, "") pin_field = profile.get(f"{field}_commit") or "" source_field = profile.get("source_commit") or "" if not isinstance(raw, dict): if not raw: return [] return [ ( str(raw), _by_mode(pin_field, label), _by_mode(source_field, label), ) ] # The requested build mode first, so it wins when several repositories # would otherwise be equally good candidates. ordered = ([label] if label and label in raw else []) + [ key for key in raw if key != label ] return [ (str(raw[key]), _by_mode(pin_field, key), _by_mode(source_field, key)) for key in ordered if raw.get(key) ] def _forge_sources(profile: dict, label: str = "") -> list[tuple[upstream.Repo, str]]: """Supported source repositories and immutable revisions for a profile.""" candidates: list[tuple[upstream.Repo, str]] = [] seen: set[tuple[str, str, str]] = set() source_repos: set[tuple[str, str]] = set() for field in ("source", "upstream"): for value, pin, fallback in _repo_pins(profile, field, label): repo = upstream.parse_repo(value) if repo is None: continue repo_key = (repo.host, repo.slug) if field == "upstream" and not pin and repo_key in source_repos: pin = fallback if not pin: continue key = (repo.host, repo.slug, pin) if key not in seen: candidates.append((repo, pin)) seen.add(key) if field == "source": source_repos.add(repo_key) return candidates def _source_permalink(repo: upstream.Repo, pin: str, path: str, start: int | None, end: int | None) -> str: """Forge-specific browser URL for one file at one immutable revision.""" quoted_path = urllib.parse.quote(path, safe="/@:+-._~") base = f"https://{repo.host}/{repo.owner}/{repo.name}" if repo.family == "github": url = f"{base}/blob/{pin}/{quoted_path}" elif repo.family == "gitlab": url = f"{base}/-/blob/{pin}/{quoted_path}" else: url = f"{base}/src/commit/{pin}/{quoted_path}" if start is not None: if repo.family == "gitlab": url += f"#L{start}" if end is not None and end != start: url += f"-{end}" else: url += f"#L{start}" if end is not None and end != start: url += f"-L{end}" return url def _source_ref_markdown(profile: dict, value) -> str: """Render source_ref values as pinned links when their forge is known. A profile can declare two repositories: the libretro port in ``source`` and the original emulator in ``upstream``. Which of the two carries a given path cannot be decided without reading their trees, and this generator runs offline. Rather than guess, an unattributable path is rendered as plain code: a citation with no link still names the file and the lines, while a link to the wrong repository is a false citation. """ rendered_groups: list[str] = [] path_re = re.compile(r"^[A-Za-z0-9_.@/+~-]+$") for label, refs in source_ref_values(value): candidates = _forge_sources(profile, label) rendered: list[str] = [] for part in split_source_ref(refs): display = part.path if part.start is not None: display += f":{part.start}" if part.end is not None and part.end != part.start: display += f"-{part.end}" selected: tuple[upstream.Repo, str] | None = None real_path = part.path if path_re.fullmatch(part.path) and candidates: for repo, pin in candidates: prefix = f"{repo.name}/" if part.path.startswith(prefix): selected = (repo, pin) real_path = part.path[len(prefix):] break if selected is None and len(candidates) == 1: selected = candidates[0] if selected is None: rendered.append(f"`{display}`") else: repo, pin = selected url = _source_permalink( repo, pin, real_path, part.start, part.end ) rendered.append(f"[`{display}`]({url})") text = ", ".join(rendered) if rendered else f"`{refs}`" if label: text = f"**{label}:** {text}" rendered_groups.append(text) return "; ".join(rendered_groups) def _admonition_body(text: str) -> str: """Indent prose without turning source tokens such as ``#if`` into H1s.""" escaped = re.sub(r"(?m)^(\s*)#", r"\1\\#", text) return escaped.replace("\n", "\n ") 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." ) def _icon_name(manufacturer: str, console_name: str) -> str: return f"{manufacturer} - {console_name}".replace("/", " ") def _icon_url(icon_name: str) -> str: return f"{SYSTEM_ICON_BASE}/{urllib.parse.quote(icon_name)}.png" def prime_system_icons(names: set[str]) -> None: """Record which system icons upstream actually serves. Results persist in ``.cache`` so later runs skip the network. Names that cannot be checked stay unavailable: the site never links an image it has not seen answer. """ cached: dict[str, bool] = {} if ICON_CACHE_PATH.exists(): try: with open(ICON_CACHE_PATH) as f: cached = json.load(f) except (json.JSONDecodeError, OSError): cached = {} unknown = sorted(n for n in names if n not in cached) if unknown: def check(name: str) -> tuple[str, bool | None]: """True when served, False when upstream says it is gone. None on a transient failure, so a flaky run never records an icon as absent for every later build. """ req = urllib.request.Request(_icon_url(name), method="HEAD") try: with urllib.request.urlopen(req, timeout=15) as resp: return name, resp.status == 200 except urllib.error.HTTPError as exc: return name, False if exc.code == 404 else None except (urllib.error.URLError, OSError): return name, None print(f"Checking {len(unknown)} system icons...") with ThreadPoolExecutor(max_workers=8) as pool: for name, ok in pool.map(check, unknown): if ok is not None: cached[name] = ok ICON_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) with open(ICON_CACHE_PATH, "w") as f: json.dump(cached, f, indent=2, sort_keys=True) _icon_available.update(cached) missing = sum(1 for n in names if not cached.get(n)) if missing: print(f" {missing} systems have no upstream icon") def system_icon_markdown(manufacturer: str, console_name: str) -> str: """Icon image for a system heading, empty when upstream serves none.""" name = _icon_name(manufacturer, console_name) if not _icon_available.get(name): return "" return f"![{console_name}]({_icon_url(name)}){{ width=24 }} " 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", } # Global index: maps system_id -> (manufacturer_slug, console_name) for cross-linking _system_page_map: dict[str, tuple[str, str]] = {} 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 _slugify_anchor(text: str) -> str: """Slugify text for MkDocs anchor compatibility.""" import re slug = text.lower() slug = re.sub(r"[^\w\s-]", "", slug) slug = re.sub(r"[\s]+", "-", slug) slug = slug.strip("-") return slug def _system_link(sys_id: str, prefix: str = "") -> str: """Generate a markdown link to a system page with anchor.""" if sys_id in _system_page_map: slug, console = _system_page_map[sys_id] anchor = _slugify_anchor(console) return f"[{sys_id}]({prefix}systems/{slug}.md#{anchor})" return sys_id def _render_yaml_value(lines: list[str], val, indent: int = 4) -> None: """Render any YAML value as indented markdown.""" pad = " " * indent if isinstance(val, dict): for k, v in val.items(): if isinstance(v, dict): lines.append(f"{pad}**{k}:**") lines.append("") _render_yaml_value(lines, v, indent + 4) elif isinstance(v, list): lines.append(f"{pad}**{k}:**") lines.append("") for item in v: if isinstance(item, dict): parts = [ f"{ik}: {iv}" for ik, iv in item.items() if not isinstance(iv, (dict, list)) ] lines.append(f"{pad}- {', '.join(parts)}") else: lines.append(f"{pad}- {item}") lines.append("") else: # Truncate very long strings in tables sv = str(v) if len(sv) > 200: sv = sv[:200] + "..." lines.append(f"{pad}- **{k}:** {sv}") elif isinstance(val, list): for item in val: if isinstance(item, dict): parts = [ f"{ik}: {iv}" for ik, iv in item.items() if not isinstance(iv, (dict, list)) ] lines.append(f"{pad}- {', '.join(parts)}") else: lines.append(f"{pad}- {item}") elif isinstance(val, str) and "\n" in val: for line in val.split("\n"): lines.append(f"{pad}{line}") else: lines.append(f"{pad}{val}") def _platform_link(name: str, display: str, prefix: str = "") -> str: """Generate a markdown link to a platform page.""" return f"[{display}]({prefix}platforms/{name}.md)" def _emulator_link(name: str, prefix: str = "") -> str: """Generate a markdown link to an emulator page.""" return f"[{name}]({prefix}emulators/{name}.md)" def _timestamp() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _fmt_size(size: int) -> str: if size >= 1024 * 1024 * 1024: return f"{size / (1024**3):.1f} GB" if size >= 1024 * 1024: return f"{size / (1024**2):.1f} MB" if size >= 1024: return f"{size / 1024:.1f} KB" return f"{size} B" def _pct(n: int, total: int) -> str: if total == 0: return "0%" return f"{n / total * 100:.1f}%" # 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:,}', 'Files', "
", "", '
', 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 | 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"{cov['present']:,} | {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 = sum( 1 for f in db.get("files", {}).values() if f.get("provenance") ) if catalog_matched: lines.extend( [ "", f"**{catalog_matched:,}** 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": sum( 1 for f in db.get("files", {}).values() if f.get("provenance") ), "source": REPO_URL, "downloads": RELEASE_URL, } def composition_sentence(db: dict) -> str: comp = compute_composition(db) return ( f"Of these files, {comp['systems']['files']:,} are console and " f"computer system files, {comp['arcade']['files']:,} arcade ROM sets " f"(`Arcade/`), and {comp['game_data']['files']:,} game and engine " "data (the `RPG Maker/` and `ScummVM/` trees)." ) 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 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(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) 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, ) 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") if markdown.startswith("---\n") and "generated_by: retrobios-site" in markdown[:300]: end = markdown.find("\n---\n", 4) if end != -1: markdown = markdown[end + 5:].lstrip("\n") script_end = markdown.find("\n\n") if markdown.startswith('\n\n"):] 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_if_changed(str(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 | Files | 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']:,} | {mode_html} | {status_html} | " 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. " "[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 mode in ("md5", "sha1") 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.""" # Scoped to system files: these catalogs index console and computer dumps, # so arcade ROM sets and engine data can never match and would only make # the ratio look worse than the work behind it. matched_files = 0 for entry in db.get("files", {}).values(): if not entry.get("provenance"): continue parts = entry.get("path", "").split("/") top = parts[1] if len(parts) > 1 else "" if top != "Arcade" and top not in GAME_DATA_TOPS: matched_files += 1 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_options = f.get("size_options", []) 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"
{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: s = fsha1[:12] details.append( f'SHA1: ' f"`{s}...`" ) if fmd5: s = fmd5[:12] details.append( f'MD5: ' f"`{s}...`" ) if fcrc32: details.append(f"CRC32: `{fcrc32}`") if fsha256: s = fsha256[:12] details.append( f'SHA256: ' f"`{s}...`" ) 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_options: details.append( f"Size options: {', '.join(_fmt_size(s) for s in size_options)}" ) 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"- {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 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} |") # Additional metadata fields (scalar values only -complex ones go to collapsible sections) 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'])} |" ) lines.append("") lines.append("
") lines.append("") # Platform-specific details (rich structured data) 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}: {sv}") elif isinstance(pv, list): lines.append(f" **{pk}:** {', '.join(str(x) for x in pv)}") else: lines.append(f" **{pk}:** {pv}") lines.append("") # All remaining structured data blocks as collapsible sections _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("") _render_yaml_value(lines, val, indent=4) lines.append("") # 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" {exclusion}", ] ) else: from cross_reference import _resolve_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", {}) by_md5 = db.get("indexes", {}).get("by_md5", {}) db_files = db.get("files", {}) def _file_available(f: dict) -> bool: """Check if a file is available using the same resolution as cross_reference.""" fname = f.get("name", "") if not fname: return False storage = f.get("storage", "") if storage in ("release", "large_file"): return True src = _resolve_source( fname, by_name, by_name_lower, data_names, by_path_suffix, f, db_files, ) if src is not None: return True path_field = f.get("path", "") if path_field and path_field != fname: src = _resolve_source( path_field, by_name, by_name_lower, data_names, by_path_suffix, f, db_files, ) if src is not None: return True md5_raw = f.get("md5", "") if md5_raw: for md5_val in parse_md5_list(md5_raw): if by_md5.get(md5_val): return True sha1 = f.get("sha1", "") if sha1 and sha1 in db_files: return True return False # 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, core_to_profile: 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("") # Resolve which profiles this platform uses if platform_cores == "all_libretro": matched = { k: v for k, v in unique.items() if "libretro" in v.get("type", "") } elif isinstance(platform_cores, list): matched = {} for cname in platform_cores: cname_str = str(cname) if cname_str in unique: matched[cname_str] = unique[cname_str] elif cname_str in core_to_profile: pkey = core_to_profile[cname_str] matched[pkey] = unique[pkey] else: # Fallback: system intersection psystems = set(config.get("systems", {}).keys()) matched = { k: v for k, v in unique.items() if set(v.get("systems", [])) & psystems } 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, core_to_profile: 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(): config = cov["config"] pcores = config.get("cores", []) if pcores == "all_libretro": for k, v in unique.items(): if "libretro" in v.get("type", ""): platform_membership.setdefault(k, set()).add(pname) elif isinstance(pcores, list): for cname in pcores: cname_str = str(cname) if cname_str in unique: platform_membership.setdefault(cname_str, set()).add(pname) elif cname_str in core_to_profile: pkey = core_to_profile[cname_str] platform_membership.setdefault(pkey, 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") } # Build core -> profile lookup by core name core_to_profile: dict[str, str] = {} for pname, p in unique.items(): for core in p.get("cores", [pname]): core_to_profile[str(core)] = pname 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, core_to_profile)) lines.extend(_render_xref_by_upstream(coverages, unique, core_to_profile)) lines.extend(["", f"*Generated on {_timestamp()}*"]) return "\n".join(lines) + "\n" def generate_contributing() -> str: return """# Contributing - RetroBIOS ## Add a BIOS file 1. Fork this repository 2. Place the file in `bios/Manufacturer/Console/filename` 3. Variants (alternate hashes for the same file): place in `bios/Manufacturer/Console/.variants/` 4. Open a Pull Request - hashes are verified automatically and reported as a comment The [dump provenance](provenance.md) page lists catalogued dumps still missing from the collection, with their hashes. A file matching one of those is the most useful contribution. ## Add a platform 1. Create a scraper in `scripts/scraper/` (inherit `BaseScraper`) 2. Read the platform's upstream source to determine how it checks BIOS files 3. Add an entry to `platforms/_registry.yml` 4. Generate the platform YAML config 5. Test: `python scripts/verify.py --platform ` Full walkthrough: [adding a platform](wiki/adding-a-platform.md). ## Add an emulator profile 1. Clone the emulator's source code, upstream and libretro port 2. Trace the file loading from the entry point, not from a keyword grep 3. Document every file the code loads, with a `source_ref` line reference 4. Write the YAML to `emulators/.yml` 5. Test: `python scripts/cross_reference.py --emulator ` Full walkthrough: [profiling guide](wiki/profiling.md). ## File conventions - `bios/Manufacturer/Console/filename` for canonical files - `bios/Manufacturer/Console/.variants/filename.sha1prefix` for alternate versions - Files >50 MB go in GitHub release assets (`large-files` release) - RPG Maker and ScummVM directories are excluded from deduplication - Two paths differing only by case break clones on Windows and macOS; `tests/test_no_case_collisions.py` enforces this ## Before opening a PR ```bash python -m unittest discover tests python scripts/pipeline.py --offline ``` ## PR validation CI computes SHA1/MD5/CRC32 for every new file, checks them against the platform configs, validates the YAML against the schemas, runs the test suite, and posts a report on the PR. Contributors who add platform support are credited in the README, on this site, and in the BIOS packs. """ # 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 iwr -useb 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. --- ## 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. Packs over 2 GB are split into numbered volumes (`.zip.001`, `.zip.002`). Download every part, then open the `.001` file with 7-Zip or PeaZip, which extract the whole archive directly. To join the parts manually instead: - Linux/macOS: `cat PackName.zip.0* > PackName.zip` - Windows (cmd): `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 | [RetroArch]({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 | --- ## Full pack or Platform pack? Each platform has two pack types on the [latest release]({rel}). **Full pack** (recommended) Contains the platform's own BIOS list plus all files needed by each emulator core available on that platform. This covers alternate cores, optional firmware that improves accuracy, and edge cases. Larger download, and the best default when storage is not constrained. It is not a guarantee: source profiles can document missing, user-provided or unsourceable files, all of which remain visible in the [gap analysis](gaps.md). **Platform pack** Contains only the files the platform officially checks for. Much smaller download. Good for limited storage (SD cards, handhelds) or setups that only use default cores. When in doubt, take the full pack and verify it against the platform page. --- ## 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"}, {"Getting started": "wiki/getting-started.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) # Clean generated dirs (preserve docs/superpowers/) for d in GENERATED_DIRS: target = docs / d if target.exists(): shutil.rmtree(target) # 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 ) 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) # 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 ) print(f"\nGenerated {total_pages} pages in {args.docs_dir}/") if __name__ == "__main__": main()