mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: rework entry pages and setup guides
# Conflicts: # wiki/testing-guide.md
This commit is contained in:
commit
38983a2a4a
13 files changed
+914
-268
No files matched your search
+16
-12
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
"",
|
||||
|
||||
@@ -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"'), "",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user