feat: read guides and wiki nav from data

Platform guides, device groups, terminal tabs, pack README manuals and
logo credits move from Python tables to platforms/_guides.yml, checked
against the registry on load. A folder row without a path is the
registry's extract_to, written once.

Wiki pages place themselves in the navigation with order and nav_title
in their front matter. Generated docs/ and mkdocs.yml are unchanged.
This commit is contained in:
Abdessamad Derraz committed 2026-10-22 15:06:52 +02:00
1 parent b77cc0b8fd
commit be3324f869
28 files changed
+1207 -610

No files matched your search

+6 -25
View File
@@ -41,30 +41,6 @@ ICON_SIZE = 48
AVATAR_HOST = "avatars.githubusercontent.com"
USER_AGENT = "retrobios-site-images"
# Who owns each mark. Logos are shown to name the platform they belong to;
# the note says so, and gives the licence of the repository the file was
# taken from when that repository states one.
OWNERS = {
"retroarch": ("libretro", "repository licence GPL-3.0"),
"lakka": ("Lakka (libretro)", "file from libretro/retroarch-assets, CC-BY-4.0"),
"batocera": ("Batocera", "repository licence MIT"),
"recalbox": ("Recalbox", "file from homarr-labs/dashboard-icons, Apache-2.0"),
"retrobat": ("RetroBat", "repository states no licence"),
"emudeck": ("EmuDeck", "repository licence GPL-3.0"),
"retrodeck": ("RetroDECK", "repository licence GPL-3.0"),
"romm": ("RomM", "GitHub organisation avatar of rommapp"),
"rocknix": ("ROCKNIX", "GitHub organisation avatar of ROCKNIX"),
"amberelec": ("AmberELEC", "GitHub organisation avatar of AmberELEC"),
"emuelec": ("EmuELEC", "GitHub organisation avatar of EmuELEC"),
"knulli": ("KNULLI", "GitHub organisation avatar of knulli-cfw"),
"retropie": ("RetroPie", "GitHub organisation avatar of RetroPie"),
"bizhawk": ("TASVideos", "GitHub organisation avatar of TASEmulators"),
"misterfpga": ("MiSTer FPGA", "GitHub organisation avatar of MiSTer-devel"),
"minui": ("Shaun Inman", "GitHub avatar of shauninman, the author of MinUI"),
"nextui": ("LoveRetro", "GitHub organisation avatar of LoveRetro"),
}
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=60) as response:
@@ -107,6 +83,8 @@ def write_manifest(directory: Path, note: str, entries: list[dict]) -> None:
def fetch_logos() -> None:
from siteguides import GUIDES
with open(ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle:
platforms = yaml.safe_load(handle)["platforms"]
LOGO_DIR.mkdir(parents=True, exist_ok=True)
@@ -125,7 +103,10 @@ def fetch_logos() -> None:
if stale.name != file_name:
stale.unlink()
(LOGO_DIR / file_name).write_bytes(payload)
owner, licence = OWNERS.get(name, (name, "ownership not recorded"))
# Who owns each mark: logo_owner and logo_licence in the guides.
guide = GUIDES.get(name)
owner = (guide and guide.logo_owner) or name
licence = (guide and guide.logo_licence) or "ownership not recorded"
entries.append({
"file": file_name, "platform": name, "source": url,
"owner": owner, "licence": licence,
+112 -63
View File
@@ -75,7 +75,6 @@ from generate_readme import (
from provenance_report import build_report, distinct_missing
from siteexports import generate_exports_page, load_export_reports
from siteguides import (
Guide,
ALSO_RUNS,
DEVICE_GROUPS,
GROUP_ANCHORS,
@@ -2339,22 +2338,15 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
lines += [note, ""]
lines += [line for note in extract_notes("platforms") if f"The {display} pack " in note
for line in (note, "")]
if name == "retroarch":
if guide.more_folders:
detected = _detected_systems(registry, name) if guide.more_folders_detected else []
lines += [
'??? note "Other builds: Microsoft Store, Mac Steam, handhelds"',
"",
*_folder_rows(guide.more_folders, " "),
*(f" | {system} | `{folder}` |" for system, folder in _retroarch_handhelds(registry)),
"",
" LaunchBox and other launchers use the folder RetroArch reads.",
"",
]
elif guide.more_folders:
lines += [
'??? note "Other layouts"',
f'??? note "{guide.more_folders_title}"',
"",
*_folder_rows(guide.more_folders, " "),
*(f" | {system} | `{folder}` |" for system, folder in detected),
"",
*([f" {guide.more_folders_note}", ""] if guide.more_folders_note else []),
]
lines += [
*(["<!-- image: minui-card -->", ""] if guide.on_card else []),
@@ -6172,11 +6164,12 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str:
return f"{links} ({_fmt_size(size)})" if size else links
def _retroarch_handhelds(registry: dict) -> list[tuple[str, str]]:
"""Handheld systems that run RetroArch, with the BIOS folder the installer
writes on each, read from the detection rules of the registry."""
def _detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]:
"""Named systems a platform runs on, handhelds that run RetroArch for one,
with the BIOS folder the installer writes on each, read from the
detection rules of the registry."""
seen: dict[str, str] = {}
for rule in (registry.get("retroarch", {}).get("install") or {}).get("detect") or []:
for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []:
name, folder = rule.get("name"), rule.get("bios_path")
if name and folder and rule.get("installs", True) and name not in seen:
seen[name] = folder
@@ -6310,7 +6303,7 @@ def generate_which_pack() -> str:
" | System | Runs | BIOS folder |",
" |--------|------|-------------|",
*(f" | {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |"
for name, folder in _retroarch_handhelds(registry)),
for name, folder in _detected_systems(registry, "retroarch")),
"",
]
lines += [
@@ -6415,11 +6408,75 @@ def _platform_nav(coverages: dict, registry: dict) -> list:
return nav
# The tabs in reading order, by the level their pages declare.
TABS = {
"start": "Start here",
"setup": "Your setup",
"explore": "Explore",
"reference": "Reference",
"contribute": "Contribute",
}
class NavError(ValueError):
"""A wiki page does not say where it goes in the navigation."""
def _h1(markdown: str) -> str:
match = re.search(r"^# (.+?)(?:\s*\{[^}]*\})?\s*$", markdown, re.M)
return match.group(1).strip() if match else ""
def wiki_nav(wiki_dir: Path | None = None) -> dict[str, list[tuple[int, object]]]:
"""Wiki pages by tab, each at the place its front matter gives.
A page states `level:` (its tab), `order:` (its place in the tab, the
smallest first) and, when the navigation names it otherwise than its
first heading, `nav_title:`. `nav: false` keeps a page out on purpose.
A page that says neither raises, naming what to add.
"""
wiki_dir = Path(wiki_dir) if wiki_dir else _REPO_ROOT / WIKI_SRC_DIR
tabs: dict[str, list[tuple[int, object]]] = {level: [] for level in TABS}
for page in sorted(wiki_dir.glob("*.md")):
where = f"{WIKI_SRC_DIR}/{page.name}"
text = page.read_text(encoding="utf-8")
meta = _front_matter(text)
level = meta.get("level")
if level not in TABS:
raise NavError(
f"{where}: front matter needs `level:`, one of {', '.join(TABS)}, "
f"to place the page in a tab (found {level!r})"
)
if meta.get("nav") is False:
continue
order = meta.get("order")
if not isinstance(order, int) or isinstance(order, bool):
raise NavError(
f"{where}: front matter needs `order:`, a whole number giving its "
f"place in the {TABS[level]} tab, smallest first (found {order!r}), "
"or `nav: false` to keep it out of the navigation"
)
path = f"{WIKI_SRC_DIR}/{page.name}"
if page.name == "index.md":
tabs[level].append((order, path))
continue
title = meta.get("nav_title") or _h1(text)
if not title:
raise NavError(f"{where}: no `# ` heading and no `nav_title:` to name it")
tabs[level].append((order, {str(title): path}))
return tabs
def _nav_target(entry: object) -> str:
return entry if isinstance(entry, str) else str(next(iter(entry.values())))
def generate_mkdocs_nav(
coverages: dict,
manufacturers: dict,
profiles: dict,
registry: dict | None = None,
wiki_dir: Path | None = None,
) -> list:
"""The navigation, five tabs in reading order.
@@ -6427,51 +6484,42 @@ def generate_mkdocs_nav(
who knows their device, Explore for one system or emulator, Reference for
checking the work, Contribute for adding to it. Each tab opens on its
index page.
The generated pages hold the places below. A wiki page takes its own
place from its front matter (wiki_nav), so a new page needs no edit here.
"""
return [
{"Start here": [
"index.md",
{"What BIOS files are": "wiki/what-are-bios-files.md"},
{"Get your files": "which-pack.md"},
{"First install, step by step": "wiki/first-install.md"},
{"When a game does not start": "wiki/troubleshooting.md"},
{"Common questions": "wiki/faq.md"},
{"Glossary": "wiki/glossary.md"},
]},
{"Your setup": _platform_nav(coverages, registry or {})},
{"Explore": [
"explore.md",
{"Systems": _system_nav(manufacturers)},
{"Emulators": _emulator_nav(profiles)},
{"All installer options": "wiki/installer.md"},
{"Is it legal?": "wiki/legal.md"},
]},
{"Reference": [
"wiki/index.md",
{"How each platform checks files": "wiki/verification-modes.md"},
{"What the packs do not hold": "gaps.md"},
{"Cores by platform": "cross-reference.md"},
{"Dump provenance": "provenance.md"},
{"Updated platform files": "exports.md"},
{"Data and API": "data.md"},
{"Find a file": "files/index.md"},
{"Data model": "wiki/data-model.md"},
{"Architecture": "wiki/architecture.md"},
{"Build your own packs": "wiki/advanced-usage.md"},
{"Command-line tools": "wiki/tools.md"},
{"Technical questions": "wiki/technical-faq.md"},
{"Project terms": "wiki/reference-terms.md"},
{"Projects using this data": "wiki/community-tools.md"},
]},
{"Contribute": [
"contributing.md",
{"Profiling an emulator": "wiki/profiling.md"},
{"Adding a platform": "wiki/adding-a-platform.md"},
{"Adding a scraper": "wiki/adding-a-scraper.md"},
{"Testing": "wiki/testing-guide.md"},
{"Release process": "wiki/release-process.md"},
]},
]
generated: dict[str, list[tuple[int, object]]] = {
"start": [(0, "index.md"), (20, {"Get your files": "which-pack.md"})],
"setup": [(10 * i, item)
for i, item in enumerate(_platform_nav(coverages, registry or {}))],
"explore": [
(0, "explore.md"),
(10, {"Systems": _system_nav(manufacturers)}),
(20, {"Emulators": _emulator_nav(profiles)}),
],
"reference": [
(20, {"What the packs do not hold": "gaps.md"}),
(30, {"Cores by platform": "cross-reference.md"}),
(40, {"Dump provenance": "provenance.md"}),
(50, {"Updated platform files": "exports.md"}),
(60, {"Data and API": "data.md"}),
(70, {"Find a file": "files/index.md"}),
(80, {"Data model": "wiki/data-model.md"}),
],
"contribute": [(0, "contributing.md")],
}
wiki = wiki_nav(wiki_dir)
nav = []
for level, label in TABS.items():
entries = sorted(generated[level] + wiki[level], key=lambda item: item[0])
for (first, a), (second, b) in zip(entries, entries[1:]):
if first == second:
raise NavError(
f"{label} tab: {_nav_target(a)} and {_nav_target(b)} both take "
f"order {first}; give one of them a free number"
)
nav.append({label: [entry for _order, entry in entries]})
return nav
# Main
@@ -6785,7 +6833,8 @@ def _generate(args: argparse.Namespace) -> None:
# 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, registry)
nav = generate_mkdocs_nav(coverages, manufacturers, profiles, registry,
Path(WIKI_SRC_DIR))
nav_yaml = yaml.dump(
{"nav": nav}, default_flow_style=False, sort_keys=False, allow_unicode=True
)
+5 -144
View File
@@ -5,8 +5,10 @@ goes, and which ones the user has to rename."""
from __future__ import annotations
INSTALL_SH = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh"
INSTALL_PS1 = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1"
from siteguides import INSTALL_BASE, pack_manual
INSTALL_SH = f"{INSTALL_BASE}/install.sh"
INSTALL_PS1 = f"{INSTALL_BASE}/install.ps1"
def _detect_systems(detect: list[dict]) -> list[str]:
"""The operating systems the registry's detection rules cover, in order."""
@@ -114,147 +116,6 @@ def _generic_manual(bios_paths: list[str]) -> str:
)
# What a manual install needs on each platform: its shares, cards and folders.
MANUAL_GUIDES: dict[str, str] = {
"retroarch": (
" Option B: Manual (PC)\n"
" ---------------------\n"
" 1. Find your RetroArch system directory:\n"
" - RetroArch > Settings > Directory > System/BIOS\n"
" - Default: retroarch/system/\n"
" 2. Extract all files from this archive directly into your system directory\n"
" 3. Overwrite if asked\n\n"
" Option C: Manual (handheld / SD card)\n"
" -------------------------------------\n"
" Anbernic, Retroid, Miyoo, Trimui, etc.:\n"
" 1. Connect your SD card to your PC\n"
" 2. Find the BIOS folder (usually BIOS/ or system/)\n"
" 3. Extract all files from this archive directly into that folder\n"
" 4. Eject SD card and reboot your device\n\n"
" Common paths by device:\n"
" Anbernic (ArkOS/JELOS): BIOS/\n"
" Retroid (RetroArch): RetroArch/system/\n"
" Miyoo Mini (Onion OS): BIOS/\n"
" Steam Deck (RetroArch): ~/.config/retroarch/system/\n\n"
),
"batocera": (
" The terminal is F1 from the Batocera menu, or SSH.\n\n"
" Option B: Manual (network share)\n"
" --------------------------------\n"
" 1. On your PC, open the Batocera network share:\n"
" - Windows: \\\\BATOCERA\\share\\bios\\\n"
" - Mac/Linux: smb://batocera/share/bios/\n"
" 2. Extract all files from this archive directly into the share\n"
" 3. Overwrite if asked\n\n"
" Option C: Manual (SD card)\n"
" --------------------------\n"
" 1. Put the SD card in your PC\n"
" 2. Navigate to /userdata/bios/ on the SHARE partition\n"
" 3. Extract all files from this archive directly into that folder\n\n"
" NOTE: Dreamcast flash memory is named dc_nvmem.bin\n"
" (if your setup asks for dc_flash.bin, same file).\n\n"
),
"recalbox": (
" Option B: Manual (network share)\n"
" --------------------------------\n"
" 1. On your PC, open the Recalbox network share:\n"
" - Windows: \\\\RECALBOX\\share\\bios\\\n"
" - Mac/Linux: smb://recalbox/share/bios/\n"
" 2. Extract all files from this archive directly into the share\n\n"
" Option C: Manual (SD card)\n"
" --------------------------\n"
" 1. Put the SD card in your PC\n"
" 2. Navigate to /recalbox/share/bios/\n"
" 3. Extract all files from this archive directly into that folder\n\n"
),
"emudeck": (
" Option B: Manual\n"
" ----------------\n"
" Extract all files from this archive directly into EmuDeck's bios\n"
" folder, inside the Emulation folder chosen in EmuDeck:\n"
" Steam Deck, Linux and macOS: ~/Emulation/bios/ by default\n"
" Windows: C:\\Emulation\\bios\\ by default\n\n"
),
"retrodeck": (
" Option B: Manual\n"
" ----------------\n"
" 1. Open Dolphin file manager\n"
" 2. Show hidden files (Ctrl+H)\n"
" 3. Navigate to ~/retrodeck/\n"
" 4. Extract all files from this archive directly into ~/retrodeck/\n"
" (the archive already holds the bios/ and roms/ folders)\n\n"
" NOTE: RetroDECK uses its own BIOS checker. After\n"
" copying, open RetroDECK > Tools > BIOS Checker to\n"
" verify everything is detected.\n\n"
),
"retrobat": (
" Option B: Manual\n"
" ----------------\n"
" 1. Open your RetroBat installation folder\n"
" 2. Navigate to the bios\\ subfolder\n"
" (default: C:\\RetroBat\\bios\\)\n"
" 3. Extract all files from this archive directly into your bios\\ folder\n"
" 4. Overwrite if asked\n\n"
),
"bizhawk": (
" Option B: Manual\n"
" ----------------\n"
" 1. Open the folder you extracted BizHawk to, the one holding\n"
" EmuHawk.exe (Windows) or EmuHawkMono.sh (Linux)\n"
" 2. Navigate to its Firmware subfolder\n"
" 3. Extract all files from this archive directly into your Firmware folder\n"
" 4. In BizHawk: Config > Paths > Firmware should\n"
" point to this folder\n\n"
),
# RomM 5.4 places firmware by filesystem.structure.firmware in config.yml,
# bios/{platform} by default, and suggests {platform}/bios for a library
# laid out as {platform}/roms (rommapp/romm 68f71259,
# backend/config/config_manager.py:58,101-109,1105-1124).
"romm": (
" Option B: Manual\n"
" ----------------\n"
" The archive holds one folder per RomM platform (psx/, gba/, ...).\n"
" Where they go depends on filesystem.structure.firmware in RomM's\n"
" config.yml, read relative to the library folder:\n\n"
" - Not set, or \"bios/{platform}\" (the default): extract all files\n"
" from this archive directly into library/bios/\n"
" - \"{platform}/bios\" (the layout RomM suggests for a library kept as\n"
" {platform}/roms): each folder goes under its platform, psx/ into\n"
" library/psx/bios/, gba/ into library/gba/bios/, and so on.\n"
" The installer reads config.yml and does this itself. Run from\n"
" the host of a container, --romm-firmware \"{platform}/bios\" says it.\n\n"
" Then restart the RomM service to detect new files.\n\n"
),
"retropie": (
" Option B: Via network share\n"
" --------------------------\n"
" 1. On your PC, open: \\\\RETROPIE\\bios\\\n"
" 2. Extract all files from this archive directly into that folder\n\n"
" Option C: Via SSH\n"
" -----------------\n"
" 1. SSH into your Pi: ssh pi@retropie\n"
" 2. Copy files to ~/RetroPie/BIOS/\n\n"
" NOTE: the BIOS folder is on the card's Linux partition,\n"
" which Windows and macOS cannot open: copying through the\n"
" card works from a Linux computer only\n"
" (<rootfs>/home/pi/RetroPie/BIOS/).\n\n"
),
"lakka": (
" Lakka reads its BIOS files from /storage/system/.\n\n"
" Option B: Via network share\n"
" --------------------------\n"
" 1. On Lakka: Settings > Services > Samba Enable\n"
" 2. On your PC, open the System share: \\\\LAKKA\\System\\\n"
" (macOS and Linux: smb://lakka/System)\n"
" 3. Extract all files from this archive directly into that share\n\n"
" Option C: Via SSH\n"
" -----------------\n"
" 1. On Lakka: Settings > Services > SSH Enable\n"
" 2. Copy the files to /storage/system/ (scp -r * root@lakka:/storage/system/)\n\n"
),
}
def _build_readme(
platform_name: str,
platform_display: str,
@@ -303,7 +164,7 @@ def _build_readme(
guide = (
"INSTALLATION GUIDE\n\n"
+ _automatic_section(platform_display, detect, standalone_copies)
+ MANUAL_GUIDES.get(platform_name, _generic_manual(bios_paths))
+ (pack_manual(platform_name) or _generic_manual(bios_paths))
)
if regions:
+208 -363
View File
@@ -1,118 +1,28 @@
"""The guide part of the site: where each setup keeps its BIOS files.
A platform page opens on what someone holding the device needs: the folder,
the command, the pack, how to check. The registry says which group of
devices a platform belongs to (`device_group`), so the device picker and the
navigation list every registered platform. The prose below is per platform,
and a test holds that every registered platform has an entry.
Every path here is one the installer detects, the registry records or the
platform documents. Menu labels of third-party software are named only where
this project already relies on them.
the command, the pack, how to check. The words live in
``platforms/_guides.yml``, one guide per registered platform, beside the
device groups, the terminal tabs and the safety notes every guide shares.
This module loads that file, checks it against the registry and renders the
parts the pages share. The registry keeps the folder the pack unzips into
(``extract_to``) and the group a platform belongs to (``device_group``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
import textwrap
from dataclasses import dataclass, fields
from pathlib import Path
INSTALL_BASE = "https://raw.githubusercontent.com/Abdess/retrobios/main"
from safeparse import require_yaml
# Order of the groups on the picker, in the navigation and on the home page.
DEVICE_GROUPS: dict[str, tuple[str, str]] = {
"computer": (
"Computers and Steam Deck",
"An emulator app on a computer or a Steam Deck.",
),
"gaming-os": (
"Game systems for PCs and small boards",
"A system that starts straight into a game menu, on a PC or a Raspberry Pi.",
),
"handheld": (
"Handhelds and TV boxes",
"The system your handheld or TV box starts.",
),
"fpga": (
"MiSTer FPGA",
"A board that recreates old consoles in hardware, not in software.",
),
"server": (
"Self-hosted game library",
"A RomM server. It plays games in your web browser.",
),
}
PLATFORMS_DIR = Path(__file__).resolve().parent.parent / "platforms"
GUIDES_FILE = PLATFORMS_DIR / "_guides.yml"
# Section anchors on the device picker. Two keep the anchors the page had
# before it was grouped by device, which links outside the site may use.
GROUP_ANCHORS = {
"computer": "computers-and-steam-deck",
"gaming-os": "game-systems",
"handheld": "handhelds",
"fpga": "fpga",
"server": "self-hosted",
}
# Setups that are not platforms of their own: they run one, and use its pack.
# (setup, what it is, platform it uses, runs on, BIOS folder)
ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = {
"computer": (
("LaunchBox", "A game library and launcher. It starts RetroArch or "
"standalone emulators.", "retroarch", "Windows",
"The folder of the RetroArch it starts"),
),
"handheld": (
("Android (Retroid Pocket, AYN Odin and others)", "RetroArch for Android.",
"retroarch", "Android", "`RetroArch/system/` on the internal storage"),
),
}
# Ways to run the installer. Each tab names how to open a terminal there,
# the shell the command is written for, and the options it needs.
TERMINALS: dict[str, tuple[str, str, str]] = {
"windows": (
"Windows",
"Open the Start menu. Type **PowerShell** and open it. Paste the line. "
"Press Enter.",
"powershell",
),
"macos": (
"macOS",
"Open **Terminal**, in Applications, then Utilities. Paste the line. "
"Press Return.",
"sh",
),
"linux": (
"Linux",
"Open a terminal. Paste the line. Press Enter.",
"sh",
),
"steamdeck": (
"Steam Deck",
"Switch to Desktop Mode. Open **Konsole**. Paste with Ctrl+Shift+V. "
"Press Enter.",
"sh",
),
"android": (
"Android (Termux)",
"Install **Termux**, a terminal app. Run `pkg install python` and "
"`termux-setup-storage` once. Then paste the line. Press Enter.",
"sh",
),
"device": (
"On the device",
"Open a terminal on the device. Or connect from a computer with SSH, a "
"terminal over the network. Paste the line. Press Enter.",
"sh",
),
"server": (
"On the server",
"Open a terminal on the machine that holds your RomM library. Put the "
"path of its `bios` folder after `--dest`.",
"sh",
),
}
# Windows users of a card frontend run the PowerShell form with a drive letter.
CARD_TERMINALS = ("macos", "linux", "windows")
class GuideError(ValueError):
"""The guides file does not say what a page needs, or contradicts the registry."""
@dataclass(frozen=True)
@@ -120,10 +30,14 @@ class Guide:
"""What a platform page says before its file reference.
`folders` are the common cases, `more_folders` the rarer builds shown
folded under them. `notes` stay in the guide, one plain fact each.
`reference_notes` carry the source citations and the details a reader
checking the work wants, and open the Reference part of the page.
`download_hint` is one more bullet under the pack download.
folded under them, under `more_folders_title`. `more_folders_detected`
adds a row for each named system the registry detects the platform on.
`notes` stay in the guide, one plain fact each. `reference_notes` carry
the source citations and the details a reader checking the work wants,
and open the Reference part of the page. `download_hint` is one more
bullet under the pack download. `pack_manual` is the manual part of the
README inside the pack. `logo_owner` and `logo_licence` credit the
logo the registry links.
"""
summary: str
@@ -132,257 +46,198 @@ class Guide:
notes: tuple[str, ...] = ()
dest: str = ""
on_card: bool = False
extra_args: dict[str, str] = field(default_factory=dict)
more_folders: tuple[tuple[str, str], ...] = ()
more_folders_title: str = "Other layouts"
more_folders_detected: bool = False
more_folders_note: str = ""
reference_notes: tuple[str, ...] = ()
download_hint: str = ""
pack_manual: str = ""
logo_owner: str = ""
logo_licence: str = ""
NETWORK_HINT = "From a computer, use the network folder above."
_GUIDE_KEYS = {f.name for f in fields(Guide)}
_REQUIRED = ("summary", "folders", "run")
_TOP_KEYS = {"install_base", "trust_items", "device_groups", "terminals", "guides"}
_GROUP_KEYS = {"label", "description", "anchor", "also_runs"}
_ALSO_KEYS = ("setup", "summary", "uses", "runs_on", "folder")
_TERMINAL_KEYS = ("label", "how", "shell")
GUIDES: dict[str, Guide] = {
"retroarch": Guide(
summary="An app that runs many consoles. It uses a different **core**, "
"a small emulator, for each one.",
folders=(
("Windows", "`system\\` next to `retroarch.exe`"),
("macOS", "`~/Documents/RetroArch/system/`"),
("Linux", "`~/.config/retroarch/system/`"),
("Steam Deck and Linux Flatpak", "`~/.var/app/org.libretro.RetroArch/config/retroarch/system/`"),
("Android", "`RetroArch/system/` on the internal storage"),
("Nintendo Switch", "`retroarch/cores/system/` on the SD card"),
),
more_folders=(
("Windows, Microsoft Store build",
"`%LOCALAPPDATA%\\Packages\\1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_*\\LocalState\\system\\`"),
("macOS, Steam or portable build", "`~/Library/Application Support/RetroArch/system/`"),
),
run=("windows", "macos", "linux", "steamdeck", "android"),
notes=(
"**Not sure?** In RetroArch, open **Settings > Directory > "
"System/BIOS**. It shows the folder.",
),
reference_notes=(
"RetroArch reads its BIOS folder from the `system_directory` line of "
"`retroarch.cfg`, the value **Settings > Directory > System/BIOS** "
"shows.",
"On Windows, `retroarch.cfg` is read next to `retroarch.exe` first, "
"then from `%APPDATA%\\RetroArch\\`. A `system_directory` left at its "
"default is the `system` folder next to `retroarch.exe` (`:\\system` "
"in RetroArch's `frontend/drivers/platform_win32.c`, where `:` stands "
"for the folder of the program).",
),
),
"batocera": Guide(
summary="A system that starts straight into a game menu.",
folders=(
("On Batocera", "`/userdata/bios/`"),
("From Windows, over your home network", "`\\\\BATOCERA\\share\\bios\\`"),
("From a Mac or Linux, over your home network", "`smb://batocera/share/bios/`"),
),
run=("device",),
download_hint=NETWORK_HINT,
),
"recalbox": Guide(
summary="A system that starts straight into a game menu and sets up its "
"emulators.",
folders=(
("On Recalbox", "`/recalbox/share/bios/`"),
("From Windows, over your home network", "`\\\\RECALBOX\\share\\bios\\`"),
),
run=("device",),
download_hint=NETWORK_HINT,
),
"retrobat": Guide(
summary="A game menu for Windows. It comes with RetroArch and other "
"emulators.",
folders=(
("Inside the RetroBat folder", "`bios\\`"),
("Default install", "`C:\\RetroBat\\bios\\`"),
),
run=("windows",),
notes=("The command finds RetroBat wherever you installed it.",),
reference_notes=(
"RetroBat records its own folder each time it starts, and the "
"installer reads it there.",
),
),
"emudeck": Guide(
summary="Installs your emulators and adds your games to Steam.",
folders=(
("Steam Deck and Linux", "`~/Emulation/bios/`"),
("Windows, by default", "`C:\\Emulation\\bios\\`"),
("macOS", "`~/Emulation/bios/`"),
),
run=("steamdeck", "linux", "windows", "macos"),
notes=(
"The `bios` folder is inside the Emulation folder you chose in "
"EmuDeck. If you chose the SD card, it is on the card.",
),
reference_notes=(
"The installer reads the Emulation folder from EmuDeck's settings "
"(`emulationPath` in `~/.config/EmuDeck/settings.sh`).",
),
),
"lakka": Guide(
summary="A system that starts straight into RetroArch.",
folders=(("On Lakka", "`/storage/system/`"),),
run=("device",),
notes=("You can also reach the folder from a computer, over your home network.",),
reference_notes=("The folder is reachable over SSH and over the network share.",),
),
"retrodeck": Guide(
summary="One app with its emulators inside. Install it from Flathub or "
"Discover.",
folders=(
("Steam Deck and Linux", "`~/retrodeck/bios/`"),
("Installed on a microSD card", "for example `/run/media/mmcblk0p1/retrodeck/bios/`"),
),
run=("steamdeck", "linux"),
),
"romm": Guide(
summary="A game library on your own server. It plays games in the web "
"browser.",
folders=(("In the RomM library", "`bios/`, one subfolder per platform"),),
more_folders=(
("With `firmware: \"{platform}/bios\"` in `config.yml`",
"`<platform>/bios/` for each platform"),
),
run=("server",),
dest="/path/to/library/bios",
notes=("You can also add BIOS files in RomM's web page.",),
),
"rocknix": Guide(
summary="A system for handhelds.",
folders=(("On ROCKNIX", "`/storage/roms/bios/`"),),
run=("device",),
notes=("You can also reach the folder from a computer, over your home network.",),
reference_notes=(
"ROCKNIX runs on ARM and x86 handhelds, among them the RG35XX (H700) "
"and the RG552 (RK3399). The folder is reachable over SSH and over "
"the network share.",
),
),
"amberelec": Guide(
summary="A system for Anbernic handhelds.",
folders=(("On AmberELEC", "`/storage/roms/`"),),
run=("device",),
reference_notes=(
"AmberELEC runs on the RG351P, RG351V, RG351MP, RG353, RG503 and "
"RG552. Its list names files under `/storage/roms`: `bios/` for most, "
"`neogeo/` for the rest. The pack holds those folders.",
),
),
"emuelec": Guide(
summary="A system for TV boxes and some handhelds.",
folders=(("On EmuELEC", "`/storage/roms/`"),),
run=("device",),
reference_notes=(
"EmuELEC runs on Amlogic TV boxes, the Odroid Go Advance, GameForce "
"and RK356x devices. Its list names files under `/storage/roms`: "
"`bios/` for most, `neogeo/` and `ports/` for the rest. The pack "
"holds those folders.",
),
),
"knulli": Guide(
summary="A system for handhelds, based on Batocera.",
folders=(("On KNULLI", "`/userdata/bios/`"),),
run=("device",),
reference_notes=(
"KNULLI runs on handhelds among them the RG35XX family (H700) and the "
"TrimUI Smart Pro.",
),
),
"retropie": Guide(
summary="Installs RetroArch and other emulators on a Raspberry Pi.",
folders=(("On the Raspberry Pi", "`~/RetroPie/BIOS/`"),),
run=("device",),
notes=(
"Use the RetroPie pack, not the RetroArch one. On RetroPie 4.8, "
"Python is too old for the command: use the pack there.",
),
reference_notes=(
"RetroPie also installs standalone emulators (openMSX, PCSX2, XRoar, "
"Amiberry and others) whose files only the RetroPie pack carries.",
"RetroPie is archived in this project: its configuration is kept and "
"its pack is still built, but its upstream list is no longer read "
"again on a schedule.",
),
),
"bizhawk": Guide(
summary="An emulator app for many consoles, from TASVideos.",
folders=(
("Inside the BizHawk folder", "`Firmware\\`"),
("Default place the installer looks", "`%USERPROFILE%\\BizHawk\\Firmware\\`"),
),
run=("windows", "linux"),
reference_notes=("BizHawk carries rerecording and debugging tools.",),
),
"misterfpga": Guide(
summary="A board that recreates old consoles in hardware, not in software.",
folders=(("On the MiSTer SD card", "`/media/fat/games/`, one subfolder per core"),),
run=("device",),
notes=(
"Each file goes in its console's folder, like "
"`/media/fat/games/3DO/boot.rom`.",
),
reference_notes=(
"MiSTer runs FPGA cores on a DE10-Nano board. Only the files the "
"MiSTer BIOS database lists with a download address are in the pack. "
"The others come with the MiSTer distribution and install themselves.",
),
),
"minui": Guide(
summary="A simple game menu for handhelds. It runs from the SD card.",
folders=(("On the SD card", "`Bios/<TAG>/`, one folder per console"),),
run=CARD_TERMINALS,
on_card=True,
notes=(
"Each console has its own folder, named after the tag in its Roms "
"folder. `Roms/Sony PlayStation (PS)/` uses `Bios/PS/`.",
),
reference_notes=(
"MinUI gives each console its own folder, named after the tag in "
"parentheses of the Roms folder the game sits in. A game in "
"`Roms/Game Boy (GB)/` runs with `Bios/GB/` as its BIOS folder (MinUI "
"`workspace/all/minarch/minarch.c:2943`).",
"Without `--platform`, the installer reads the release name MinUI "
"writes in `.system/version.txt` on the card. A console started by a "
"standalone program instead of a libretro core (DraStic for NDS, "
"native PICO-8) is not covered.",
),
),
"nextui": Guide(
summary="A game menu for handhelds, based on MinUI. It runs from the SD card.",
folders=(("On the SD card", "`Bios/<TAG>/`, one folder per console"),),
run=CARD_TERMINALS,
on_card=True,
notes=(
"Each console has its own folder, named after the tag in its Roms "
"folder. `Roms/Sony PlayStation (PS)/` uses `Bios/PS/`.",
),
reference_notes=(
"NextUI gives each console its own folder, named after the tag in "
"parentheses of the Roms folder the game sits in (NextUI "
"`workspace/all/minarch/ma_core.c:76`).",
),
),
"myminui": Guide(
summary="A game menu for handhelds, based on MinUI. It runs from the SD card.",
folders=(("On the SD card", "`Bios/<TAG>/`, one folder per console"),),
run=CARD_TERMINALS,
on_card=True,
notes=(
"Each console has its own folder, named after the tag in its Roms "
"folder. `Roms/Sony PlayStation (PS)/` uses `Bios/PS/`.",
),
reference_notes=(
"MyMinUI gives each console its own folder, named after the tag in "
"parentheses of the Roms folder the game sits in (MyMinUI "
"`workspace/all/minarch/minarch.c:3532`).",
),
),
}
def _text(value: object, where: str) -> str:
if not isinstance(value, str) or not value.strip():
raise GuideError(f"{where}: expected text, found {value!r}")
return value
def _texts(value: object, where: str) -> tuple[str, ...]:
if not isinstance(value, list):
raise GuideError(f"{where}: expected a list, found {value!r}")
return tuple(_text(item, f"{where}[{i}]") for i, item in enumerate(value))
def _unknown(data: dict, allowed: set[str] | tuple[str, ...], where: str) -> None:
extra = sorted(set(data) - set(allowed))
if extra:
raise GuideError(f"{where}: unknown key {', '.join(extra)} "
f"(known: {', '.join(sorted(allowed))})")
def _folder_rows(value: object, extract_to: str, where: str) -> tuple[tuple[str, str], ...]:
"""Folder rows as (where, path). A row with no path is the registry's
extract_to, so that folder is written once."""
if not isinstance(value, list) or not value:
raise GuideError(f"{where}: expected a list of folder rows")
rows = []
for i, row in enumerate(value):
at = f"{where}[{i}]"
if not isinstance(row, dict):
raise GuideError(f"{at}: expected a mapping with `where` and `path`")
_unknown(row, ("where", "path"), at)
label = _text(row.get("where"), f"{at}.where")
if "path" in row:
rows.append((label, _text(row["path"], f"{at}.path")))
elif extract_to:
rows.append((label, f"`{extract_to}`"))
else:
raise GuideError(f"{at}: no `path`, and the registry has no extract_to for it")
return tuple(rows)
def _guide(name: str, data: object, extract_to: str, terminals: dict) -> Guide:
where = f"{GUIDES_FILE.name}: guides.{name}"
if not isinstance(data, dict):
raise GuideError(f"{where}: expected a mapping")
_unknown(data, _GUIDE_KEYS, where)
missing = [key for key in _REQUIRED if key not in data]
if missing:
raise GuideError(f"{where}: missing {', '.join(missing)}")
run = _texts(data["run"], f"{where}.run")
unknown_tabs = [key for key in run if key not in terminals]
if unknown_tabs:
raise GuideError(f"{where}.run: no terminal named {', '.join(unknown_tabs)}")
values: dict = {
"summary": _text(data["summary"], f"{where}.summary"),
"folders": _folder_rows(data["folders"], extract_to, f"{where}.folders"),
"run": run,
}
if "more_folders" in data:
values["more_folders"] = _folder_rows(
data["more_folders"], extract_to, f"{where}.more_folders")
for key in ("notes", "reference_notes"):
if key in data:
values[key] = _texts(data[key], f"{where}.{key}")
for key in ("dest", "download_hint", "more_folders_title", "more_folders_note",
"logo_owner", "logo_licence"):
if key in data:
values[key] = _text(data[key], f"{where}.{key}")
for key in ("on_card", "more_folders_detected"):
if key in data:
if not isinstance(data[key], bool):
raise GuideError(f"{where}.{key}: expected true or false")
values[key] = data[key]
if "pack_manual" in data:
values["pack_manual"] = _text(data["pack_manual"], f"{where}.pack_manual")
return Guide(**values)
def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict:
"""The guides file, checked against the registry.
Every registered platform, archived ones included, has a guide and a
device group the file describes. A mistake raises GuideError naming the
key, so the site is never built from half a guide.
"""
yaml = require_yaml()
if registry is None:
with open(PLATFORMS_DIR / "_registry.yml", encoding="utf-8") as handle:
registry = (yaml.safe_load(handle) or {}).get("platforms", {})
with open(path, encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
if not isinstance(data, dict):
raise GuideError(f"{path.name}: expected a mapping")
_unknown(data, _TOP_KEYS, path.name)
terminals: dict[str, tuple[str, str, str]] = {}
for key, entry in (data.get("terminals") or {}).items():
at = f"{path.name}: terminals.{key}"
if not isinstance(entry, dict):
raise GuideError(f"{at}: expected a mapping")
_unknown(entry, _TERMINAL_KEYS, at)
terminals[key] = tuple(_text(entry.get(k), f"{at}.{k}") for k in _TERMINAL_KEYS)
if entry["shell"] not in ("sh", "powershell"):
raise GuideError(f"{at}.shell: sh or powershell, found {entry['shell']!r}")
raw_guides = data.get("guides") or {}
groups: dict[str, tuple[str, str]] = {}
anchors: dict[str, str] = {}
also_runs: dict[str, tuple[tuple[str, str, str, str, str], ...]] = {}
for key, entry in (data.get("device_groups") or {}).items():
at = f"{path.name}: device_groups.{key}"
if not isinstance(entry, dict):
raise GuideError(f"{at}: expected a mapping")
_unknown(entry, _GROUP_KEYS, at)
groups[key] = (_text(entry.get("label"), f"{at}.label"),
_text(entry.get("description"), f"{at}.description"))
anchors[key] = _text(entry.get("anchor"), f"{at}.anchor")
rows = []
for i, row in enumerate(entry.get("also_runs") or []):
row_at = f"{at}.also_runs[{i}]"
if not isinstance(row, dict):
raise GuideError(f"{row_at}: expected a mapping")
_unknown(row, _ALSO_KEYS, row_at)
values = tuple(_text(row.get(k), f"{row_at}.{k}") for k in _ALSO_KEYS)
if values[2] not in raw_guides:
raise GuideError(f"{row_at}.uses: {values[2]!r} is not a platform with a guide")
rows.append(values)
if rows:
also_runs[key] = tuple(rows)
unregistered = sorted(set(raw_guides) - set(registry))
if unregistered:
raise GuideError(f"{path.name}: guides for unregistered platforms: "
f"{', '.join(unregistered)}")
guides = {}
for name, entry in registry.items():
if name not in raw_guides:
raise GuideError(f"{path.name}: {name} is registered and has no guide "
f"(add guides.{name})")
group = entry.get("device_group")
if group not in groups:
raise GuideError(f"_registry.yml: {name}.device_group {group!r} is not "
f"one of {', '.join(groups)} in {path.name}")
guides[name] = _guide(name, raw_guides[name], str(entry.get("extract_to") or ""),
terminals)
# The order the file is written in, which the pages do not depend on.
guides = {name: guides[name] for name in raw_guides}
return {
"install_base": _text(data.get("install_base"), f"{path.name}: install_base"),
"trust_items": _texts(data.get("trust_items"), f"{path.name}: trust_items"),
"device_groups": groups,
"group_anchors": anchors,
"also_runs": also_runs,
"terminals": terminals,
"guides": guides,
}
_LOADED = load_guides()
INSTALL_BASE: str = _LOADED["install_base"]
DEVICE_GROUPS: dict[str, tuple[str, str]] = _LOADED["device_groups"]
GROUP_ANCHORS: dict[str, str] = _LOADED["group_anchors"]
ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also_runs"]
TERMINALS: dict[str, tuple[str, str, str]] = _LOADED["terminals"]
TRUST_ITEMS: tuple[str, ...] = _LOADED["trust_items"]
GUIDES: dict[str, Guide] = _LOADED["guides"]
def pack_manual(name: str) -> str:
"""The manual part of a pack README, indented as the README is, or empty."""
guide = GUIDES.get(name)
if not guide or not guide.pack_manual:
return ""
return textwrap.indent(guide.pack_manual.rstrip("\n"), " ") + "\n\n"
def install_command(shell: str, args: str = "") -> str:
@@ -433,16 +288,6 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]:
return args
TRUST_ITEMS = (
"It checks each file before saving it.",
"It writes only in your BIOS folder.",
"It never touches games or saves.",
"It never moves or deletes files it does not know.",
"It keeps any file it replaces in a `retrobios-backup` folder, beside your "
"BIOS folder. The same command with `--restore` puts it back.",
)
def trust_box(prefix: str, indent: str = "") -> list[str]:
"""Is it safe, under every command on a guide page: five plain facts."""
return [