mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-04-13 12:22:33 -05:00
- fix urllib.parse.quote import (was urllib.request.quote) - add operator precedence parens in generate_pack dedup check - narrow bare except to specific types in batocera target scraper - cache load_platform_config and build_zip_contents_index results - add selective algorithm support to compute_hashes - atomic write for fetch_large_file (tmp + rename) - add response size limit to base scraper fetch - extract build_target_cores_cache to common.py (dedup verify/pack) - hoist _build_supplemental_index out of per-platform loop - migrate function-attribute caches to module-level dicts - add @abstractmethod to BaseTargetScraper.fetch_targets - remove backward-compat re-exports from common.py - replace em-dashes and unicode arrows with ASCII equivalents - remove decorative section dividers and obvious comments
2195 lines
86 KiB
Python
2195 lines
86 KiB
Python
#!/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 json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from common import list_registered_platforms, load_database, load_emulator_profiles, load_platform_config, require_yaml
|
|
|
|
yaml = require_yaml()
|
|
from generate_readme import compute_coverage
|
|
from verify import verify_platform
|
|
|
|
DOCS_DIR = "docs"
|
|
SITE_NAME = "RetroBIOS"
|
|
REPO_URL = "https://github.com/Abdess/retrobios"
|
|
RELEASE_URL = f"{REPO_URL}/releases/latest"
|
|
GENERATED_DIRS = ["platforms", "systems", "emulators"]
|
|
WIKI_SRC_DIR = "wiki" # manually maintained wiki sources
|
|
SYSTEM_ICON_BASE = "https://raw.githubusercontent.com/libretro/retroarch-assets/master/xmb/systematic/png"
|
|
|
|
# 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.
|
|
"""
|
|
files_db = db.get("files", {})
|
|
by_name = 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 _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 = console.lower().replace(" ", "-").replace("/", "-")
|
|
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}%"
|
|
|
|
|
|
def _status_icon(pct: float) -> str:
|
|
if pct >= 100:
|
|
return "OK"
|
|
if pct >= 95:
|
|
return "~OK"
|
|
return "partial"
|
|
|
|
|
|
# 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 = {k: v for k, v in profiles.items()
|
|
if v.get("type") not in ("alias", "test")}
|
|
emulator_count = len(unique)
|
|
|
|
# Classification stats
|
|
classifications: dict[str, int] = {}
|
|
for p in unique.values():
|
|
cls = p.get("core_classification", "unclassified")
|
|
classifications[cls] = classifications.get(cls, 0) + 1
|
|
|
|
lines = [
|
|
f"# {SITE_NAME}",
|
|
"",
|
|
"Source-verified BIOS and firmware packs for retrogaming platforms.",
|
|
"",
|
|
"## Quick start",
|
|
"",
|
|
"1. Find your platform in the table below",
|
|
"2. Click **Pack** to download the ZIP",
|
|
"3. Extract to your emulator's BIOS directory",
|
|
"",
|
|
"| Platform | Extract to |",
|
|
"|----------|-----------|",
|
|
"| RetroArch / Lakka | `system/` |",
|
|
"| Batocera | `/userdata/bios/` |",
|
|
"| Recalbox | `/recalbox/share/bios/` |",
|
|
"| RetroBat | `bios/` |",
|
|
"| RetroDECK | `~/retrodeck/bios/` |",
|
|
"| EmuDeck | `Emulation/bios/` |",
|
|
"",
|
|
"---",
|
|
"",
|
|
"## Methodology",
|
|
"",
|
|
"Documentation and metadata can drift from what emulators actually load at runtime.",
|
|
"To keep packs accurate, each file here is checked against the emulator's source code.",
|
|
"",
|
|
"The source code is the primary reference because it reflects actual behavior.",
|
|
"Other sources remain useful but are verified against it:",
|
|
"",
|
|
"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",
|
|
"",
|
|
f"**{emulator_count}** emulators profiled. "
|
|
f"Each profile documents what the code loads, what it validates, "
|
|
f"and where the port differs from the original.",
|
|
"",
|
|
f"**{total_files:,}** files | **{len(coverages)}** platforms | "
|
|
f"**{emulator_count}** emulator profiles | **{_fmt_size(total_size)}** total",
|
|
"",
|
|
"---",
|
|
"",
|
|
]
|
|
|
|
# Platform table
|
|
lines.extend([
|
|
"## Platforms",
|
|
"",
|
|
"| | Platform | Coverage | Verified | Download |",
|
|
"|---|----------|----------|----------|----------|",
|
|
])
|
|
|
|
for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]):
|
|
display = cov["platform"]
|
|
pct = _pct(cov["present"], cov["total"])
|
|
logo_url = (registry or {}).get(name, {}).get("logo", "")
|
|
logo_md = f"{{ width=20 loading=lazy }}" if logo_url else ""
|
|
|
|
lines.append(
|
|
f"| {logo_md} | [{display}](platforms/{name}.md) | "
|
|
f"{cov['present']}/{cov['total']} ({pct}) | "
|
|
f"{cov['verified']} | "
|
|
f"[Pack]({RELEASE_URL}) |"
|
|
)
|
|
|
|
# Emulator classification breakdown
|
|
lines.extend([
|
|
"",
|
|
"## Emulator profiles",
|
|
"",
|
|
"| Classification | Count |",
|
|
"|---------------|-------|",
|
|
])
|
|
for cls, count in sorted(classifications.items(), key=lambda x: -x[1]):
|
|
lines.append(f"| {cls} | {count} |")
|
|
|
|
# Quick links
|
|
lines.extend([
|
|
"",
|
|
"---",
|
|
"",
|
|
f"[Systems](systems/){{ .md-button }} "
|
|
f"[Emulators](emulators/){{ .md-button }} "
|
|
f"[Cross-reference](cross-reference.md){{ .md-button }} "
|
|
f"[Gap Analysis](gaps.md){{ .md-button }} "
|
|
f"[Contributing](contributing.md){{ .md-button .md-button--primary }}",
|
|
"",
|
|
f"*Generated on {ts}.*",
|
|
])
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
# Platform pages
|
|
|
|
def generate_platform_index(coverages: dict) -> str:
|
|
lines = [
|
|
f"# Platforms - {SITE_NAME}",
|
|
"",
|
|
"| Platform | Coverage | Verification | Status |",
|
|
"|----------|----------|-------------|--------|",
|
|
]
|
|
|
|
for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]):
|
|
display = cov["platform"]
|
|
pct = _pct(cov["present"], cov["total"])
|
|
plat_status = cov["config"].get("status", "active")
|
|
status = "archived" if plat_status == "archived" else _status_icon(cov["percentage"])
|
|
lines.append(
|
|
f"| [{display}]({name}.md) | "
|
|
f"{cov['present']}/{cov['total']} ({pct}) | "
|
|
f"{cov['mode']} | {status} |"
|
|
)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
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"{{ 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", "")
|
|
|
|
lines = [
|
|
f"# {display} - {SITE_NAME}",
|
|
"",
|
|
logo_md + f"| | |",
|
|
"|---|---|",
|
|
f"| Verification | {mode} |",
|
|
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}) |")
|
|
lines.extend([
|
|
"",
|
|
f"**Coverage:** {cov['present']}/{cov['total']} ({pct}) | "
|
|
f"**Verified:** {cov['verified']} | **Untested:** {cov['untested']} | **Missing:** {cov['missing']}",
|
|
"",
|
|
f"[Download {display} Pack]({RELEASE_URL}){{ .md-button }}",
|
|
"",
|
|
])
|
|
|
|
# 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
|
|
status = "OK" if non_ok == 0 else 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("")
|
|
|
|
# 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"<a id=\"{anchor}\"></a>")
|
|
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("")
|
|
|
|
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:
|
|
lines = [
|
|
f"# Systems - {SITE_NAME}",
|
|
"",
|
|
"| 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"
|
|
|
|
|
|
def generate_system_page(
|
|
manufacturer: str,
|
|
consoles: dict[str, list],
|
|
platform_files: dict[str, set],
|
|
emulator_files: dict[str, dict],
|
|
) -> str:
|
|
slug = manufacturer.lower().replace(" ", "-")
|
|
lines = [
|
|
f"# {manufacturer} - {SITE_NAME}",
|
|
"",
|
|
]
|
|
|
|
for console_name in sorted(consoles.keys()):
|
|
files = consoles[console_name]
|
|
icon_name = f"{manufacturer} - {console_name}".replace("/", " ")
|
|
icon_url = f"{SYSTEM_ICON_BASE}/{icon_name.replace(' ', '%20')}.png"
|
|
lines.append(f"## {{ width=24 }} {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: which platforms declare this file
|
|
plats = sorted(p for p, names in platform_files.items() if name in names)
|
|
# Cross-reference: which emulators load this file
|
|
emus = sorted(e for e, data in emulator_files.items() if name in data.get("files", set()))
|
|
|
|
lines.append(f"**`{name}`** ({size})")
|
|
lines.append("")
|
|
lines.append(f"- SHA1: `{sha1_full}`")
|
|
lines.append(f"- MD5: `{md5_full}`")
|
|
if plats:
|
|
plat_links = [_platform_link(p, p, "../") for p in plats]
|
|
lines.append(f"- Platforms: {', '.join(plat_links)}")
|
|
if emus:
|
|
emu_links = [_emulator_link(e, "../") for e in emus]
|
|
lines.append(f"- Emulators: {', '.join(emu_links)}")
|
|
lines.append("")
|
|
|
|
if variant_files:
|
|
lines.append("**Variants:**")
|
|
lines.append("")
|
|
for v in sorted(variant_files, key=lambda x: x["name"]):
|
|
vname = v["name"]
|
|
vmd5 = v.get("md5", "unknown")
|
|
lines.append(f"- `{vname}` MD5: `{vmd5}`")
|
|
|
|
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
|
|
desc = cls_desc.get(cls, "")
|
|
lines.append(f"| [{cls}](#{cls}) | {len(entries)} | {desc} |")
|
|
lines.append("")
|
|
|
|
# Per-classification sections
|
|
for cls in cls_order:
|
|
entries = by_class.get(cls, [])
|
|
if not entries:
|
|
continue
|
|
lines.extend([
|
|
f"## {cls}",
|
|
"",
|
|
"| 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}"
|
|
lines.append(
|
|
f"| [{emu_name}]({name}.md) | {sys_str} | {len(files)} |"
|
|
)
|
|
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 generate_emulator_page(name: str, profile: dict, db: dict,
|
|
platform_files: dict | 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 = profile.get("source", "")
|
|
upstream = profile.get("upstream", "")
|
|
version = profile.get("core_version", "unknown")
|
|
display = 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"| | |",
|
|
f"|---|---|",
|
|
f"| Type | {emu_type} |",
|
|
]
|
|
if classification:
|
|
lines.append(f"| Classification | {classification} |")
|
|
if source:
|
|
lines.append(f"| Source | [{source}]({source}) |")
|
|
if upstream and upstream != source:
|
|
lines.append(f"| Upstream | [{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", "Verification mode"),
|
|
("source_ref", "Source ref"), ("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} |")
|
|
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 = notes.replace("\n", "\n ")
|
|
lines.extend(["???+ note \"Technical notes\"",
|
|
f" {indented}",
|
|
""])
|
|
|
|
if not files:
|
|
lines.append("No BIOS or firmware files required.")
|
|
if exclusion:
|
|
lines.extend([
|
|
"",
|
|
f"!!! info \"Why no files\"",
|
|
f" {exclusion}",
|
|
])
|
|
else:
|
|
by_name = db.get("indexes", {}).get("by_name", {})
|
|
files_db = db.get("files", {})
|
|
|
|
# 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 f.get("name", "") in by_name)
|
|
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:
|
|
fname = f.get("name", "")
|
|
required = f.get("required", False)
|
|
in_repo = fname in by_name
|
|
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", "")
|
|
|
|
# Status badges
|
|
badges = []
|
|
if required:
|
|
badges.append("**required**")
|
|
else:
|
|
badges.append("optional")
|
|
if hle:
|
|
badges.append("HLE available")
|
|
if mode:
|
|
badges.append(mode)
|
|
if category and category != "bios":
|
|
badges.append(category)
|
|
if region:
|
|
badges.append(", ".join(region) if isinstance(region, list) else str(region))
|
|
if storage and storage != "embedded":
|
|
badges.append(storage)
|
|
if bundled:
|
|
badges.append("bundled in binary")
|
|
if embedded:
|
|
badges.append("embedded")
|
|
if has_builtin:
|
|
badges.append("has built-in fallback")
|
|
if archive:
|
|
badges.append(f"in `{archive}`")
|
|
if ftype and ftype != "bios":
|
|
badges.append(ftype)
|
|
if not in_repo:
|
|
badges.append("missing from repo")
|
|
|
|
lines.append(f"**`{fname}`** -{', '.join(badges)}")
|
|
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:
|
|
size_str = _fmt_size(size)
|
|
if fmin or fmax:
|
|
bounds = []
|
|
if fmin:
|
|
bounds.append(f"min {_fmt_size(fmin)}")
|
|
if fmax:
|
|
bounds.append(f"max {_fmt_size(fmax)}")
|
|
size_str += f" ({', '.join(bounds)})"
|
|
details.append(f"Size: {size_str}")
|
|
elif fmin or fmax:
|
|
bounds = []
|
|
if fmin:
|
|
bounds.append(f"min {_fmt_size(fmin)}")
|
|
if fmax:
|
|
bounds.append(f"max {_fmt_size(fmax)}")
|
|
details.append(f"Size: {', '.join(bounds)}")
|
|
if fsha1:
|
|
details.append(f"SHA1: `{fsha1}`")
|
|
if fmd5:
|
|
details.append(f"MD5: `{fmd5}`")
|
|
if fcrc32:
|
|
details.append(f"CRC32: `{fcrc32}`")
|
|
if fsha256:
|
|
details.append(f"SHA256: `{fsha256}`")
|
|
if fadler32:
|
|
details.append(f"Adler32: `{fadler32}`")
|
|
if aliases:
|
|
details.append(f"Aliases: {', '.join(f'`{a}`' for a in aliases)}")
|
|
if priority is not None:
|
|
details.append(f"Priority: {priority}")
|
|
if fast_boot is not None:
|
|
details.append(f"Fast boot: {'yes' if fast_boot else 'no'}")
|
|
if validation:
|
|
if isinstance(validation, list):
|
|
details.append(f"Validation: {', '.join(validation)}")
|
|
elif isinstance(validation, dict):
|
|
for scope, checks in validation.items():
|
|
details.append(f"Validation ({scope}): {', '.join(checks)}")
|
|
if source_ref:
|
|
details.append(f"Source: `{source_ref}`")
|
|
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("")
|
|
|
|
# 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 generate_gap_analysis(
|
|
profiles: dict,
|
|
coverages: dict,
|
|
db: dict,
|
|
) -> str:
|
|
"""Generate a global gap analysis page showing all missing/undeclared files."""
|
|
by_name = db.get("indexes", {}).get("by_name", {})
|
|
platform_files = _build_platform_file_index(coverages)
|
|
|
|
lines = [
|
|
f"# Gap Analysis - {SITE_NAME}",
|
|
"",
|
|
"Files that emulators load but platforms don't declare, and their availability.",
|
|
"",
|
|
]
|
|
|
|
# Global stats
|
|
total_undeclared = 0
|
|
total_in_repo = 0
|
|
total_missing = 0
|
|
|
|
# Build global set of all platform-declared filenames (once)
|
|
all_platform_names = set()
|
|
for pfiles in platform_files.values():
|
|
all_platform_names.update(pfiles)
|
|
|
|
emulator_gaps = []
|
|
for emu_name, profile in sorted(profiles.items()):
|
|
if profile.get("type") == "alias":
|
|
continue
|
|
files = profile.get("files", [])
|
|
if not files:
|
|
continue
|
|
|
|
undeclared = []
|
|
for f in files:
|
|
fname = f.get("name", "")
|
|
if not fname or fname.startswith("<"):
|
|
continue
|
|
if fname not in all_platform_names:
|
|
in_repo = fname in by_name
|
|
undeclared.append({
|
|
"name": fname,
|
|
"required": f.get("required", False),
|
|
"in_repo": in_repo,
|
|
"source_ref": f.get("source_ref", ""),
|
|
})
|
|
total_undeclared += 1
|
|
if in_repo:
|
|
total_in_repo += 1
|
|
else:
|
|
total_missing += 1
|
|
|
|
if undeclared:
|
|
emulator_gaps.append((emu_name, profile.get("emulator", emu_name), undeclared))
|
|
|
|
lines.extend([
|
|
"## Summary",
|
|
"",
|
|
f"| Metric | Count |",
|
|
f"|--------|-------|",
|
|
f"| Total undeclared files | {total_undeclared} |",
|
|
f"| Already in repo | {total_in_repo} |",
|
|
f"| Missing from repo | {total_missing} |",
|
|
f"| Emulators with gaps | {len(emulator_gaps)} |",
|
|
"",
|
|
])
|
|
|
|
# Per-emulator breakdown
|
|
lines.extend([
|
|
"## Per Emulator",
|
|
"",
|
|
"| Emulator | Undeclared | In Repo | Missing |",
|
|
"|----------|-----------|---------|---------|",
|
|
])
|
|
|
|
for emu_name, display, gaps in sorted(emulator_gaps, key=lambda x: -len(x[2])):
|
|
in_repo = sum(1 for g in gaps if g["in_repo"])
|
|
missing = len(gaps) - in_repo
|
|
lines.append(f"| [{display}](emulators/{emu_name}.md) | {len(gaps)} | {in_repo} | {missing} |")
|
|
|
|
# Missing files detail (not in repo)
|
|
all_missing = set()
|
|
missing_details = []
|
|
for emu_name, display, gaps in emulator_gaps:
|
|
for g in gaps:
|
|
if not g["in_repo"] and g["name"] not in all_missing:
|
|
all_missing.add(g["name"])
|
|
missing_details.append({
|
|
"name": g["name"],
|
|
"emulator": display,
|
|
"required": g["required"],
|
|
"source_ref": g["source_ref"],
|
|
})
|
|
|
|
# Build reverse map: emulator -> platforms that use it (via cores: field)
|
|
from common import resolve_platform_cores
|
|
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)
|
|
|
|
if missing_details:
|
|
req_missing = [m for m in missing_details if m["required"]]
|
|
opt_missing = [m for m in missing_details if not m["required"]]
|
|
|
|
lines.extend([
|
|
"",
|
|
f"## Missing Files ({len(missing_details)} unique, {len(req_missing)} required)",
|
|
"",
|
|
"Files loaded by emulators but not available in the repository.",
|
|
"Adding these files would improve pack completeness.",
|
|
"",
|
|
])
|
|
|
|
if req_missing:
|
|
lines.extend([
|
|
"### Required (highest priority)",
|
|
"",
|
|
"These files are needed for the emulator to function.",
|
|
"",
|
|
"| File | Emulator | Affects platforms | Source |",
|
|
"|------|----------|------------------|--------|",
|
|
])
|
|
for m in sorted(req_missing, key=lambda x: x["name"]):
|
|
emu_key = next((k for k, v in profiles.items()
|
|
if v.get("emulator") == m["emulator"]), "")
|
|
plats = sorted(emu_to_platforms.get(emu_key, set()))
|
|
plat_str = ", ".join(plats) if plats else "-"
|
|
lines.append(f"| `{m['name']}` | {m['emulator']} | {plat_str} | {m['source_ref']} |")
|
|
lines.append("")
|
|
|
|
if opt_missing:
|
|
lines.extend([
|
|
"### Optional",
|
|
"",
|
|
"| File | Emulator | Source |",
|
|
"|------|----------|--------|",
|
|
])
|
|
for m in sorted(opt_missing, key=lambda x: x["name"]):
|
|
lines.append(f"| `{m['name']}` | {m['emulator']} | {m['source_ref']} |")
|
|
lines.append("")
|
|
|
|
lines.extend(["", f"*Generated on {_timestamp()}*"])
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
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
|
|
|
|
lines = [
|
|
f"# Cross-reference - {SITE_NAME}",
|
|
"",
|
|
"Platform >Core >Systems >Upstream emulator.",
|
|
"",
|
|
"The libretro core is a port of the upstream emulator. "
|
|
"Files, features, and validation may differ between the two.",
|
|
"",
|
|
]
|
|
|
|
# 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}](platforms/{pname}.md)\"")
|
|
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 = p.get("core_classification", "-")
|
|
emu_type = p.get("type", "")
|
|
upstream = p.get("upstream", "")
|
|
source = p.get("source", "")
|
|
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:
|
|
upstream_short = upstream.replace("https://github.com/", "")
|
|
upstream_display = f"[{upstream_short}]({upstream})"
|
|
elif source:
|
|
source_short = source.replace("https://github.com/", "")
|
|
upstream_display = f"[{source_short}]({source})"
|
|
|
|
lines.append(
|
|
f" | [{emu_display}](emulators/{emu_name}.md) | {cls} | "
|
|
f"{sys_str} | {file_str} | {upstream_display} |"
|
|
)
|
|
|
|
lines.append("")
|
|
|
|
# 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()):
|
|
upstream = p.get("upstream", p.get("source", ""))
|
|
if upstream:
|
|
by_upstream.setdefault(upstream, []).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:
|
|
classifications.add(unique[c].get("core_classification", "-"))
|
|
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))
|
|
|
|
lines.append(
|
|
f"| [{upstream_short}]({upstream_url}) | {core_links} | "
|
|
f"{cls_str} | {plat_str} |"
|
|
)
|
|
|
|
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. Create a Pull Request - hashes are verified automatically
|
|
|
|
## Add a platform
|
|
|
|
1. Create a scraper in `scripts/scraper/` (inherit `BaseScraper`)
|
|
2. Read the platform's upstream source code to understand its BIOS check logic
|
|
3. Add entry to `platforms/_registry.yml`
|
|
4. Generate the platform YAML config
|
|
5. Test: `python scripts/verify.py --platform <name>`
|
|
|
|
## Add an emulator profile
|
|
|
|
1. Clone the emulator's source code
|
|
2. Search for BIOS/firmware loading (grep for `bios`, `rom`, `firmware`, `fopen`)
|
|
3. Document every file the emulator loads with source code references
|
|
4. Write YAML to `emulators/<name>.yml`
|
|
5. Test: `python scripts/cross_reference.py --emulator <name>`
|
|
|
|
## 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
|
|
|
|
## PR validation
|
|
|
|
The CI automatically:
|
|
- Computes SHA1/MD5/CRC32 of new files
|
|
- Checks against known hashes in platform configs
|
|
- Reports coverage impact
|
|
"""
|
|
|
|
|
|
# Wiki pages
|
|
|
|
def generate_wiki_index() -> str:
|
|
"""Generate wiki landing page."""
|
|
return f"""# Wiki - {SITE_NAME}
|
|
|
|
Technical documentation for the RetroBIOS toolchain.
|
|
|
|
## Pages
|
|
|
|
- **[Architecture](architecture.md)** - directory structure, data flow, platform inheritance, pack grouping, security, edge cases, CI workflows
|
|
- **[Tools](tools.md)** - CLI reference for every script, pipeline usage, scrapers
|
|
- **[Profiling guide](profiling.md)** - how to create an emulator profile from source code, step by step, with YAML field reference
|
|
- **[Data model](data-model.md)** - database.json structure, indexes, file resolution order, YAML formats
|
|
|
|
## For users
|
|
|
|
If you just want to download BIOS packs, see the [home page](../index.md).
|
|
|
|
## For contributors
|
|
|
|
Start with the [profiling guide](profiling.md) to understand how emulator profiles are built,
|
|
then see [contributing](../contributing.md) for submission guidelines.
|
|
"""
|
|
|
|
|
|
def generate_wiki_architecture() -> str:
|
|
"""Generate architecture overview from codebase structure."""
|
|
lines = [
|
|
f"# Architecture - {SITE_NAME}",
|
|
"",
|
|
"## Directory structure",
|
|
"",
|
|
"```",
|
|
"bios/ BIOS and firmware files, organized by Manufacturer/Console/",
|
|
" Manufacturer/Console/ canonical files (one per unique content)",
|
|
" .variants/ alternate versions (different hash, same purpose)",
|
|
"emulators/ one YAML profile per core (285 profiles)",
|
|
"platforms/ one YAML config per platform (scraped from upstream)",
|
|
" _shared.yml shared file groups across platforms",
|
|
" _registry.yml platform metadata (logos, scrapers, status)",
|
|
" _data_dirs.yml data directory definitions (Dolphin Sys, PPSSPP...)",
|
|
"scripts/ all tooling (Python, pyyaml only dependency)",
|
|
" scraper/ upstream scrapers (libretro, batocera, recalbox...)",
|
|
"data/ cached data directories (not BIOS, fetched at build)",
|
|
"schemas/ JSON schemas for validation",
|
|
"tests/ E2E test suite with synthetic fixtures",
|
|
"dist/ generated packs (gitignored)",
|
|
".cache/ hash cache and large file downloads (gitignored)",
|
|
"```",
|
|
"",
|
|
"## Data flow",
|
|
"",
|
|
"```",
|
|
"Upstream sources Scrapers parse generate_db.py scans",
|
|
" System.dat (libretro) + fetch versions bios/ on disk",
|
|
" batocera-systems builds database.json",
|
|
" es_bios.xml (recalbox) (SHA1 primary key,",
|
|
" core-info .info files indexes: by_md5, by_name,",
|
|
" by_crc32, by_path_suffix)",
|
|
"",
|
|
"emulators/*.yml verify.py checks generate_pack.py resolves",
|
|
" source-verified platform-native files by hash, builds ZIP",
|
|
" from code verification packs per platform",
|
|
"```",
|
|
"",
|
|
"## Three layers of data",
|
|
"",
|
|
"| Layer | Source | Role |",
|
|
"|-------|--------|------|",
|
|
"| Platform YAML | Scraped from upstream | What the platform declares it needs |",
|
|
"| `_shared.yml` | Curated | Shared files across platforms, reflects actual behavior |",
|
|
"| Emulator profiles | Source-verified | What the code actually loads. Used for cross-reference and gap detection |",
|
|
"",
|
|
"The pack combines platform baseline (layer 1) with core requirements (layer 3).",
|
|
"Neither too much (no files from unused cores) nor too few (no missing files for active cores).",
|
|
"",
|
|
"## Pack grouping",
|
|
"",
|
|
"Platforms that produce identical packs are grouped automatically.",
|
|
"RetroArch and Lakka share the same files and `base_destination` (`system/`),",
|
|
"so they produce one combined pack (`RetroArch_Lakka_BIOS_Pack.zip`).",
|
|
"RetroPie uses `BIOS/` as base path, so it gets a separate pack.",
|
|
"",
|
|
"## Storage tiers",
|
|
"",
|
|
"| Tier | Meaning |",
|
|
"|------|---------|",
|
|
"| `embedded` (default) | file is in the `bios/` directory, included in packs |",
|
|
"| `external` | file has a `source_url`, downloaded at pack build time |",
|
|
"| `user_provided` | user must provide the file (instructions included in pack) |",
|
|
"",
|
|
"## Verification severity",
|
|
"",
|
|
"How missing or mismatched files are reported:",
|
|
"",
|
|
"| Mode | required + missing | optional + missing | hash mismatch |",
|
|
"|------|-------------------|-------------------|--------------|",
|
|
"| existence | WARNING | INFO | N/A |",
|
|
"| md5 | CRITICAL | WARNING | UNTESTED |",
|
|
"",
|
|
"Files with `hle_fallback: true` are downgraded to INFO when missing",
|
|
"(the emulator has a software fallback).",
|
|
"",
|
|
"## Discrepancy detection",
|
|
"",
|
|
"When a file passes platform verification (MD5 match) but fails",
|
|
"emulator-level validation (wrong CRC32, wrong size), a DISCREPANCY is reported.",
|
|
"The pack generator searches the repo for a variant that satisfies both.",
|
|
"If none exists, the platform version is kept.",
|
|
"",
|
|
"## Security",
|
|
"",
|
|
"- `safe_extract_zip()` prevents zip-slip path traversal attacks",
|
|
"- `deterministic_zip` rebuilds MAME ZIPs so same ROMs always produce the same hash",
|
|
"- `crypto_verify.py` and `sect233r1.py` verify 3DS RSA-2048 signatures and AES-128-CBC integrity",
|
|
"- ZIP inner ROM verification via `checkInsideZip()` replicates Batocera's behavior",
|
|
"- `md5_composite()` replicates Recalbox's composite ZIP hash",
|
|
"",
|
|
"## Edge cases",
|
|
"",
|
|
"| Case | Handling |",
|
|
"|------|---------|",
|
|
"| Batocera truncated MD5 (29 chars) | prefix match in resolution |",
|
|
"| `zippedFile` entries | MD5 is of the ROM inside the ZIP, not the ZIP itself |",
|
|
"| Regional variants (same filename) | `by_path_suffix` index disambiguates |",
|
|
"| MAME BIOS ZIPs | `contents` field documents inner structure |",
|
|
"| RPG Maker/ScummVM | excluded from dedup (NODEDUP) to preserve directory structure |",
|
|
"| `strip_components` in data dirs | flattens cache prefix to match expected path |",
|
|
"| case-insensitive dedup | prevents `font.rom` + `FONT.ROM` conflicts on Windows/macOS |",
|
|
"",
|
|
"## Platform inheritance",
|
|
"",
|
|
"Platform configs support `inherits:` to share definitions.",
|
|
"Lakka inherits from RetroArch, RetroPie inherits from RetroArch with `base_destination: BIOS`.",
|
|
"`overrides:` allows child platforms to modify specific systems from the parent.",
|
|
"",
|
|
"Core resolution (`resolve_platform_cores`) uses three strategies:",
|
|
"",
|
|
"- `cores: all_libretro` - include all profiles with `libretro` in their type",
|
|
"- `cores: [list]` - include only named profiles",
|
|
"- `cores:` absent - fallback to system ID intersection between platform and profiles",
|
|
"",
|
|
"## MAME clone map",
|
|
"",
|
|
"`_mame_clones.json` at repo root maps MAME clone ROM names to their canonical parent.",
|
|
"When a clone ZIP was deduplicated, `resolve_local_file` uses this map to find the canonical file.",
|
|
"",
|
|
"## Tests",
|
|
"",
|
|
"`tests/test_e2e.py` contains 75 end-to-end tests with synthetic fixtures.",
|
|
"Covers: file resolution, verification, severity, cross-reference, aliases,",
|
|
"inheritance, shared groups, data dirs, storage tiers, HLE, launchers,",
|
|
"platform grouping, core resolution (3 strategies + alias exclusion).",
|
|
"",
|
|
"```bash",
|
|
"python -m unittest tests.test_e2e -v",
|
|
"```",
|
|
"",
|
|
"## CI workflows",
|
|
"",
|
|
"| Workflow | File | Trigger | Role |",
|
|
"|----------|------|---------|------|",
|
|
"| Build & Release | `build.yml` | `workflow_dispatch` (manual) | restore large files, build packs, deploy site, create GitHub release |",
|
|
"| PR Validation | `validate.yml` | pull request on `bios/`/`platforms/` | validate BIOS hashes, schema check, run tests, auto-label PR |",
|
|
"| Weekly Sync | `watch.yml` | cron (Monday 6 AM UTC) + manual | scrape upstream sources, detect changes, create update PR |",
|
|
"",
|
|
"Build workflow has a 7-day rate limit between releases and keeps the 3 most recent.",
|
|
"",
|
|
"## License",
|
|
"",
|
|
"See `LICENSE` at repo root. Files are provided for personal backup and archival.",
|
|
"",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def generate_wiki_tools() -> str:
|
|
"""Generate tool reference from script docstrings and argparse."""
|
|
lines = [
|
|
f"# Tools - {SITE_NAME}",
|
|
"",
|
|
"All tools are Python scripts in `scripts/`. Single dependency: `pyyaml`.",
|
|
"",
|
|
"## Pipeline",
|
|
"",
|
|
"Run everything in sequence:",
|
|
"",
|
|
"```bash",
|
|
"python scripts/pipeline.py --offline # DB + verify + packs + readme + site",
|
|
"python scripts/pipeline.py --offline --skip-packs # DB + verify only",
|
|
"python scripts/pipeline.py --skip-docs # skip readme + site generation",
|
|
"```",
|
|
"",
|
|
"## Individual tools",
|
|
"",
|
|
"### generate_db.py",
|
|
"",
|
|
"Scan `bios/` and build `database.json` with multi-indexed lookups.",
|
|
"Large files in `.gitignore` are preserved from the existing database",
|
|
"and downloaded from GitHub release assets if not cached locally.",
|
|
"",
|
|
"```bash",
|
|
"python scripts/generate_db.py --force --bios-dir bios --output database.json",
|
|
"```",
|
|
"",
|
|
"### verify.py",
|
|
"",
|
|
"Check BIOS coverage for each platform using its native verification mode.",
|
|
"",
|
|
"```bash",
|
|
"python scripts/verify.py --all # all platforms",
|
|
"python scripts/verify.py --platform batocera # single platform",
|
|
"python scripts/verify.py --emulator dolphin # single emulator",
|
|
"python scripts/verify.py --system atari-lynx # single system",
|
|
"```",
|
|
"",
|
|
"Verification modes per platform:",
|
|
"",
|
|
"| Platform | Mode | Logic |",
|
|
"|----------|------|-------|",
|
|
"| RetroArch, Lakka, RetroPie | existence | file present = OK |",
|
|
"| Batocera, RetroBat | md5 | MD5 hash match |",
|
|
"| Recalbox | md5 | MD5 multi-hash, 3 severity levels |",
|
|
"| EmuDeck | md5 | MD5 whitelist per system |",
|
|
"",
|
|
"### generate_pack.py",
|
|
"",
|
|
"Build platform-specific BIOS ZIP packs.",
|
|
"",
|
|
"```bash",
|
|
"# Full platform packs",
|
|
"python scripts/generate_pack.py --all --output-dir dist/",
|
|
"python scripts/generate_pack.py --platform batocera",
|
|
"python scripts/generate_pack.py --emulator dolphin",
|
|
"python scripts/generate_pack.py --system atari-lynx",
|
|
"",
|
|
"# Granular options",
|
|
"python scripts/generate_pack.py --platform retroarch --system sony-playstation",
|
|
"python scripts/generate_pack.py --platform batocera --required-only",
|
|
"python scripts/generate_pack.py --platform retroarch --split",
|
|
"python scripts/generate_pack.py --platform retroarch --split --group-by manufacturer",
|
|
"",
|
|
"# Hash-based lookup and custom packs",
|
|
"python scripts/generate_pack.py --from-md5 d8f1206299c48946e6ec5ef96d014eaa",
|
|
"python scripts/generate_pack.py --platform batocera --from-md5-file missing.txt",
|
|
"python scripts/generate_pack.py --platform retroarch --list-systems",
|
|
"```",
|
|
"",
|
|
"Packs include platform baseline files plus files required by the platform's cores.",
|
|
"When a file passes platform verification but fails emulator validation,",
|
|
"the tool searches for a variant that satisfies both.",
|
|
"If none exists, the platform version is kept and the discrepancy is reported.",
|
|
"",
|
|
"**Granular options:**",
|
|
"",
|
|
"- `--system` with `--platform`: filter to specific systems within a platform pack",
|
|
"- `--required-only`: exclude optional files, keep only required",
|
|
"- `--split`: generate one ZIP per system instead of one big pack",
|
|
"- `--split --group-by manufacturer`: group split packs by manufacturer (Sony, Nintendo, Sega...)",
|
|
"- `--from-md5`: look up a hash in the database, or build a custom pack with `--platform`/`--emulator`",
|
|
"- `--from-md5-file`: same, reading hashes from a file (one per line, comments with #)",
|
|
"",
|
|
"### cross_reference.py",
|
|
"",
|
|
"Compare emulator profiles against platform configs.",
|
|
"Reports files that cores need but platforms don't declare.",
|
|
"",
|
|
"```bash",
|
|
"python scripts/cross_reference.py # all",
|
|
"python scripts/cross_reference.py --emulator dolphin # single",
|
|
"```",
|
|
"",
|
|
"### refresh_data_dirs.py",
|
|
"",
|
|
"Fetch data directories (Dolphin Sys, PPSSPP assets, blueMSX databases)",
|
|
"from upstream repositories into `data/`.",
|
|
"",
|
|
"```bash",
|
|
"python scripts/refresh_data_dirs.py",
|
|
"python scripts/refresh_data_dirs.py --key dolphin-sys --force",
|
|
"```",
|
|
"",
|
|
"### Other tools",
|
|
"",
|
|
"| Script | Purpose |",
|
|
"|--------|---------|",
|
|
"| `dedup.py` | Deduplicate `bios/`, move duplicates to `.variants/`. RPG Maker and ScummVM excluded (NODEDUP) |",
|
|
"| `validate_pr.py` | Validate BIOS files in pull requests |",
|
|
"| `auto_fetch.py` | Fetch missing BIOS files from known sources |",
|
|
"| `list_platforms.py` | List active platforms (used by CI) |",
|
|
"| `download.py` | Download packs from GitHub releases |",
|
|
"| `common.py` | Shared library: hash computation, file resolution, platform config loading, emulator profiles |",
|
|
"| `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database |",
|
|
"| `generate_site.py` | Generate all MkDocs site pages (this documentation) |",
|
|
"| `deterministic_zip.py` | Rebuild MAME BIOS ZIPs deterministically (same ROMs = same hash) |",
|
|
"| `crypto_verify.py` | 3DS RSA signature and AES crypto verification |",
|
|
"| `sect233r1.py` | Pure Python ECDSA verification on sect233r1 curve (3DS OTP cert) |",
|
|
"| `batch_profile.py` | Batch profiling automation for libretro cores |",
|
|
"| `migrate.py` | Migrate flat bios structure to Manufacturer/Console/ hierarchy |",
|
|
"",
|
|
"## Large files",
|
|
"",
|
|
"Files over 50 MB are stored as assets on the `large-files` GitHub release.",
|
|
"They are listed in `.gitignore` so they don't bloat the git repository.",
|
|
"`generate_db.py` downloads them from the release when rebuilding the database,",
|
|
"using `fetch_large_file()` from `common.py`. The same function is used by",
|
|
"`generate_pack.py` when a file has a hash mismatch with the local variant.",
|
|
"",
|
|
"## Scrapers",
|
|
"",
|
|
"Located in `scripts/scraper/`. Each inherits `BaseScraper` and implements `fetch_requirements()`.",
|
|
"",
|
|
"| Scraper | Source | Format |",
|
|
"|---------|--------|--------|",
|
|
"| `libretro_scraper` | System.dat + core-info .info files | clrmamepro DAT |",
|
|
"| `batocera_scraper` | batocera-systems script | Python dict |",
|
|
"| `recalbox_scraper` | es_bios.xml | XML |",
|
|
"| `retrobat_scraper` | batocera-systems.json | JSON |",
|
|
"| `emudeck_scraper` | checkBIOS.sh | Bash + CSV |",
|
|
"| `retrodeck_scraper` | component manifests | JSON per component |",
|
|
"| `coreinfo_scraper` | .info files from libretro-core-info | INI-like |",
|
|
"",
|
|
"Internal modules: `base_scraper.py` (abstract base with `_fetch_raw()` caching",
|
|
"and shared CLI), `dat_parser.py` (clrmamepro DAT format parser).",
|
|
"",
|
|
"Adding a scraper: inherit `BaseScraper`, implement `fetch_requirements()`,",
|
|
"call `scraper_cli(YourScraper)` in `__main__`.",
|
|
"",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def generate_wiki_profiling() -> str:
|
|
"""Generate the emulator profiling methodology guide."""
|
|
lines = [
|
|
f"# Profiling guide - {SITE_NAME}",
|
|
"",
|
|
"How to create an emulator profile from source code.",
|
|
"",
|
|
"## Approach",
|
|
"",
|
|
"A profile documents what an emulator loads at runtime.",
|
|
"The source code is the reference because it reflects actual behavior.",
|
|
"Documentation, .info files, and wikis are useful starting points",
|
|
"but are verified against the code.",
|
|
"",
|
|
"## Steps",
|
|
"",
|
|
"### 1. Find the source code",
|
|
"",
|
|
"Check these locations in order:",
|
|
"",
|
|
"1. Upstream original (the emulator's own repository)",
|
|
"2. Libretro fork (may have adapted paths or added files)",
|
|
"3. If not on GitHub: GitLab, Codeberg, SourceForge, archive.org",
|
|
"",
|
|
"Always clone both upstream and libretro port to compare.",
|
|
"",
|
|
"### 2. Trace file loading",
|
|
"",
|
|
"Read the code flow. Don't grep keywords by assumption.",
|
|
"Each emulator has its own way of loading files.",
|
|
"",
|
|
"Look for:",
|
|
"",
|
|
"- `fopen`, `open`, `read_file`, `load_rom`, `load_bios` calls",
|
|
"- `retro_system_directory` / `system_dir` in libretro cores",
|
|
"- File existence checks (`path_is_valid`, `file_exists`)",
|
|
"- Hash validation (MD5, CRC32, SHA1 comparisons in code)",
|
|
"- Size validation (`fseek`/`ftell`, `stat`, fixed buffer sizes)",
|
|
"",
|
|
"### 3. Determine required vs optional",
|
|
"",
|
|
"This is decided by code behavior, not by judgment:",
|
|
"",
|
|
"- **required**: the core does not start or function without the file",
|
|
"- **optional**: the core works with degraded functionality without it",
|
|
"- **hle_fallback: true**: the core has a high-level emulation path when the file is missing",
|
|
"",
|
|
"### 4. Document divergences",
|
|
"",
|
|
"When the libretro port differs from the upstream:",
|
|
"",
|
|
"- `mode: libretro` - file only used by the libretro core",
|
|
"- `mode: standalone` - file only used in standalone mode",
|
|
"- `mode: both` - used by both (default, can be omitted)",
|
|
"",
|
|
"Path differences (current dir vs system_dir) are normal adaptation,",
|
|
"not a divergence. Name changes (e.g. `naomi2_` to `n2_`) may be intentional",
|
|
"to avoid conflicts in the shared system directory.",
|
|
"",
|
|
"### 5. Write the YAML profile",
|
|
"",
|
|
"```yaml",
|
|
"emulator: Dolphin",
|
|
"type: standalone + libretro",
|
|
"core_classification: community_fork",
|
|
"source: https://github.com/libretro/dolphin",
|
|
"upstream: https://github.com/dolphin-emu/dolphin",
|
|
"profiled_date: 2026-03-25",
|
|
"core_version: 5.0-21264",
|
|
"systems:",
|
|
" - nintendo-gamecube",
|
|
" - nintendo-wii",
|
|
"",
|
|
"files:",
|
|
" - name: GC/USA/IPL.bin",
|
|
" system: nintendo-gamecube",
|
|
" required: false",
|
|
" hle_fallback: true",
|
|
" size: 2097152",
|
|
" validation: [size, adler32]",
|
|
" known_hash_adler32: 0x4f1f6f5c",
|
|
" region: north-america",
|
|
" source_ref: Source/Core/Core/Boot/Boot_BS2Emu.cpp:42",
|
|
"```",
|
|
"",
|
|
"### 6. Validate",
|
|
"",
|
|
"```bash",
|
|
"python scripts/cross_reference.py --emulator dolphin --json",
|
|
"python scripts/verify.py --emulator dolphin",
|
|
"```",
|
|
"",
|
|
"## YAML field reference",
|
|
"",
|
|
"### Profile fields",
|
|
"",
|
|
"| Field | Required | Description |",
|
|
"|-------|----------|-------------|",
|
|
"| `emulator` | yes | display name |",
|
|
"| `type` | yes | `libretro`, `standalone`, `standalone + libretro`, `alias`, `launcher` |",
|
|
"| `core_classification` | no | `pure_libretro`, `official_port`, `community_fork`, `frozen_snapshot`, `enhanced_fork`, `game_engine`, `embedded_hle`, `alias`, `launcher` |",
|
|
"| `source` | yes | libretro core repository URL |",
|
|
"| `upstream` | no | original emulator repository URL |",
|
|
"| `profiled_date` | yes | date of source analysis |",
|
|
"| `core_version` | yes | version analyzed |",
|
|
"| `systems` | yes | list of system IDs this core handles |",
|
|
"| `cores` | no | list of core names (default: profile filename) |",
|
|
"| `files` | yes | list of file entries |",
|
|
"| `notes` | no | free-form technical notes |",
|
|
"| `exclusion_note` | no | why the profile has no files |",
|
|
"| `data_directories` | no | references to data dirs in `_data_dirs.yml` |",
|
|
"",
|
|
"### File entry fields",
|
|
"",
|
|
"| Field | Description |",
|
|
"|-------|-------------|",
|
|
"| `name` | filename as the core expects it |",
|
|
"| `required` | true if the core needs this file to function |",
|
|
"| `system` | system ID this file belongs to |",
|
|
"| `size` | expected size in bytes |",
|
|
"| `md5`, `sha1`, `crc32`, `sha256` | expected hashes from source code |",
|
|
"| `validation` | list of checks the code performs: `size`, `crc32`, `md5`, `sha1` |",
|
|
"| `aliases` | alternate filenames for the same file |",
|
|
"| `mode` | `libretro`, `standalone`, or `both` |",
|
|
"| `hle_fallback` | true if a high-level emulation path exists |",
|
|
"| `category` | `bios` (default), `game_data`, `bios_zip` |",
|
|
"| `region` | geographic region (e.g. `north-america`, `japan`) |",
|
|
"| `source_ref` | source file and line number |",
|
|
"| `path` | path relative to system directory |",
|
|
"| `description` | what this file is |",
|
|
"| `note` | additional context |",
|
|
"| `archive` | parent ZIP if this file is inside an archive |",
|
|
"| `contents` | structure of files inside a BIOS ZIP |",
|
|
"| `storage` | `embedded` (default), `external`, `user_provided` |",
|
|
"",
|
|
]
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
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", {}))
|
|
|
|
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": "..."',
|
|
'}',
|
|
"```",
|
|
"",
|
|
"### Indexes",
|
|
"",
|
|
f"| Index | Entries | Purpose |",
|
|
f"|-------|---------|---------|",
|
|
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) |",
|
|
"",
|
|
"### File resolution order",
|
|
"",
|
|
"`resolve_local_file` tries these steps in order:",
|
|
"",
|
|
"1. Path suffix exact match (for regional variants with same filename)",
|
|
"2. SHA1 exact match",
|
|
"3. MD5 direct lookup (supports truncated Batocera 29-char MD5)",
|
|
"4. Name + alias lookup without hash (existence mode)",
|
|
"5. Name + alias with md5_composite / direct MD5 per candidate",
|
|
"6. zippedFile content match via inner ROM MD5 index",
|
|
"7. MAME clone fallback (deduped ZIP mapped to canonical name)",
|
|
"",
|
|
"## 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`).",
|
|
"",
|
|
"## Emulator YAML",
|
|
"",
|
|
f"**{len(profiles)}** profiles. Source-verified from emulator code.",
|
|
"",
|
|
"See the [profiling guide](profiling.md) for the full field reference.",
|
|
"",
|
|
]
|
|
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_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"},
|
|
{"Architecture": "wiki/architecture.md"},
|
|
{"Tools": "wiki/tools.md"},
|
|
{"Profiling guide": "wiki/profiling.md"},
|
|
{"Data model": "wiki/data-model.md"},
|
|
]
|
|
|
|
return [
|
|
{"Home": "index.md"},
|
|
{"Platforms": platform_nav},
|
|
{"Systems": system_nav},
|
|
{"Emulators": emu_nav},
|
|
{"Cross-reference": "cross-reference.md"},
|
|
{"Gap Analysis": "gaps.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)
|
|
|
|
registry_path = Path(args.platforms_dir) / "_registry.yml"
|
|
registry = {}
|
|
if registry_path.exists():
|
|
with open(registry_path) as f:
|
|
registry = (yaml.safe_load(f) or {}).get("platforms", {})
|
|
|
|
platform_names = list_registered_platforms(args.platforms_dir, include_archived=True)
|
|
|
|
print("Computing platform coverage...")
|
|
coverages = {}
|
|
for name in sorted(platform_names):
|
|
try:
|
|
cov = compute_coverage(name, args.platforms_dir, db)
|
|
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...")
|
|
(docs / "index.md").write_text(generate_home(db, coverages, profiles, registry))
|
|
|
|
# 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...")
|
|
(docs / "platforms" / "index.md").write_text(generate_platform_index(coverages))
|
|
for name, cov in coverages.items():
|
|
(docs / "platforms" / f"{name}.md").write_text(generate_platform_page(name, cov, registry, emulator_files))
|
|
|
|
# Generate system pages
|
|
print("Generating system pages...")
|
|
|
|
(docs / "systems" / "index.md").write_text(generate_systems_index(manufacturers))
|
|
for mfr, consoles in manufacturers.items():
|
|
slug = mfr.lower().replace(" ", "-")
|
|
page = generate_system_page(mfr, consoles, platform_files, emulator_files)
|
|
(docs / "systems" / f"{slug}.md").write_text(page)
|
|
|
|
# Generate emulator pages
|
|
print("Generating emulator pages...")
|
|
(docs / "emulators" / "index.md").write_text(generate_emulators_index(profiles))
|
|
for name, profile in profiles.items():
|
|
page = generate_emulator_page(name, profile, db, platform_files)
|
|
(docs / "emulators" / f"{name}.md").write_text(page)
|
|
|
|
# Generate cross-reference page
|
|
print("Generating cross-reference page...")
|
|
(docs / "cross-reference.md").write_text(
|
|
generate_cross_reference(coverages, profiles)
|
|
)
|
|
|
|
# Generate gap analysis page
|
|
print("Generating gap analysis page...")
|
|
(docs / "gaps.md").write_text(
|
|
generate_gap_analysis(profiles, coverages, db)
|
|
)
|
|
|
|
# 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)
|
|
(wiki_dest / "data-model.md").write_text(generate_wiki_data_model(db, profiles))
|
|
|
|
# Generate contributing
|
|
print("Generating contributing page...")
|
|
(docs / "contributing.md").write_text(generate_contributing())
|
|
|
|
# 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_url: https://abdess.github.io/retrobios/
|
|
repo_url: https://github.com/Abdess/retrobios
|
|
repo_name: Abdess/retrobios
|
|
theme:
|
|
name: material
|
|
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
|
|
features:
|
|
- navigation.tabs
|
|
- navigation.sections
|
|
- navigation.top
|
|
- navigation.indexes
|
|
- search.suggest
|
|
- search.highlight
|
|
- content.tabs.link
|
|
- toc.follow
|
|
markdown_extensions:
|
|
- tables
|
|
- admonition
|
|
- attr_list
|
|
- md_in_html
|
|
- toc:
|
|
permalink: true
|
|
- pymdownx.details
|
|
- pymdownx.superfences
|
|
- pymdownx.tabbed:
|
|
alternate_style: true
|
|
plugins:
|
|
- search
|
|
"""
|
|
with open("mkdocs.yml", "w") as f:
|
|
f.write(mkdocs_static)
|
|
f.write(nav_yaml)
|
|
|
|
total_pages = (
|
|
1 # home
|
|
+ 1 + len(coverages) # platform index + detail
|
|
+ 1 + len(manufacturers) # system index + detail
|
|
+ 1 # cross-reference
|
|
+ 1 + len(profiles) # emulator index + detail
|
|
+ 1 # gap analysis
|
|
+ 5 # wiki (index, architecture, tools, profiling, data model)
|
|
+ 1 # contributing
|
|
)
|
|
print(f"\nGenerated {total_pages} pages in {args.docs_dir}/")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|