feat: rework entry pages and setup guides

# Conflicts:
#	wiki/testing-guide.md
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 02:34:44 +02:00
commit 38983a2a4a
13 files changed
+914 -268

No files matched your search

+16 -12
View File
@@ -2,15 +2,23 @@
## Add a BIOS file
One file needs no clone and no local setup:
Have a file the collection lacks? Add it from the browser. It is checked on arrival.
1. [Upload it into `bios/`](https://github.com/Abdess/retrobios/upload/main/bios)
from the browser, 25 MB at most per file. GitHub writes it to a fork and
opens the pull request.
2. CI hashes it and looks it up in every platform list and emulator profile.
For a file it recognises, it names the folder holding the other files of its
system. The report is in the summary of the pull request's Validation run.
3. The file can be moved to that folder in the same pull request. A file whose
[Upload it into `bios/`](https://github.com/Abdess/retrobios/upload/main/bios), 25 MB at most per file. GitHub
writes it to a fork and opens the pull request.
The files most wanted are on [what the packs do not hold](https://abdess.github.io/retrobios/gaps/). The
[dump provenance](https://abdess.github.io/retrobios/provenance/) page lists catalogued dumps still
missing from the collection, with their hashes. A file matching one of those is
the most useful contribution.
## What happens next
1. CI hashes the file and looks it up in every platform list and emulator
profile. For a file it recognises, it names the folder holding the other
files of its system. The report is in the summary of the pull request's
Validation run.
2. The file can be moved to that folder in the same pull request. A file whose
hash differs from the one already held under its name goes to that folder's
`.variants/`.
@@ -18,10 +26,6 @@ From a clone, the file goes to `bios/Manufacturer/Console/filename`, and
`python scripts/validate_pr.py path/to/file` names that folder for a file still
outside `bios/`. A file over the browser's 25 MB limit goes in that way.
The [dump provenance](https://abdess.github.io/retrobios/provenance/) page lists catalogued dumps still
missing from the collection, with their hashes. A file matching one of those is
the most useful contribution.
## Add a platform
1. Write a scraper in `scripts/scraper/` (inherit `BaseScraper`)
+141 -39
View File
@@ -7,6 +7,10 @@
# `extract_to` for that platform, the one place that folder is written.
# `run` names the terminals below, in the order the page shows them.
# `pack_manual` is the manual part of the README inside the pack, plain text.
# `{folders}` in it is the folder list of the page.
# `runs_on` names the hardware in the project's own words, cited beside it.
# `terminal` is how this system opens a terminal, from its own documentation.
# `lead` comes before the folder table, `finish` closes the guide.
# `logo_owner` and `logo_licence` credit the logo the registry links: it is
# shown only to name the platform, with the licence of the repository the
# file was taken from when that repository states one.
@@ -16,15 +20,17 @@
install_base: https://raw.githubusercontent.com/Abdess/retrobios/main
# Is it safe: shown under every command on a guide page.
# Is it safe: shown on the home page and under every command on a guide page.
# Where it writes follows install.py: the BIOS folder, the frontend's save
# folder for the entries a manifest files under `root: save`
# (place_save_entries), and retrobios-backup beside the folder written to.
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 writes in your BIOS folder. For some emulators it also adds files to your save folder.
- It never changes or deletes a game or a save.
- >-
It keeps any file it replaces in a `retrobios-backup` folder, beside your BIOS folder.
The same command with `--restore` puts it back.
It keeps any file it replaces in a `retrobios-backup` folder, next to your BIOS folder.
`--restore` puts it back.
# Order of the groups on the picker, in the navigation and on the home page.
# `anchor` is the section id on the device picker: two keep the ids the page
@@ -33,7 +39,7 @@ trust_items:
# and use its pack.
device_groups:
computer:
label: Computers and Steam Deck
label: Computers (Windows, Mac, Linux) and Steam Deck
description: An emulator app on a computer or a Steam Deck.
anchor: computers-and-steam-deck
also_runs:
@@ -44,12 +50,11 @@ device_groups:
runs_on: Windows
folder: The folder of the RetroArch it starts
gaming-os:
label: Game systems for PCs and small boards
description: >-
A system that starts straight into a game menu, on a PC or a Raspberry Pi.
label: Raspberry Pi and mini PCs
description: A system that starts straight into a game menu.
anchor: game-systems
handheld:
label: Handhelds and TV boxes
label: Handhelds and TV boxes (Anbernic, Retroid, Miyoo)
description: The system your handheld or TV box starts.
anchor: handhelds
also_runs:
@@ -137,8 +142,7 @@ guides:
more_folders_detected: true
more_folders_note: LaunchBox and other launchers use the folder RetroArch reads.
run: [windows, macos, linux, steamdeck, android]
notes:
- >-
lead: >-
**Not sure?** In RetroArch, open **Settings > Directory > System/BIOS**. It shows
the folder.
reference_notes:
@@ -161,21 +165,27 @@ guides:
Option C: Manual (handheld / SD card)
-------------------------------------
Anbernic, Retroid, Miyoo, Trimui, etc.:
1. Connect your SD card to your PC
2. Find the BIOS folder (usually BIOS/ or system/)
2. Find the BIOS folder in the list below
3. Extract all files from this archive directly into that folder
4. Eject SD card and reboot your device
Common paths by device:
Anbernic (ArkOS/JELOS): BIOS/
Retroid (RetroArch): RetroArch/system/
Miyoo Mini (Onion OS): BIOS/
Steam Deck (RetroArch): ~/.config/retroarch/system/
Folders by device:
{folders}
logo_owner: libretro
logo_licence: repository licence GPL-3.0
batocera:
summary: A system that starts straight into a game menu.
summary: A system that starts straight into a game menu, on a PC, a Raspberry Pi or a handheld.
# batocera.org/download: x86-64 desktop PC and laptop, Raspberry, Steam Deck OLED,
# Anbernic and AYN handhelds.
runs_on: PCs, Raspberry Pi, Steam Deck, handhelds
# wiki.batocera.org/access_the_batocera_via_ssh: root@batocera.local, password
# linux, SSH on by default. wiki.batocera.org (file manager): xterm in the
# Applications menu of the F1 file manager, Xorg builds (x86_64) only.
terminal: >-
On a PC, press **F1** on the system list, then open **xterm** in the Applications
menu. From a computer, open a terminal over the network with `ssh
root@batocera.local`, password `linux`. Paste the line. Press Enter.
folders:
- where: On Batocera
- where: From Windows, over your home network
@@ -185,7 +195,8 @@ guides:
run: [device]
download_hint: From a computer, use the network folder above.
pack_manual: |
The terminal is F1 from the Batocera menu, or SSH.
The terminal is xterm, in the Applications menu of the F1 file
manager (PC only), or SSH: ssh root@batocera.local, password linux.
Option B: Manual (network share)
--------------------------------
@@ -206,8 +217,15 @@ guides:
logo_owner: Batocera
logo_licence: repository licence MIT
recalbox:
summary: >-
A system that starts straight into a game menu and sets up its emulators.
summary: A system that starts straight into a game menu, on a board, a PC or a handheld.
# recalbox.com: "TV, PC or handheld?", 13 supported boards and consoles.
runs_on: Boards, PCs, handhelds
# wiki.recalbox.com/en/tutorials/system/access/root-access-terminal-cli: Alt+F2 with
# a keyboard. SSH user root, password recalboxroot or the one set when flashing 10.0+.
terminal: >-
With a keyboard, press **Alt+F2**. From a computer, open a terminal over the network
with `ssh root@recalbox`, password `recalboxroot` or the one you set. Paste the line.
Press Enter.
folders:
- where: On Recalbox
- where: From Windows, over your home network
@@ -219,7 +237,6 @@ guides:
--------------------------------
1. On your PC, open the Recalbox network share:
- Windows: \\RECALBOX\share\bios\
- Mac/Linux: smb://recalbox/share/bios/
2. Extract all files from this archive directly into the share
Option C: Manual (SD card)
@@ -258,14 +275,23 @@ guides:
summary: Installs your emulators and adds your games to Steam.
folders:
- where: Steam Deck and Linux
# EmuDeck functions/helperFunctions.sh:89-99 (getSDPath, the card's mount point)
# and functions/jsonToBashVars.sh:128-132 (biosPath "$globPath/Emulation/bios"),
# dragoonDorise/EmuDeck dddb3022.
- where: Steam Deck, installed on the SD card
path: '`Emulation/bios/` on the card, under `/run/media/`'
- where: Windows, by default
path: '`C:\Emulation\bios\`'
- where: macOS
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.
The `bios` folder is inside the Emulation folder you chose in EmuDeck.
# emudeck-electron src/renderer/translations/en.json:416-418 (b06bfcf5): the
# "Bios files checker".
finish: >-
EmuDeck's **Bios files checker** shows the files too. Return to Gaming Mode and start
a game.
reference_notes:
- >-
The installer reads the Emulation folder from EmuDeck's settings (`emulationPath`
@@ -280,13 +306,24 @@ guides:
logo_owner: EmuDeck
logo_licence: repository licence GPL-3.0
lakka:
summary: A system that starts straight into RetroArch.
summary: A system that starts straight into RetroArch, on a Raspberry Pi or a small PC.
# lakka.tv: "transforms a small computer", "a Raspberry Pi".
runs_on: Raspberry Pi, small computers
folders:
- where: On Lakka
# lakka.tv/doc/Accessing-Lakka-filesystem: Samba in Settings > Services, share System.
- where: From Windows, over your home network
path: '`\\LAKKA\System\`'
run: [device]
notes:
- >-
You can also reach the folder from a computer, over your home network.
For the network folder, turn on **Samba** in Lakka's **Settings > Services**.
# lakka.tv/doc/Accessing-Lakka-command-line-interface: SSH in Settings > Services,
# user root, password root.
terminal: >-
Turn on **SSH**, a terminal over the network, in Lakka's **Settings > Services**. From
a computer, connect with `ssh root@lakka`, password `root`. Paste the line. Press
Enter.
reference_notes:
- The folder is reachable over SSH and over the network share.
pack_manual: |
@@ -296,7 +333,6 @@ guides:
--------------------------
1. On Lakka: Settings > Services > Samba Enable
2. On your PC, open the System share: \\LAKKA\System\
(macOS and Linux: smb://lakka/System)
3. Extract all files from this archive directly into that share
Option C: Via SSH
@@ -365,12 +401,20 @@ guides:
logo_licence: GitHub organisation avatar of rommapp
rocknix:
summary: A system for handhelds.
# rocknix.org: "an immutable Linux distribution for handheld gaming devices".
runs_on: Handhelds
folders:
- where: On ROCKNIX
run: [device]
notes:
- >-
You can also reach the folder from a computer, over your home network.
# rocknix.org/faqs and rocknix.org/play/add-games: SSH off by default, turned on in
# Network Settings, user root, the password shown in System Settings > Security.
terminal: >-
Turn on **SSH**, a terminal over the network, in **Network Settings**. From a
computer, connect as `root`, with the password shown in **System Settings >
Security**. Paste the line. Press Enter.
reference_notes:
- >-
ROCKNIX runs on ARM and x86 handhelds, among them the RG35XX (H700) and the RG552
@@ -379,9 +423,15 @@ guides:
logo_licence: GitHub organisation avatar of ROCKNIX
amberelec:
summary: A system for Anbernic handhelds.
# amberelec.org: "optimized for the RG351P/M/V/MP and RG552".
runs_on: Anbernic RG351 and RG552
folders:
- where: On AmberELEC
run: [device]
# amberelec.org/guides/getting-to-know-amberelec.html: host AmberELEC, root, amberelec.
terminal: >-
From a computer, open a terminal over the network with `ssh root@AmberELEC`, password
`amberelec`. Paste the line. Press Enter.
reference_notes:
- >-
AmberELEC runs on the RG351P, RG351V, RG351MP, RG353, RG503 and RG552. Its list
@@ -391,9 +441,20 @@ guides:
logo_licence: GitHub organisation avatar of AmberELEC
emuelec:
summary: A system for TV boxes and some handhelds.
# EmuELEC wiki Supported-Devices.md: Amlogic boxes, Odroid-GO Advance and Super,
# GameForce Chi.
runs_on: TV boxes, Odroid Go handhelds
folders:
- where: On EmuELEC
# EmuELEC wiki (35b92c78) Backup-and-Recovery-options.md:4 (\\EMUELEC, roms share)
# and Bios.md (/storage/roms/bios).
- where: From Windows, over your home network
path: '`\\EMUELEC\roms\`'
run: [device]
# EmuELEC wiki How-to-SSH-into-EmuELEC.md:38-44: root, password emuelec.
terminal: >-
From a computer, open a terminal over the network with `ssh root@emuelec`, password
`emuelec`. Paste the line. Press Enter.
reference_notes:
- >-
EmuELEC runs on Amlogic TV boxes, the Odroid Go Advance, GameForce and RK356x devices.
@@ -402,10 +463,24 @@ guides:
logo_owner: EmuELEC
logo_licence: GitHub organisation avatar of EmuELEC
knulli:
summary: A system for handhelds, based on Batocera.
summary: A system for handhelds and bartops, based on Batocera.
# knulli.org: "retro gaming devices (handhelds, bartops, etc.)".
runs_on: Handhelds, bartops
folders:
- where: On KNULLI
# knulli.org/play/add-games/network-transfer: \\KNULLI, its share folder, Samba
# off by default. The share is /userdata, so bios is under it.
- where: From Windows, over your home network
path: '`\\KNULLI\share\bios\`'
run: [device]
notes:
- >-
For the network folder, turn on **SAMBA** in **System Settings > Services**.
# knulli.org/configure/ssh: SSH in System Settings > Services, root, password linux.
terminal: >-
Turn on **SSH**, a terminal over the network, in **System Settings > Services**. From
a computer, connect with `ssh root@KNULLI`, password `linux`. Paste the line. Press
Enter.
reference_notes:
- >-
KNULLI runs on handhelds among them the RG35XX family (H700) and the TrimUI Smart
@@ -413,14 +488,24 @@ guides:
logo_owner: KNULLI
logo_licence: GitHub organisation avatar of knulli-cfw
retropie:
summary: Installs RetroArch and other emulators on a Raspberry Pi.
summary: Installs RetroArch and other emulators on a Raspberry Pi or a PC.
# RetroPie docs index.md (175f5b15): Raspberry Pi, PC and Odroid.
runs_on: Raspberry Pi, PC, Odroid
folders:
- where: On the Raspberry Pi
# RetroPie-Setup scriptmodules/supplementary/samba.sh:49-52 (46128cd2) shares
# $home/RetroPie/BIOS as "bios", and docs/Transferring-Roms.md:29 names \\RETROPIE.
- where: From Windows, over your home network
path: '`\\RETROPIE\bios\`'
run: [device]
# RetroPie docs/FAQ.md:193 (F4 in EmulationStation, its game menu), 175f5b15. The computer route
# is the network folder above.
terminal: >-
Press **F4** in the game menu for a terminal. Paste the line. Press Enter.
notes:
- >-
Use the RetroPie pack, not the RetroArch one. On RetroPie 4.8, Python is too old
for the command: use the pack there.
Use the RetroPie pack, not RetroArch's. The command does not run on RetroPie 4.8: use
the pack there.
reference_notes:
- >-
RetroPie also installs standalone emulators (openMSX, PCSX2, XRoar, Amiberry and
@@ -469,13 +554,26 @@ guides:
misterfpga:
summary: >-
A board that recreates old consoles in hardware, not in software.
# mister-devel.github.io/MkDocs_MiSTer: "an FPGA board called the DE10-Nano".
runs_on: Its own board
folders:
- where: On the MiSTer SD card
path: '`/media/fat/games/`, one subfolder per core'
# mister-devel.github.io/MkDocs_MiSTer/setup/games: run samba_on from the Scripts
# menu, then \\MiSTer\sdcard\.
- where: From Windows, over your home network
path: '`\\MiSTer\sdcard\games\`'
run: [device]
# mister-devel.github.io/MkDocs_MiSTer/advanced/network: F9 for a Linux terminal,
# SSH user root, password 1.
terminal: >-
Press **F9** in the MiSTer menu and log in as `root`, password `1`. Or from a
computer, open a terminal over the network as `root`. Paste the line. Press Enter.
notes:
- >-
Each file goes in its console's folder, like `/media/fat/games/3DO/boot.rom`.
- >-
For the network folder, run **samba_on** from the MiSTer Scripts menu.
reference_notes:
- >-
MiSTer runs FPGA cores on a DE10-Nano board. Only the files the MiSTer BIOS database
@@ -484,7 +582,9 @@ guides:
logo_owner: MiSTer FPGA
logo_licence: GitHub organisation avatar of MiSTer-devel
minui:
summary: A simple game menu for handhelds. It runs from the SD card.
summary: A simple game menu for Anbernic, Miyoo and Trimui handhelds, on the SD card.
# shauninman/MinUI README (dbf89435) lines 3, 65-91.
runs_on: Anbernic, Miyoo, Trimui handhelds
folders:
- where: On the SD card
path: '`Bios/<TAG>/`, one folder per console'
@@ -506,8 +606,9 @@ guides:
logo_owner: Shaun Inman
logo_licence: GitHub avatar of shauninman, the author of MinUI
nextui:
summary: >-
A game menu for handhelds, based on MinUI. It runs from the SD card.
summary: A game menu for TrimUI handhelds, based on MinUI, on the SD card.
# LoveRetro/NextUI README (5291344b) lines 37, 89-94.
runs_on: TrimUI Brick, Smart Pro
folders:
- where: On the SD card
path: '`Bios/<TAG>/`, one folder per console'
@@ -524,8 +625,9 @@ guides:
logo_owner: LoveRetro
logo_licence: GitHub organisation avatar of LoveRetro
myminui:
summary: >-
A game menu for handhelds, based on MinUI. It runs from the SD card.
summary: A game menu for Anbernic and Miyoo handhelds, based on MinUI, on the SD card.
# Turro75/MyMinUI README (72ed4f3d) lines 9-35.
runs_on: Anbernic, Miyoo handhelds
folders:
- where: On the SD card
path: '`Bios/<TAG>/`, one folder per console'
+16 -12
View File
@@ -843,15 +843,23 @@ def contributing_text(
## Add a BIOS file
One file needs no clone and no local setup:
Have a file the collection lacks? Add it from the browser. It is checked on arrival.
1. [Upload it into `bios/`]({UPLOAD_URL})
from the browser, 25 MB at most per file. GitHub writes it to a fork and
opens the pull request.
2. CI hashes it and looks it up in every platform list and emulator profile.
For a file it recognises, it names the folder holding the other files of its
system. The report is in the summary of the pull request's Validation run.
3. The file can be moved to that folder in the same pull request. A file whose
[Upload it into `bios/`]({UPLOAD_URL}), 25 MB at most per file. GitHub
writes it to a fork and opens the pull request.
The files most wanted are on [what the packs do not hold]({link("gaps")}). The
[dump provenance]({link("provenance")}) page lists catalogued dumps still
missing from the collection, with their hashes. A file matching one of those is
the most useful contribution.
## What happens next
1. CI hashes the file and looks it up in every platform list and emulator
profile. For a file it recognises, it names the folder holding the other
files of its system. The report is in the summary of the pull request's
Validation run.
2. The file can be moved to that folder in the same pull request. A file whose
hash differs from the one already held under its name goes to that folder's
`.variants/`.
@@ -859,10 +867,6 @@ From a clone, the file goes to `bios/Manufacturer/Console/filename`, and
`python scripts/validate_pr.py path/to/file` names that folder for a file still
outside `bios/`. A file over the browser's 25 MB limit goes in that way.
The [dump provenance]({link("provenance")}) page lists catalogued dumps still
missing from the collection, with their hashes. A file matching one of those is
the most useful contribution.
## Add a platform
1. Write a scraper in `scripts/scraper/` (inherit `BaseScraper`)
+305 -184
View File
@@ -85,9 +85,15 @@ from siteguides import (
GROUP_ANCHORS,
GUIDES,
Guide,
FENCE_TITLES,
INSTALL_BASE,
TERMINALS,
TRUST_ITEMS,
detected_systems as _detected_systems,
fence,
folder_list,
install_command,
setup_count,
platform_args,
terminal_tabs,
trust_box,
@@ -398,9 +404,18 @@ def _system_pages_from_data(
def _timestamp() -> str:
"""The build time for data files (stats, API records), ISO 8601."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def data_as_of(moment: datetime | None = None) -> str:
"""The build date for a reader, in words, with the machine form beside it.
A raw ISO stamp ("2026-10-22T22:43:08Z") closed the home page."""
moment = moment or datetime.now(timezone.utc)
return (f'<time datetime="{moment.strftime("%Y-%m-%dT%H:%M:%SZ")}">'
f"Data as of {moment.day} {moment.strftime('%B %Y')}</time>")
# Home page
@@ -425,8 +440,36 @@ def _group_members(coverages: dict, registry: dict, group: str) -> list[str]:
def _next_block(links: list[str]) -> list[str]:
"""Where to go from here, at the end of a page."""
return markup.aside("tip", "Next", [f"- {link}" for link in links])
"""Where to go from here, at the end of a page: a plain heading and a
list. A tinted callout with a side stripe said the same as the pager."""
return ["## Next steps", "", *(f"- {link}" for link in links), ""]
def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "") -> list[str]:
"""The setup tiles of one device group: logo, name and the hardware it runs on."""
return markup.platform_tiles([
(display[name], f"{prefix}platforms/{name}.md",
str(registry.get(name, {}).get("logo") or ""),
GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name]))
for name in members
])
def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> list[str]:
"""Every setup as a tile, under the device group it belongs to. The
setups alone in their group share one row: "MiSTer FPGA" above
"MiSTer FPGA" said nothing."""
lines: list[str] = []
alone: list[str] = []
for group, (label, _description) in DEVICE_GROUPS.items():
members = _group_keys(registry, group, display)
if len(members) == 1:
alone += members
elif members:
lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix)]
if alone:
lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix)]
return lines
def generate_home(
@@ -441,11 +484,13 @@ def generate_home(
The figures about the collection, the cards to the deeper pages and the
call for help sat below the steps and doubled the page: they are on the
Explore index, one tab further."""
registry = registry or {}
names = sorted((cov["platform"] for cov in coverages.values()), key=str.casefold)
named = [n for n in ("RetroArch", "Batocera", "EmuDeck") if n in names]
others = len(names) - len(named)
Explore index, one tab further. No date and no "next" box: the hero
buttons say where to go."""
registry = registry or load_platform_registry(platforms_dir)
display = {key: load_platform_config(key, platforms_dir).get("platform", key)
for key in registry}
named = [n for n in ("RetroArch", "Batocera", "EmuDeck") if n in display.values()]
others = setup_count() - len(named)
works_with = ", ".join(named) + (f" and {others} more" if others else "")
lines = [
@@ -460,6 +505,9 @@ def generate_home(
"",
f"Free. No account. Works with {works_with}.",
"",
"- A game says BIOS missing? [When a game does not start](wiki/troubleshooting.md)",
"- Have a file already? Reference: [Check it](files/index.md)",
"",
"## Do I need it?",
"",
"- **Yes**, if your emulator says a BIOS is missing.",
@@ -477,31 +525,14 @@ def generate_home(
]),
"## Pick your device",
"",
]
lines += markup.card_grid([
(label, [", ".join(
f"[{coverages[name]['platform']}](platforms/{name}.md)"
for name in _group_members(coverages, registry, group)
)])
for group, (label, _description) in DEVICE_GROUPS.items()
if _group_members(coverages, registry, group)
])
lines += [
*setup_picker(registry, display),
"## Is it safe?",
"",
"- It writes only in your BIOS folder.",
"- It checks every file before saving it.",
"- It keeps any file it replaces, in a `retrobios-backup` folder.",
"- It never touches your games or saves.",
*(f"- {item}" for item in TRUST_ITEMS),
"",
"[How the command is checked](wiki/faq.md#is-piping-the-installer-into-a-shell-safe). "
"[Is it legal?](wiki/faq.md#is-this-legal)",
"Details: [Is it legal?](wiki/legal.md)",
"",
*_next_block([
"[Get your files](which-pack.md)",
"[What is a BIOS?](wiki/what-are-bios-files.md)",
]),
f'<div class="rb-timestamp">Generated on {_timestamp()}.</div>',
]
return "\n".join(lines) + "\n"
@@ -537,7 +568,7 @@ def generate_explore_index(db: dict, coverages: dict, profiles: dict) -> str:
markup.block_open("div", 'class="rb-stats"'),
"",
*_stat(f"{total_files:,}", COLLECTION_FILES_LABEL),
*_stat(str(len(coverages)), "Setups supported"),
*_stat(str(setup_count()), "Setups supported"),
*_stat(str(emulator_count), "Emulators documented"),
*_stat(_fmt_size(total_size), "Size of the collection"),
"</div>",
@@ -572,15 +603,58 @@ def compute_stats(db: dict, coverages: dict, profiles: dict) -> dict:
}
def _release_date(tag: str) -> str:
"""The date a release tag names (v2026.10.10), in words, or empty."""
match = re.fullmatch(r"v(\d{4})\.(\d{2})\.(\d{2})", tag or "")
if not match:
return ""
moment = datetime(int(match[1]), int(match[2]), int(match[3]))
return f"{moment.day} {moment.strftime('%B %Y')}"
def _pack_version(pack_name: str) -> str:
"""The platform version a pack was built for, from its name
(Batocera_43.1_BIOS_Pack.zip is 43.1)."""
stem = pack_name.removesuffix("_BIOS_Pack.zip")
return stem.rsplit("_", 1)[1] if "_" in stem else ""
def release_line(platform_name: str, record: dict) -> list[str]:
"""Which release the buttons serve, with its checksums and signature, and
the version gap when the page describes a newer platform than the pack."""
tag = record.get("tag")
pack_name = release_record.pack_name_for(platform_name, record)
if not tag or not pack_name:
return []
base = f"{REPO_URL}/releases/download/{tag}"
built = _release_date(tag)
lines = [
f"Pack {tag}{f', built {built}' if built else ''}. "
f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) "
f"[signature]({base}/SHA256SUMS.txt.sig)",
]
config = load_platform_config(platform_name, "platforms")
page_version = str(config.get("version") or "")
pack_version = _pack_version(pack_name)
if page_version and pack_version and page_version.lstrip("v") != pack_version.lstrip("v"):
display = config.get("platform", platform_name)
lines.append(f"This pack was built for {display} {pack_version}. The command "
f"installs the files for {display} {page_version}.")
return lines
def _pack_buttons(platform_name: str, label: str = "Pack") -> str:
"""Buttons to the files of the platform's pack in the recorded release."""
return download_links(
"""Buttons to the files of the platform's pack in the recorded release,
and the release they come from under them."""
record = release_record.load_record()
buttons = download_links(
platform_name,
release_record.load_record(),
record,
REPO_URL,
label,
button=lambda text, url: markup.button(text, url, primary=True),
)
return "\n\n".join([buttons, *release_line(platform_name, record)])
def _released_files(platform_name: str) -> str:
@@ -1466,6 +1540,7 @@ def _page_description(markdown: str, relative: Path, title: str) -> str:
"provenance.md": "Hash-based comparison of RetroBIOS metadata with No-Intro, Redump and TOSEC catalog snapshots.",
"explore.md": "Systems, emulators and installer options to explore, with the figures of the BIOS collection.",
"which-pack.md": "Pick your device or setup and get its BIOS files: the folder they go in, one command that installs and checks them, or a ZIP pack to extract.",
"contributing.md": "Add a BIOS file the collection lacks from the browser, checked on arrival, or add a platform or an emulator profile.",
}
if key in specific:
return specific[key]
@@ -1500,7 +1575,9 @@ def _page_description(markdown: str, relative: Path, title: str) -> str:
):
continue
plain = _plain_markdown(stripped)
if len(plain) >= 35:
# A paragraph that ends on a colon introduces what follows: alone,
# it describes nothing ("One file needs no clone and no local setup:").
if len(plain) >= 35 and not plain.endswith(":"):
return _shorten(plain)
return f"{title}: RetroBIOS source-traced retrogaming firmware reference."
@@ -2012,7 +2089,8 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st
members = _group_members(coverages, registry, group)
if not members:
continue
lines += [f"## {label}", "", description, ""]
# One setup: its card says what the group description would.
lines += [f"## {label}", "", *([description, ""] if len(members) > 1 else [])]
lines += markup.card_grid([
(f"[{coverages[name]['platform']}]({name}.md)", [GUIDES[name].summary])
for name in members
@@ -2023,7 +2101,7 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st
"",
level_label("reference"),
"",
f"{len(coverages)} supported platforms with {total_present:,} verified files.",
f"{setup_count()} supported platforms with {total_present:,} verified files.",
"",
"| Platform | On its BIOS list | Files in the pack | Checked by | Status | Download |",
"|----------|------:|------:|-----------|--------|----------|",
@@ -2354,12 +2432,102 @@ def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[st
]
# Shown in the "On the device" tab of a guide that has no `terminal:` of its own.
DEVICE_TERMINAL_FALLBACK = "Or fill the folder from your computer, with the ZIP."
def _computer_first(name: str, guide: Guide) -> bool:
"""A system that runs only on its own device, with a ZIP to download: a
beginner fills its folder from a computer more easily than from a
terminal on the TV. Without a ZIP the command is the only route."""
pack = release_record.pack_for(name, release_record.load_record())
return guide.run == ("device",) and bool(pack and pack.get("assets"))
def _ships_neogeo(name: str) -> bool:
"""Whether the setup's files hold `neogeo.zip`, the archive a reader most
often unzips by mistake: arcade BIOS sets are read zipped."""
manifest = _REPO_ROOT / "install" / f"{name}.json"
if not manifest.is_file():
return False
files = json.loads(manifest.read_text(encoding="utf-8")).get("files", [])
return any(f.get("dest", "").rsplit("/", 1)[-1] == "neogeo.zip" for f in files)
def _guide_download(name: str, display: str, guide: Guide, extract_to: str | None,
heading: str) -> list[str]:
"""The pack: its buttons, its size, and where it is unzipped."""
lines = [heading, ""]
record = release_record.load_record()
pack = release_record.pack_for(name, record)
network = any(label.startswith("From ") for label, _path in guide.folders)
if pack and pack.get("assets"):
parts = len(pack["assets"])
bullets = [
f"{_fmt_size(pack['download_size'])} to download, "
f"{_fmt_size(pack['extracted_size'])} once unzipped."
]
if parts > 1:
bullets.append("You need both parts." if parts == 2 else f"You need all {parts} parts.")
if _computer_first(name, guide) and network:
target = "the network folder above"
else:
target = f"`{extract_to}`" if extract_to else "your BIOS folder"
bullets.append(f"Unzip {'them all' if parts > 1 else 'it'} into {target}."
+ (" Files like `neogeo.zip` stay zipped." if _ships_neogeo(name) else ""))
if guide.download_hint and not _computer_first(name, guide):
bullets.append(guide.download_hint)
lines += [
_pack_buttons(name, f"Download {display} Pack"),
"",
*(f"- {b}" for b in bullets),
"",
]
elif record.get("tag"):
lines += [
f"No {display} ZIP yet. The command installs the same files.",
"",
]
else:
lines += [_pack_buttons(name, f"Download {display} Pack"), ""]
return lines
def _guide_install(name: str, guide: Guide, heading: str) -> list[str]:
"""The command, in a tab per place it runs, and whether it is safe."""
lines = [
heading,
"",
"The command finds the folder, downloads what is missing, and checks "
"each file.",
"",
]
if guide.on_card:
lines += [
"Put the SD card in a computer. On Windows, note its letter, like "
"**E:**. Put the card's path after `--dest`.",
"",
]
device = guide.terminal or f"{TERMINALS['device'][1]} {DEVICE_TERMINAL_FALLBACK}"
return [
*lines,
*terminal_tabs(guide.run, platform_args(name, guide), how={"device": device}),
*trust_box("../"),
]
def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: bool) -> list[str]:
"""The guide part of a platform page: folder, command, pack, check, next."""
"""The guide part of a platform page: folder, command, pack, check, next.
A system that runs only on its own device puts the computer route
(download, unzip into the network folder) before the command."""
guide = GUIDES[name]
entry = registry.get(name, {})
extract_to = entry.get("extract_to")
lines = ["## Where the files go", "", *_folder_rows(guide.folders)]
lines = ["## Where the files go", ""]
if guide.lead:
lines += [guide.lead, ""]
lines += _folder_rows(guide.folders)
if extract_to:
lines.append(f"| Unzip the pack into | `{extract_to}` |")
lines.append("")
@@ -2375,54 +2543,19 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
"",
*([guide.more_folders_note, ""] if guide.more_folders_note else []),
])
lines += [
*(["<!-- image: minui-card -->", ""] if guide.on_card else []),
"## Install",
"",
"The command finds the folder, downloads what is missing, and checks "
"each file.",
"",
]
if guide.on_card:
lines += ["<!-- image: minui-card -->", ""]
if _computer_first(name, guide):
lines += [
"Put the SD card in a computer. On Windows, note its letter, like "
"**E:**. Put the card's path after `--dest`.",
"",
]
lines += [
*terminal_tabs(guide.run, platform_args(name, guide)),
*trust_box("../"),
"## Download",
"",
]
record = release_record.load_record()
pack = release_record.pack_for(name, record)
if pack and pack.get("assets"):
parts = len(pack["assets"])
bullets = [
f"{_fmt_size(pack['download_size'])} to download, "
f"{_fmt_size(pack['extracted_size'])} once unzipped."
]
if parts > 1:
bullets.append("You need both parts." if parts == 2 else f"You need all {parts} parts.")
target = f"`{extract_to}`" if extract_to else "your BIOS folder"
bullets.append(f"Unzip {'them all' if parts > 1 else 'it'} into {target}.")
if guide.download_hint:
bullets.append(guide.download_hint)
lines += [
_pack_buttons(name, f"Download {display} Pack"),
"",
*(f"- {b}" for b in bullets),
"",
]
elif record.get("tag"):
lines += [
f"No {display} pack in release {record['tag']} yet. The command above "
"installs the same files.",
"",
*_guide_download(name, display, guide, extract_to,
"## From your computer { #download }"),
*_guide_install(name, guide, "## On the device { #install }"),
]
else:
lines += [_pack_buttons(name, f"Download {display} Pack"), ""]
lines += [
*_guide_install(name, guide, "## Install"),
*_guide_download(name, display, guide, extract_to, "## Download"),
]
check_args = {key: f"{args} --check" for key, args in platform_args(name, guide).items()}
lines += [
"## How to check",
@@ -2434,6 +2567,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
"",
_check_sentence(display, cov["mode"], cov["config"], no_list),
"",
*([guide.finish, ""] if guide.finish else []),
*_next_block([
"[First install, step by step](../wiki/first-install.md)",
"[When a game does not start](../wiki/troubleshooting.md)",
@@ -4378,8 +4512,7 @@ def install_commands(args: str = "", indent: str = "") -> list[str]:
("Windows (PowerShell):", "powershell"),
("Android (Termux, after `pkg install python` and `termux-setup-storage`):", "sh"),
):
lines += [f"{indent}{label}", "", f"{indent}```{shell}",
f"{indent}{install_command(shell, args)}", f"{indent}```", ""]
lines += [f"{indent}{label}", "", *fence(shell, install_command(shell, args), indent), ""]
return lines
@@ -4416,15 +4549,11 @@ def _platform_install_block(name: str, display: str, on_card: bool = False) -> l
"",
"Linux, macOS:",
"",
"```sh",
install_command("sh", f"--platform {name} --dest /path/to/card"),
"```",
*fence("sh", install_command("sh", f"--platform {name} --dest /path/to/card")),
"",
"Windows (PowerShell, E: being the card):",
"",
"```powershell",
install_command("powershell", f"--platform {name} --dest E:\\"),
"```",
*fence("powershell", install_command("powershell", f"--platform {name} --dest E:\\")),
], opened=True)
return markup.details("tip", "Install with one command", [
f"The installer downloads the files of the {display} pack that are "
@@ -4436,22 +4565,31 @@ def _platform_install_block(name: str, display: str, on_card: bool = False) -> l
], opened=True)
def _ps_quote(value: str) -> str:
"""A PowerShell argument: bare when plain, single-quoted otherwise."""
return value if re.fullmatch(r"[\w.+-]+", value) else "'" + value.replace("'", "''") + "'"
def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]:
"""Each platform's command for one console, by the system id it gives it."""
"""Each platform's command for one console, by the system id it gives it,
for a terminal and for PowerShell."""
if not declared:
return []
lines = [
"Each platform names the system its own way. On Windows, run the "
"same options through `&([scriptblock]::Create((irm "
f"{INSTALL_BASE}/install.ps1)))`.",
sh = [install_command("sh", f"--platform {platform} --system {shlex.quote(system)}")
for platform, system in declared]
ps = [install_command("powershell", f"--platform {platform} --system {_ps_quote(system)}")
for platform, system in declared]
return search_excluded(markup.details("tip", f"Install the {console} files", [
"Each platform names the system its own way.",
"",
"```sh",
]
lines += [
install_command("sh", f"--platform {platform} --system {shlex.quote(system)}")
for platform, system in declared
]
return search_excluded(markup.details("tip", f"Install the {console} files", [*lines, "```"]))
"Linux, macOS, Steam Deck:",
"",
*fence("sh", "\n".join(sh)),
"",
"Windows (PowerShell):",
"",
*fence("powershell", "\n".join(ps)),
]))
def _render_platform_details(profile: dict) -> list[str]:
@@ -6105,9 +6243,9 @@ def generate_not_found() -> str:
def reference_next() -> str:
return "\n" + "\n".join(markup.aside("tip", "Next", [
"- [Reference index](wiki/index.md), every reference page in one list",
"- For contributors: [How to contribute](contributing.md)",
return "\n" + "\n".join(_next_block([
"[Reference index](wiki/index.md), every reference page in one list",
"For contributors: [How to contribute](contributing.md)",
]))
@@ -6327,26 +6465,13 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str:
"""The pack's parts and size, or the command where the release has none."""
pack = release_record.pack_for(name, record)
if record.get("tag") and not (pack and pack.get("assets")):
return (f"Not in {record['tag']}: "
f"[use the command]({prefix}platforms/{name}.md#install)")
return f"No ZIP yet. [Use the command]({prefix}platforms/{name}.md#install)."
display = load_platform_config(name, "platforms").get("platform", name)
links = download_links(name, record, REPO_URL, display)
size = pack.get("download_size") if pack else None
return f"{links} ({_fmt_size(size)})" if size else links
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(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
return sorted(seen.items(), key=lambda item: item[0].casefold())
def _group_keys(registry: dict, group: str, display: dict[str, str]) -> list[str]:
return sorted(
(k for k in registry if registry[k].get("device_group") == group),
@@ -6368,75 +6493,41 @@ def _own_root_folders(registry: dict) -> dict[str, str]:
def generate_which_pack() -> str:
"""The device picker: every registered platform, grouped by the device it
runs on, with its BIOS folder, its pack and its page.
"""The device picker: every registered platform as a tile under the
device it runs on, then one table per device with its BIOS folder and
its ZIP, the console picker, and the command for a setup not listed.
The command comes first: it finds the setup by itself, and the tables
below it serve the reader who downloads a ZIP."""
The tiles are the choice. A second set of cards, five tables and the
picker offered the same choice three times."""
registry = load_platform_registry("platforms")
record = release_record.load_record()
display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry}
own_root = _own_root_folders(registry)
all_tabs = ("windows", "macos", "linux", "steamdeck", "android")
lines = [
"# Get your files",
"",
"Two ways to fill your BIOS folder.",
"",
"- **[One command](#quick-install)**: finds your folder and checks each "
"file.",
"- **[A ZIP file](#manual-download)**: download it, unzip it into your "
"BIOS folder.",
"",
"## One command { #quick-install }",
"",
"A **terminal** is a window where you type commands.",
"",
*terminal_tabs(("windows", "macos", "linux", "steamdeck", "android"), {}),
"On Batocera, Recalbox and other game systems, run it on the device. "
"Your setup's page shows how.",
"",
*trust_box(""),
"It then asks what to install. Press **Enter** for everything.",
"",
"```",
" Enter) install everything",
" i) only what the installed cores read",
" c) choose systems, cores and regions",
" q) quit",
"```",
"",
"**Check your folder later.** Add `--check`. It changes nothing.",
"",
*terminal_tabs(("windows", "macos", "linux", "steamdeck", "android"),
dict.fromkeys(TERMINALS, "--check"), steps=False),
*markup.details("note", "Fill an SD card from a computer", [
"Name your setup and the BIOS folder on the card. For RetroArch on a "
"Nintendo Switch:",
"",
"```sh",
install_command(
"sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system"),
"```",
]),
"Details: [All installer options](wiki/installer.md).",
"Pick your setup. Its page gives the folder, the command and the ZIP.",
"",
"## Pick your setup",
"",
*setup_picker(registry, display),
"## Folders and ZIP files { #manual-download }",
"",
"- A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.",
"- Unzip them all into the BIOS folder of your setup. Put the files "
"straight in it, not in a new folder.",
"- Some files inside stay zipped, like `neogeo.zip`. Leave them as they are.",
"- **Unzip into** means the ZIP holds its own top folder. Unzip it there.",
"",
]
lines += markup.card_grid([
(label, [f"[{', '.join(display[k] for k in members)}](#{GROUP_ANCHORS[group]})"])
for group, (label, _description) in DEVICE_GROUPS.items()
for members in [_group_keys(registry, group, display)]
if members
])
for group, (label, description) in DEVICE_GROUPS.items():
no_zip = False
for group, (label, _description) in DEVICE_GROUPS.items():
members = _group_keys(registry, group, display)
if not members:
continue
lines += [
f"## {label} {{ #{GROUP_ANCHORS[group]} }}",
"",
description,
f"### {label} {{ #{GROUP_ANCHORS[group]} }}",
"",
"| Setup | Runs on | BIOS folder | ZIP |",
"|-------|---------|-------------|-----|",
@@ -6445,9 +6536,11 @@ def generate_which_pack() -> str:
guide = GUIDES[key]
folder = (f"Unzip into `{own_root[key]}`" if key in own_root
else _folder_cell(key, guide))
pack = release_record.pack_for(key, record)
no_zip |= bool(record.get("tag") and not (pack and pack.get("assets")))
lines.append(
f"| [{display[key]}](platforms/{key}.md) | "
f"{_runs_on(registry[key], guide)} | {folder} | "
f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | "
f"{_pack_cell(key, record)} |"
)
for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ()):
@@ -6466,28 +6559,56 @@ def generate_which_pack() -> str:
*(f"| {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |"
for name, folder in _detected_systems(registry, "retroarch")),
])
if no_zip:
lines += ["Setups added after the last release have no ZIP until the next one.", ""]
lines += [
"## Download a ZIP { #manual-download }",
"",
"1. Find your setup in the tables above.",
"2. Download its ZIP. A big one comes in parts, like "
"`Pack.part1of2.zip` and `Pack.part2of2.zip`. Download every part.",
"3. Unzip every part into the folder named in the table.",
"",
"A row that says **Unzip into** has a ZIP that holds its own top folder. "
"Unzip it there, not into the BIOS folder.",
"",
f"The ZIPs come from the [latest release]({RELEASE_URL}). The command may "
"install newer files.",
"",
"## Only some consoles?",
"",
"Run the command and press **c** when it asks.",
"",
"Or pick them here, and get the files and the command.",
"Pick them here, and get the files and the command. Or run the command "
"below and press **c** when it asks.",
"",
"<FilePicker />",
"",
"## Not in the list? One command finds it { #quick-install }",
"",
"A **terminal** is a window where you type commands.",
"",
*terminal_tabs(all_tabs, {}),
"On Batocera, Recalbox and other game systems, run it on the device. "
"Your setup's page shows how.",
"",
"The command asks what to install. Press **Enter** for everything.",
"",
"```text title=\"What it asks\"",
" Enter) install everything",
" i) only what the installed cores read",
" c) choose systems, cores and regions",
" q) quit",
"```",
"",
*trust_box(""),
"**Check your folder later.** Add `--check`. It changes nothing.",
"",
*terminal_tabs(all_tabs, dict.fromkeys(TERMINALS, "--check"), steps=False),
*markup.details("note", "Fill an SD card from a computer", [
"Name your setup and the BIOS folder on the card. For RetroArch on a "
"Nintendo Switch:",
"",
"Linux, macOS:",
"",
*fence("sh", install_command(
"sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system")),
"",
"Windows (PowerShell, E: being the card):",
"",
*fence("powershell", install_command(
"powershell", "--platform retroarch --dest E:\\retroarch\\cores\\system")),
]),
"Details: [All installer options](wiki/installer.md).",
"",
*_next_block([
"[First install, step by step](wiki/first-install.md)",
"[When a game does not start](wiki/troubleshooting.md)",
+70 -10
View File
@@ -38,7 +38,12 @@ class Guide:
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.
logo the registry links. `runs_on` names the hardware, in the project's
own words, for its tile and its row. `terminal` replaces the shared
"On the device" text with how this system opens a terminal. `lead` comes
before the folder table, `finish` closes the guide. `{folders}` in
`pack_manual` is the folder list of the page, so the README and the page
cannot name two folders for one device.
"""
summary: str
@@ -56,6 +61,10 @@ class Guide:
pack_manual: str = ""
logo_owner: str = ""
logo_licence: str = ""
runs_on: str = ""
terminal: str = ""
lead: str = ""
finish: str = ""
_GUIDE_KEYS = {f.name for f in fields(Guide)}
@@ -130,7 +139,7 @@ def _guide(name: str, data: object, extract_to: str, terminals: dict) -> Guide:
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"):
"logo_owner", "logo_licence", "runs_on", "terminal", "lead", "finish"):
if key in data:
values[key] = _text(data[key], f"{where}.{key}")
for key in ("on_card", "more_folders_detected"):
@@ -213,6 +222,7 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict:
# The order the file is written in, which the pages do not depend on.
guides = {name: guides[name] for name in raw_guides}
return {
"registry": registry,
"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,
@@ -231,6 +241,43 @@ ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also
TERMINALS: dict[str, tuple[str, str, str]] = _LOADED["terminals"]
TRUST_ITEMS: tuple[str, ...] = _LOADED["trust_items"]
GUIDES: dict[str, Guide] = _LOADED["guides"]
_REGISTRY: dict = _LOADED["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(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
return sorted(seen.items(), key=lambda item: item[0].casefold())
def folder_list(name: str, registry: dict | None = None) -> list[tuple[str, str]]:
"""Every folder a guide names, as (where, path): the common ones, the
rarer builds, and the systems the registry detects when the guide says so."""
guide = GUIDES[name]
rows = [*guide.folders, *guide.more_folders]
if guide.more_folders_detected:
rows += [(system, f"`{folder}`")
for system, folder in detected_systems(registry or _REGISTRY, name)]
return rows
def _plain_folders(name: str) -> str:
rows = folder_list(name)
width = max(len(where) for where, _path in rows) + 1
return "\n".join(f"{where + ':':<{width}} {path.replace('`', '')}" for where, path in rows)
def setup_count() -> int:
"""Setups the site covers: every registered platform, archived ones
included, since each keeps its page, its pack and its command. The one
count every page states."""
return len(GUIDES)
def pack_manual(name: str) -> str:
@@ -238,7 +285,12 @@ def pack_manual(name: str) -> str:
guide = GUIDES.get(name)
if not guide or not guide.pack_manual:
return ""
return textwrap.indent(guide.pack_manual.rstrip("\n"), " ") + "\n\n"
text = guide.pack_manual.rstrip("\n")
if "{folders}" in text:
indent = next(line[: len(line) - len(line.lstrip())]
for line in text.splitlines() if "{folders}" in line)
text = text.replace("{folders}", textwrap.indent(_plain_folders(name), indent).lstrip())
return textwrap.indent(text, " ") + "\n\n"
def install_command(shell: str, args: str = "") -> str:
@@ -254,21 +306,29 @@ def install_command(shell: str, args: str = "") -> str:
return f"irm {INSTALL_BASE}/install.ps1 | iex"
def _fence(shell: str, command: str, indent: str) -> list[str]:
return [f"{indent}```{shell}", f"{indent}{command}", f"{indent}```"]
# The title over each command block, by the shell it is written for.
FENCE_TITLES = {"sh": "Terminal", "powershell": "PowerShell"}
def fence(shell: str, command: str, indent: str = "") -> list[str]:
"""A command block with its title, as every command on the site is shown."""
return [f'{indent}```{shell} title="{FENCE_TITLES[shell]}"', f"{indent}{command}", f"{indent}```"]
def terminal_tabs(
tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True,
how: dict[str, str] | None = None,
) -> list[str]:
"""Content tabs, one per place the command runs, each with how to open
the terminal there. Tab labels are shared across the site, so the reader's
choice carries from page to page."""
choice carries from page to page. *how* replaces the shared text of a tab
for one setup."""
items = []
for key in tabs:
label, how, shell = TERMINALS[key]
body = [how, ""] if steps else []
items.append((label, body + _fence(shell, install_command(shell, args.get(key, "")), "")))
label, shared, shell = TERMINALS[key]
text = (how or {}).get(key) or shared
body = [text, ""] if steps else []
items.append((label, body + fence(shell, install_command(shell, args.get(key, "")))))
return markup.tabs(items, indent)
@@ -287,7 +347,7 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]:
def trust_box(prefix: str, indent: str = "") -> list[str]:
"""Is it safe, under every command on a guide page: five plain facts."""
"""Is it safe, under every command on a guide page: the trust items."""
return markup.details("note", "Is it safe?", [
*(f"- {item}" for item in TRUST_ITEMS),
"",
+31
View File
@@ -184,6 +184,37 @@ def card_grid(cards: list[tuple[str, list[str]]]) -> list[str]:
return [*lines, "</div>", ""]
def initials(name: str) -> str:
"""Up to two capitals of a name, for a setup that has no logo: MyMinUI is MM."""
capitals = [c for c in name if c.isupper()]
return "".join(capitals[:2]) or name[:2].upper()
def _tile_mark(name: str, logo: str) -> str:
if logo:
return f'<img class="rb-card-logo" src="{html.escape(logo)}" alt="" width="40" height="40">'
# The same square as a logo, drawn on white like the logos are.
return ('<svg class="rb-card-logo" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true">'
'<text x="20" y="25" text-anchor="middle" font-size="14" font-weight="700" '
f'fill="#2b2e5c">{html.escape(initials(name))}</text></svg>')
def platform_tiles(tiles: list[tuple[str, str, str, str]]) -> list[str]:
"""One link per setup: its logo (or its initials), its name and where it
runs. *tiles* are (name, target, logo, runs_on).
One HTML line: a grid of links is a menu, not prose, and the Markdown
around it reads none of it."""
links = "".join(
f'<a class="rb-card rb-tile" href="{html.escape(target)}">'
f"{_tile_mark(name, logo)}"
f'<strong class="rb-tile-name">{html.escape(name)}</strong>'
f'<span class="rb-tile-runs">{inline_html(runs_on)}</span></a>'
for name, target, logo, runs_on in tiles
)
return [f'<div class="rb-cards rb-tiles">{links}</div>', ""]
def steps(items: list[str]) -> list[str]:
"""An ordered list drawn as numbered steps."""
return [block_open("div", 'class="rb-steps"'), "",
+1 -1
View File
@@ -22,7 +22,7 @@ import generate_site # noqa: E402
def _normalized(text: str) -> str:
# The site ends each page with its next step in the reading order. That
# is navigation, not contributing text, and a repository file has none.
text = text.split("\n\n:::tip[Next]\n", 1)[0].rstrip("\n")
text = text.split("\n## Next steps\n", 1)[0].rstrip("\n")
text = re.sub(r"\]\([^)]*\)", "](link)", text)
return "\n".join(text.splitlines()[1:]).replace("on this site", "on the documentation site")
+1 -1
View File
@@ -179,7 +179,7 @@ class DownloadLinksNameTheFile(unittest.TestCase):
row = next(line for line in page.splitlines() if line.startswith(f"| [{label}]("))
with self.subTest(platform=key):
self.assertNotIn("/releases/", row)
self.assertIn(f"Not in {RECORD['tag']}: [use the command](platforms/{key}.md#install)", row)
self.assertIn(f"No ZIP yet. [Use the command](platforms/{key}.md#install).", row)
class SlicesLeaveWithTheirRelease(unittest.TestCase):
+1 -1
View File
@@ -224,7 +224,7 @@ def parse(markdown: str) -> Page:
flush()
tab = 0
continue
if "rb-button" in stripped or stripped.startswith("<FilePicker"):
if "rb-button" in stripped or "rb-tile" in stripped or stripped.startswith("<FilePicker"):
act()
continue
if re.match(r"</?(?!abbr\b|a\b|code\b|strong\b|em\b)[A-Za-z]", stripped):
+4 -3
View File
@@ -962,13 +962,14 @@ class TheOneLinerIsWhereUsersLand(unittest.TestCase):
"verified": 0, "missing": 0, "details": [], "config": {"systems": {}}}
page = generate_site.generate_platform_page("batocera", cov)
self.assertIn("install.sh | sh -s -- --platform batocera", page)
self.assertLess(page.index("--platform batocera"), page.index("rb-button"))
# Batocera runs the command on the device itself. A platform detected
# on Windows gets the PowerShell form.
# Batocera runs the command on the device itself, so its page opens on
# the computer route. A platform detected on Windows gets the
# PowerShell form, and its command comes before the ZIP.
cov = dict(cov, platform="RetroArch", mode="existence")
page = generate_site.generate_platform_page("retroarch", cov)
self.assertIn("install.ps1))) --platform retroarch", page)
self.assertIn("install.sh | sh -s -- --platform retroarch", page)
self.assertLess(page.index("--platform retroarch"), page.index("rb-button"))
entry = {"name": "a.bin", "sha1": "a" * 40, "md5": "b" * 32, "size": 3,
"path": "bios/Recalbox/MSX/a.bin"}
+7 -4
View File
@@ -229,7 +229,8 @@ class DeepLinksCarryALabel(unittest.TestCase):
class GuidePagesEndOnANextStep(unittest.TestCase):
def test_home_picker_and_platform_page(self):
for page in ("index.md", "which-pack.md", "platforms/batocera.md",
# The home page has none: its hero buttons are the next step.
for page in ("which-pack.md", "platforms/batocera.md",
"wiki/first-install.md", "wiki/troubleshooting.md",
"wiki/what-are-bios-files.md", "wiki/faq.md"):
if page.startswith(("index", "which", "platforms")):
@@ -239,16 +240,18 @@ class GuidePagesEndOnANextStep(unittest.TestCase):
path = REPO_ROOT / page
with self.subTest(page=page):
text = path.read_text(encoding="utf-8")
self.assertTrue(":::tip[Next]" in text or "## What comes next" in text)
self.assertTrue(any(mark in text for mark in (
"## Next steps", ":::tip[Next]", "## What comes next")))
def test_the_platform_page_opens_on_its_guide(self):
"""The folder came after four counters and a metadata table, and was
the pack-relative `bios/` where Batocera reads /userdata/bios/."""
sitepages.require()
text = sitepages.page("platforms/batocera.md").read_text(encoding="utf-8")
# Batocera runs on its own device: the computer route comes first.
order = [text.index(h) for h in (
"## Where the files go", "## Install", "## Download", "## How to check",
"## Reference: every file by system",
"## Where the files go", "## From your computer", "## On the device",
"## How to check", "## Reference: every file by system",
)]
self.assertEqual(order, sorted(order))
self.assertLess(text.index("`/userdata/bios/`"), text.index("rb-stat-value"))
+320
View File
@@ -0,0 +1,320 @@
"""Entry pages and guides (round 2, package 4).
The home page said the command "writes only in your BIOS folder" while
install.py writes the entries a manifest files under `root: save` into the
frontend's save folder. It ended on a tinted "Next" box, a raw ISO stamp and
a setup count the diagrams gave one lower. Get your files offered the same
choice three times, a Batocera page sent a beginner to "a terminal on the
device" with no way to open one, and command blocks had no Windows form or
no title.
"""
from __future__ import annotations
import json
import re
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
sys.path.insert(0, str(REPO_ROOT))
import generate_readme # noqa: E402
import generate_site # noqa: E402
import release_record # noqa: E402
import siteguides # noqa: E402
import sitemarkup # noqa: E402
from common import load_platform_config, load_platform_registry # noqa: E402
from tests import sitepages # noqa: E402
REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
GUIDES_YAML = (REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8")
UNTITLED_FENCE = re.compile(r"^\s*```(?:sh|powershell)\s*$", re.M)
# A path a reader types: a network share, a home or root path, a drive letter.
ABSOLUTE_PATH = re.compile(
r"(?<![\w/@:}>])(?:\\\\[^\s,)]+|smb://[^\s,)]+|~/[^\s,)]*|/[A-Za-z][^\s,)]*|[A-Z]:\\[^\s,)]*)"
)
def _page(name: str) -> str:
sitepages.require()
return sitepages.page(name).read_text(encoding="utf-8")
def _body(text: str) -> str:
"""A page without its front matter."""
return text.split("\n---\n", 1)[1] if text.startswith("---\n") else text
def _guide_part(text: str) -> str:
match = re.search(r"^## Reference\b", text, re.M)
return text[: match.start()] if match else text
class TrustItemsSayWhereItWrites(unittest.TestCase):
def test_no_only_in_the_bios_folder_while_a_manifest_writes_saves(self):
saves = [
path.name for path in sorted((REPO_ROOT / "install").glob("*.json"))
if any(entry.get("root") == "save"
for entry in json.loads(path.read_text(encoding="utf-8")).get("files", []))
]
self.assertTrue(saves, "no manifest files an entry under root: save")
text = " ".join(siteguides.TRUST_ITEMS).lower()
self.assertNotIn("only in your bios folder", text)
self.assertIn("save folder", text)
self.assertIn("--restore", text)
def test_home_shows_the_same_items(self):
home = _page("index.md")
for item in siteguides.TRUST_ITEMS:
self.assertIn(f"- {item}", home)
self.assertNotIn("only in your BIOS folder", home)
class NextStepsIsAPlainList(unittest.TestCase):
def test_the_helpers(self):
block = "\n".join(generate_site._next_block(["[A](a.md)", "[B](b.md)"]))
self.assertEqual(block, "## Next steps\n\n- [A](a.md)\n- [B](b.md)\n")
self.assertIn("## Next steps", generate_site.reference_next())
self.assertNotIn(":::", generate_site.reference_next())
def test_entry_pages(self):
for name in ("which-pack.md", "contributing.md", "platforms/batocera.md",
"platforms/retroarch.md"):
with self.subTest(page=name):
text = _page(name)
self.assertNotIn(":::tip[Next]", text)
self.assertIn("## Next steps", text)
def test_home_has_no_next_box_and_no_stamp(self):
home = _page("index.md")
self.assertNotIn("Next steps", home)
self.assertNotIn(":::tip", home)
self.assertNotIn("Generated on", home)
self.assertNotRegex(home, r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
class DatesInWords(unittest.TestCase):
def test_data_as_of(self):
moment = datetime(2026, 10, 22, 22, 43, 8, tzinfo=timezone.utc)
self.assertEqual(
generate_site.data_as_of(moment),
'<time datetime="2026-10-22T22:43:08Z">Data as of 22 October 2026</time>',
)
def test_get_your_files_carries_no_date(self):
self.assertNotIn("Generated on", _page("which-pack.md"))
class OneSetupCount(unittest.TestCase):
def test_every_entry_page_states_the_registry_count(self):
count = len(REGISTRY)
self.assertEqual(siteguides.setup_count(), count)
self.assertIn(f"and {count - 3} more.", _page("index.md"))
self.assertIn(f'<span class="rb-stat-value">{count}</span>', _page("explore.md"))
self.assertIn(f"{count} supported platforms", _page("platforms/index.md"))
class SetupTiles(unittest.TestCase):
def test_one_tile_per_setup_on_home_and_get_your_files(self):
for name in ("index.md", "which-pack.md"):
text = _page(name)
with self.subTest(page=name):
self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY))
for key in REGISTRY:
self.assertIn(f'href="/retrobios/platforms/{key}/"', text)
def test_get_your_files_offers_the_choice_once(self):
text = _page("which-pack.md")
self.assertEqual(text.count('class="rb-card"'), 0)
self.assertEqual(text.count("<FilePicker"), 1)
def test_a_setup_without_a_logo_shows_its_initials(self):
self.assertEqual(sitemarkup.initials("MyMinUI"), "MM")
tile = "".join(sitemarkup.platform_tiles([("MyMinUI", "x.md", "", "Handhelds")]))
self.assertIn(">MM</text>", tile)
tile = "".join(sitemarkup.platform_tiles([("RomM", "x.md", "logo.png", "A server")]))
self.assertIn('<img class="rb-card-logo" src="logo.png"', tile)
def test_a_one_setup_group_is_not_titled_with_its_own_name(self):
home = _page("index.md")
display = {k: load_platform_config(k, str(REPO_ROOT / "platforms")).get("platform", k)
for k in REGISTRY}
for group, (label, _description) in siteguides.DEVICE_GROUPS.items():
members = [k for k in REGISTRY if REGISTRY[k].get("device_group") == group]
if len(members) == 1:
with self.subTest(group=group):
self.assertNotIn(f"**{label}**", home)
self.assertIn(f">{display[members[0]]}</strong>", home)
def test_hardware_is_named_and_cited(self):
self.assertIn("Raspberry Pi", siteguides.GUIDES["batocera"].summary)
self.assertIn("Raspberry Pi", siteguides.DEVICE_GROUPS["gaming-os"][0])
self.assertIn("Anbernic", siteguides.DEVICE_GROUPS["handheld"][0])
lines = GUIDES_YAML.splitlines()
for number, line in enumerate(lines):
if re.match(r" (runs_on|terminal):", line):
with self.subTest(line=line.strip()):
self.assertTrue(lines[number - 1].lstrip().startswith("#"),
"no source cited on the line above")
class GetYourFilesOrder(unittest.TestCase):
def test_choice_then_tables_then_picker_then_command(self):
text = _page("which-pack.md")
order = [text.index(mark) for mark in (
"## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "<FilePicker",
"## Not in the list? One command finds it", "Press **Enter** for everything",
"<summary>Is it safe?</summary>",
)]
self.assertEqual(order, sorted(order))
def test_a_setup_after_the_release_says_no_zip_yet(self):
text = _page("which-pack.md")
self.assertNotIn("Not in v", text)
record = release_record.load_record()
missing = [k for k in REGISTRY if not release_record.pack_for(k, record)]
if record.get("tag") and missing:
self.assertIn("No ZIP yet. [Use the command]", text)
self.assertIn("Setups added after the last release have no ZIP until the next one.",
text)
def test_zip_advice_answers_the_usual_mistakes(self):
text = _page("which-pack.md")
self.assertIn("Download every part", text)
self.assertIn("not in a new folder", text)
self.assertIn("`neogeo.zip`", text)
class GuideRoutes(unittest.TestCase):
def test_a_device_system_opens_on_the_computer_route(self):
text = _page("platforms/batocera.md")
self.assertLess(text.index("## From your computer"), text.index("## On the device"))
self.assertIn("{ #install }", "\n".join(generate_site._guide_install(
"batocera", siteguides.GUIDES["batocera"], "## On the device { #install }")))
self.assertIn("Unzip them all into the network folder above", text)
def test_a_computer_app_keeps_the_command_first(self):
text = _page("platforms/retroarch.md")
self.assertLess(text.index("## Install"), text.index("## Download"))
def test_each_device_tab_says_how_to_open_its_terminal(self):
shared = siteguides.TERMINALS["device"][1]
for name, guide in siteguides.GUIDES.items():
if "device" not in guide.run:
continue
with self.subTest(platform=name):
self.assertTrue(guide.terminal, "no terminal text of its own")
text = _guide_part(_page(f"platforms/{name}.md"))
self.assertIn(guide.terminal.split(".")[0], text)
self.assertNotIn(shared, text)
def test_retroarch_names_the_menu_before_the_folders(self):
text = _page("platforms/retroarch.md")
self.assertLess(text.index("Settings > Directory > System/BIOS"),
text.index("| Where | BIOS folder |"))
def test_emudeck_names_the_card_and_ends_the_job(self):
text = _guide_part(_page("platforms/emudeck.md"))
self.assertIn("installed on the SD card", text)
self.assertIn("Return to Gaming Mode", text)
self.assertNotIn("it is on the card", text)
class PackManualReadsThePageFolders(unittest.TestCase):
def test_every_path_in_a_pack_manual_is_a_folder_of_its_page(self):
for name, guide in siteguides.GUIDES.items():
if not guide.pack_manual:
continue
known = " ".join(path.replace("`", "") for _w, path in siteguides.folder_list(name))
known += " " + str(REGISTRY[name].get("extract_to") or "")
for path in ABSOLUTE_PATH.findall(siteguides.pack_manual(name)):
with self.subTest(platform=name, path=path):
self.assertIn(path.rstrip(".:"), known)
def test_the_retroarch_manual_lists_the_detected_systems(self):
manual = siteguides.pack_manual("retroarch")
self.assertNotIn("{folders}", manual)
self.assertIn("/run/muos/storage/bios", manual)
self.assertIn("~/.var/app/org.libretro.RetroArch/config/retroarch/system/", manual)
class DownloadNamesTheRelease(unittest.TestCase):
def test_tag_date_checksums_and_signature(self):
record = release_record.load_record()
if not release_record.pack_for("batocera", record):
self.skipTest("release.json has no Batocera pack")
tag = record["tag"]
lines = generate_site.release_line("batocera", record)
self.assertTrue(lines[0].startswith(f"Pack {tag}, built "))
self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt)", lines[0])
self.assertIn(f"/releases/download/{tag}/SHA256SUMS.txt.sig)", lines[0])
self.assertEqual(generate_site._release_date("v2026.10.10"), "10 October 2026")
def test_a_newer_platform_than_the_pack_is_named(self):
record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
"assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
version = load_platform_config("retrobat", str(REPO_ROOT / "platforms"))["version"]
lines = generate_site.release_line("retrobat", record)
self.assertIn(f"built for RetroBat 1.0. The command installs the files for "
f"RetroBat {version}.", lines[-1])
class CommandBlocks(unittest.TestCase):
def test_every_command_on_an_entry_page_has_a_title(self):
names = ["index.md", "which-pack.md", "contributing.md"] + [
f"platforms/{key}.md" for key in REGISTRY]
for name in names:
with self.subTest(page=name):
self.assertIsNone(UNTITLED_FENCE.search(_guide_part(_page(name))))
def test_shell_lines_have_a_powershell_form(self):
text = _page("which-pack.md")
groups = re.findall(r"<Tabs.*?</Tabs>|<details.*?</details>", text, re.S)
commands = [group for group in groups if "install.sh | sh" in group]
self.assertGreaterEqual(len(commands), 3)
for group in commands:
self.assertIn("install.ps1", group)
block = "\n".join(generate_site._console_install_block(
"PlayStation", [("batocera", "psx"), ("recalbox", "psx")]))
self.assertEqual(block.count("install.sh | sh"), block.count("install.ps1))) "))
self.assertIn('```powershell title="PowerShell"', block)
for key, guide in siteguides.GUIDES.items():
if "windows" in guide.run:
with self.subTest(platform=key):
self.assertIn("install.ps1", _guide_part(_page(f"platforms/{key}.md")))
class ContributingOpensOnTheAction(unittest.TestCase):
def test_both_generators_open_on_the_upload(self):
for text in (generate_readme.generate_contributing(), generate_site.generate_contributing()):
body = text.split("## Add a BIOS file", 1)[1]
self.assertTrue(body.lstrip().startswith("Have a file the collection lacks?"))
self.assertLess(body.index(generate_readme.UPLOAD_URL), body.index("## What happens next"))
self.assertLess(body.index("gaps"), body.index("## What happens next"))
self.assertLess(body.index("## What happens next"), body.index("CI hashes"))
def test_description_is_a_sentence(self):
text = generate_site._page_description("", Path("contributing.md"), "Contributing")
self.assertTrue(text.endswith("."))
fallback = generate_site._page_description(
"One file needs no clone and no local setup at all here:\n\n"
"A plain sentence that describes the page well enough.",
Path("other.md"), "Other")
self.assertEqual(fallback, "A plain sentence that describes the page well enough.")
class HomeLinksTheOtherTasks(unittest.TestCase):
def test_troubleshooting_file_check_and_legal(self):
home = _body(_page("index.md"))
for target in ("/retrobios/wiki/troubleshooting/", "/retrobios/files/",
"/retrobios/wiki/legal/"):
self.assertIn(f"]({target})", home)
self.assertLess(home.index("/retrobios/wiki/troubleshooting/"), home.index("## Do I need it?"))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards.
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_check_freshness`, `test_dedup`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture