diff --git a/platforms/_guides.yml b/platforms/_guides.yml new file mode 100644 index 00000000..1b109bcf --- /dev/null +++ b/platforms/_guides.yml @@ -0,0 +1,541 @@ +# What each platform page and pack README tell someone holding the device: +# the folder, where the command runs, what to know. Read by +# scripts/siteguides.py, which checks it on load: every platform in +# _registry.yml has a guide here, and every guide a registered platform. +# +# Values are Markdown. A folder row with no `path` is the registry's +# `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. +# `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. +# +# The registry keeps what tools read (device_group, extract_to, install); +# this file keeps what people read. + +install_base: https://raw.githubusercontent.com/Abdess/retrobios/main + +# Is it safe: shown under every command on a guide page. +trust_items: +- It checks each file before saving it. +- It writes only in your BIOS folder. +- It never touches games or saves. +- It never moves or deletes files it does not know. +- >- + It keeps any file it replaces in a `retrobios-backup` folder, beside your BIOS folder. + The same command with `--restore` puts it back. + +# 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 +# had before it was grouped by device, which links outside the site may use. +# `also_runs` are setups that are not platforms of their own: they run one, +# and use its pack. +device_groups: + computer: + label: Computers and Steam Deck + description: An emulator app on a computer or a Steam Deck. + anchor: computers-and-steam-deck + also_runs: + - setup: LaunchBox + summary: >- + A game library and launcher. It starts RetroArch or standalone emulators. + uses: retroarch + 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. + anchor: game-systems + handheld: + label: Handhelds and TV boxes + description: The system your handheld or TV box starts. + anchor: handhelds + also_runs: + - setup: Android (Retroid Pocket, AYN Odin and others) + summary: RetroArch for Android. + uses: retroarch + runs_on: Android + folder: '`RetroArch/system/` on the internal storage' + fpga: + label: MiSTer FPGA + description: >- + A board that recreates old consoles in hardware, not in software. + anchor: fpga + server: + label: Self-hosted game library + description: A RomM server. It plays games in your web browser. + anchor: self-hosted + +# Ways to run the installer: the tab label, how to open a terminal there, and +# the shell the command is written for. +terminals: + windows: + label: Windows + how: >- + Open the Start menu. Type **PowerShell** and open it. Paste the line. Press Enter. + shell: powershell + macos: + label: macOS + how: >- + Open **Terminal**, in Applications, then Utilities. Paste the line. Press Return. + shell: sh + linux: + label: Linux + how: Open a terminal. Paste the line. Press Enter. + shell: sh + steamdeck: + label: Steam Deck + how: >- + Switch to Desktop Mode. Open **Konsole**. Paste with Ctrl+Shift+V. Press Enter. + shell: sh + android: + label: Android (Termux) + how: >- + Install **Termux**, a terminal app. Run `pkg install python` and `termux-setup-storage` + once. Then paste the line. Press Enter. + shell: sh + device: + label: On the device + how: >- + Open a terminal on the device. Or connect from a computer with SSH, a terminal over + the network. Paste the line. Press Enter. + shell: sh + server: + label: On the server + how: >- + Open a terminal on the machine that holds your RomM library. Put the path of its + `bios` folder after `--dest`. + shell: sh + +guides: + retroarch: + summary: >- + An app that runs many consoles. It uses a different **core**, a small emulator, + for each one. + folders: + - where: Windows + path: '`system\` next to `retroarch.exe`' + - where: macOS + path: '`~/Documents/RetroArch/system/`' + - where: Linux + path: '`~/.config/retroarch/system/`' + - where: Steam Deck and Linux Flatpak + path: '`~/.var/app/org.libretro.RetroArch/config/retroarch/system/`' + - where: Android + path: '`RetroArch/system/` on the internal storage' + - where: Nintendo Switch + path: '`retroarch/cores/system/` on the SD card' + more_folders: + - where: Windows, Microsoft Store build + path: '`%LOCALAPPDATA%\Packages\1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_*\LocalState\system\`' + - where: macOS, Steam or portable build + path: '`~/Library/Application Support/RetroArch/system/`' + more_folders_title: 'Other builds: Microsoft Store, Mac Steam, handhelds' + # Adds a row for each named system the registry detects this platform on. + more_folders_detected: true + more_folders_note: LaunchBox and other launchers use the folder RetroArch reads. + run: [windows, macos, linux, steamdeck, android] + notes: + - >- + **Not sure?** In RetroArch, open **Settings > Directory > System/BIOS**. It shows + the folder. + reference_notes: + - >- + RetroArch reads its BIOS folder from the `system_directory` line of `retroarch.cfg`, + the value **Settings > Directory > System/BIOS** shows. + - >- + On Windows, `retroarch.cfg` is read next to `retroarch.exe` first, then from `%APPDATA%\RetroArch\`. + A `system_directory` left at its default is the `system` folder next to `retroarch.exe` + (`:\system` in RetroArch's `frontend/drivers/platform_win32.c`, where `:` stands + for the folder of the program). + pack_manual: | + Option B: Manual (PC) + --------------------- + 1. Find your RetroArch system directory: + - RetroArch > Settings > Directory > System/BIOS + - Default: retroarch/system/ + 2. Extract all files from this archive directly into your system directory + 3. Overwrite if asked + + 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/) + 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/ + logo_owner: libretro + logo_licence: repository licence GPL-3.0 + batocera: + summary: A system that starts straight into a game menu. + folders: + - where: On Batocera + - where: From Windows, over your home network + path: '`\\BATOCERA\share\bios\`' + - where: From a Mac or Linux, over your home network + path: '`smb://batocera/share/bios/`' + run: [device] + download_hint: From a computer, use the network folder above. + pack_manual: | + The terminal is F1 from the Batocera menu, or SSH. + + Option B: Manual (network share) + -------------------------------- + 1. On your PC, open the Batocera network share: + - Windows: \\BATOCERA\share\bios\ + - Mac/Linux: smb://batocera/share/bios/ + 2. Extract all files from this archive directly into the share + 3. Overwrite if asked + + Option C: Manual (SD card) + -------------------------- + 1. Put the SD card in your PC + 2. Navigate to /userdata/bios/ on the SHARE partition + 3. Extract all files from this archive directly into that folder + + NOTE: Dreamcast flash memory is named dc_nvmem.bin + (if your setup asks for dc_flash.bin, same file). + logo_owner: Batocera + logo_licence: repository licence MIT + recalbox: + summary: >- + A system that starts straight into a game menu and sets up its emulators. + folders: + - where: On Recalbox + - where: From Windows, over your home network + path: '`\\RECALBOX\share\bios\`' + run: [device] + download_hint: From a computer, use the network folder above. + pack_manual: | + Option B: Manual (network share) + -------------------------------- + 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) + -------------------------- + 1. Put the SD card in your PC + 2. Navigate to /recalbox/share/bios/ + 3. Extract all files from this archive directly into that folder + logo_owner: Recalbox + logo_licence: file from homarr-labs/dashboard-icons, Apache-2.0 + retrobat: + summary: >- + A game menu for Windows. It comes with RetroArch and other emulators. + folders: + - where: Inside the RetroBat folder + path: '`bios\`' + - where: Default install + path: '`C:\RetroBat\bios\`' + run: [windows] + notes: + - The command finds RetroBat wherever you installed it. + reference_notes: + - >- + RetroBat records its own folder each time it starts, and the installer reads it + there. + pack_manual: | + Option B: Manual + ---------------- + 1. Open your RetroBat installation folder + 2. Navigate to the bios\ subfolder + (default: C:\RetroBat\bios\) + 3. Extract all files from this archive directly into your bios\ folder + 4. Overwrite if asked + logo_owner: RetroBat + logo_licence: repository states no licence + emudeck: + summary: Installs your emulators and adds your games to Steam. + folders: + - where: Steam Deck and Linux + - 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. + reference_notes: + - >- + The installer reads the Emulation folder from EmuDeck's settings (`emulationPath` + in `~/.config/EmuDeck/settings.sh`). + pack_manual: | + Option B: Manual + ---------------- + Extract all files from this archive directly into EmuDeck's bios + folder, inside the Emulation folder chosen in EmuDeck: + Steam Deck, Linux and macOS: ~/Emulation/bios/ by default + Windows: C:\Emulation\bios\ by default + logo_owner: EmuDeck + logo_licence: repository licence GPL-3.0 + lakka: + summary: A system that starts straight into RetroArch. + folders: + - where: On Lakka + run: [device] + notes: + - >- + You can also reach the folder from a computer, over your home network. + reference_notes: + - The folder is reachable over SSH and over the network share. + pack_manual: | + Lakka reads its BIOS files from /storage/system/. + + Option B: Via network share + -------------------------- + 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 + ----------------- + 1. On Lakka: Settings > Services > SSH Enable + 2. Copy the files to /storage/system/ (scp -r * root@lakka:/storage/system/) + logo_owner: Lakka (libretro) + logo_licence: file from libretro/retroarch-assets, CC-BY-4.0 + retrodeck: + summary: >- + One app with its emulators inside. Install it from Flathub or Discover. + folders: + - where: Steam Deck and Linux + path: '`~/retrodeck/bios/`' + - where: Installed on a microSD card + path: 'for example `/run/media/mmcblk0p1/retrodeck/bios/`' + run: [steamdeck, linux] + pack_manual: | + Option B: Manual + ---------------- + 1. Open Dolphin file manager + 2. Show hidden files (Ctrl+H) + 3. Navigate to ~/retrodeck/ + 4. Extract all files from this archive directly into ~/retrodeck/ + (the archive already holds the bios/ and roms/ folders) + + NOTE: RetroDECK uses its own BIOS checker. After + copying, open RetroDECK > Tools > BIOS Checker to + verify everything is detected. + logo_owner: RetroDECK + logo_licence: repository licence GPL-3.0 + romm: + summary: >- + A game library on your own server. It plays games in the web browser. + folders: + - where: In the RomM library + path: '`bios/`, one subfolder per platform' + more_folders: + - where: 'With `firmware: "{platform}/bios"` in `config.yml`' + path: '`/bios/` for each platform' + run: [server] + notes: + - 'You can also add BIOS files in RomM''s web page.' + dest: /path/to/library/bios + # RomM 5.4 places firmware by filesystem.structure.firmware in config.yml, + # bios/{platform} by default, and suggests {platform}/bios for a library + # laid out as {platform}/roms (rommapp/romm 68f71259, + # backend/config/config_manager.py:58,101-109,1105-1124). + pack_manual: | + Option B: Manual + ---------------- + The archive holds one folder per RomM platform (psx/, gba/, ...). + Where they go depends on filesystem.structure.firmware in RomM's + config.yml, read relative to the library folder: + + - Not set, or "bios/{platform}" (the default): extract all files + from this archive directly into library/bios/ + - "{platform}/bios" (the layout RomM suggests for a library kept as + {platform}/roms): each folder goes under its platform, psx/ into + library/psx/bios/, gba/ into library/gba/bios/, and so on. + The installer reads config.yml and does this itself. Run from + the host of a container, --romm-firmware "{platform}/bios" says it. + + Then restart the RomM service to detect new files. + logo_owner: RomM + logo_licence: GitHub organisation avatar of rommapp + rocknix: + summary: A system for handhelds. + folders: + - where: On ROCKNIX + run: [device] + notes: + - >- + You can also reach the folder from a computer, over your home network. + reference_notes: + - >- + ROCKNIX runs on ARM and x86 handhelds, among them the RG35XX (H700) and the RG552 + (RK3399). The folder is reachable over SSH and over the network share. + logo_owner: ROCKNIX + logo_licence: GitHub organisation avatar of ROCKNIX + amberelec: + summary: A system for Anbernic handhelds. + folders: + - where: On AmberELEC + run: [device] + reference_notes: + - >- + AmberELEC runs on the RG351P, RG351V, RG351MP, RG353, RG503 and RG552. Its list + names files under `/storage/roms`: `bios/` for most, `neogeo/` for the rest. The + pack holds those folders. + logo_owner: AmberELEC + logo_licence: GitHub organisation avatar of AmberELEC + emuelec: + summary: A system for TV boxes and some handhelds. + folders: + - where: On EmuELEC + run: [device] + reference_notes: + - >- + EmuELEC runs on Amlogic TV boxes, the Odroid Go Advance, GameForce and RK356x devices. + Its list names files under `/storage/roms`: `bios/` for most, `neogeo/` and `ports/` + for the rest. The pack holds those folders. + logo_owner: EmuELEC + logo_licence: GitHub organisation avatar of EmuELEC + knulli: + summary: A system for handhelds, based on Batocera. + folders: + - where: On KNULLI + run: [device] + reference_notes: + - >- + KNULLI runs on handhelds among them the RG35XX family (H700) and the TrimUI Smart + Pro. + logo_owner: KNULLI + logo_licence: GitHub organisation avatar of knulli-cfw + retropie: + summary: Installs RetroArch and other emulators on a Raspberry Pi. + folders: + - where: On the Raspberry Pi + run: [device] + notes: + - >- + Use the RetroPie pack, not the RetroArch one. On RetroPie 4.8, Python is too old + for the command: use the pack there. + reference_notes: + - >- + RetroPie also installs standalone emulators (openMSX, PCSX2, XRoar, Amiberry and + others) whose files only the RetroPie pack carries. + - >- + RetroPie is archived in this project: its configuration is kept and its pack is + still built, but its upstream list is no longer read again on a schedule. + pack_manual: | + Option B: Via network share + -------------------------- + 1. On your PC, open: \\RETROPIE\bios\ + 2. Extract all files from this archive directly into that folder + + Option C: Via SSH + ----------------- + 1. SSH into your Pi: ssh pi@retropie + 2. Copy files to ~/RetroPie/BIOS/ + + NOTE: the BIOS folder is on the card's Linux partition, + which Windows and macOS cannot open: copying through the + card works from a Linux computer only + (/home/pi/RetroPie/BIOS/). + logo_owner: RetroPie + logo_licence: GitHub organisation avatar of RetroPie + bizhawk: + summary: An emulator app for many consoles, from TASVideos. + folders: + - where: Inside the BizHawk folder + path: '`Firmware\`' + - where: Default place the installer looks + path: '`%USERPROFILE%\BizHawk\Firmware\`' + run: [windows, linux] + reference_notes: + - BizHawk carries rerecording and debugging tools. + pack_manual: | + Option B: Manual + ---------------- + 1. Open the folder you extracted BizHawk to, the one holding + EmuHawk.exe (Windows) or EmuHawkMono.sh (Linux) + 2. Navigate to its Firmware subfolder + 3. Extract all files from this archive directly into your Firmware folder + 4. In BizHawk: Config > Paths > Firmware should + point to this folder + logo_owner: TASVideos + logo_licence: GitHub organisation avatar of TASEmulators + misterfpga: + summary: >- + A board that recreates old consoles in hardware, not in software. + folders: + - where: On the MiSTer SD card + path: '`/media/fat/games/`, one subfolder per core' + run: [device] + notes: + - >- + Each file goes in its console's folder, like `/media/fat/games/3DO/boot.rom`. + reference_notes: + - >- + MiSTer runs FPGA cores on a DE10-Nano board. Only the files the MiSTer BIOS database + lists with a download address are in the pack. The others come with the MiSTer distribution + and install themselves. + 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. + folders: + - where: On the SD card + path: '`Bios//`, one folder per console' + run: [macos, linux, windows] + notes: + - >- + Each console has its own folder, named after the tag in its Roms folder. `Roms/Sony + PlayStation (PS)/` uses `Bios/PS/`. + on_card: true + reference_notes: + - >- + MinUI gives each console its own folder, named after the tag in parentheses of the + Roms folder the game sits in. A game in `Roms/Game Boy (GB)/` runs with `Bios/GB/` + as its BIOS folder (MinUI `workspace/all/minarch/minarch.c:2943`). + - >- + Without `--platform`, the installer reads the release name MinUI writes in `.system/version.txt` + on the card. A console started by a standalone program instead of a libretro core + (DraStic for NDS, native PICO-8) is not covered. + 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. + folders: + - where: On the SD card + path: '`Bios//`, one folder per console' + run: [macos, linux, windows] + notes: + - >- + Each console has its own folder, named after the tag in its Roms folder. `Roms/Sony + PlayStation (PS)/` uses `Bios/PS/`. + on_card: true + reference_notes: + - >- + NextUI gives each console its own folder, named after the tag in parentheses of + the Roms folder the game sits in (NextUI `workspace/all/minarch/ma_core.c:76`). + 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. + folders: + - where: On the SD card + path: '`Bios//`, one folder per console' + run: [macos, linux, windows] + notes: + - >- + Each console has its own folder, named after the tag in its Roms folder. `Roms/Sony + PlayStation (PS)/` uses `Bios/PS/`. + on_card: true + reference_notes: + - >- + MyMinUI gives each console its own folder, named after the tag in parentheses of + the Roms folder the game sits in (MyMinUI `workspace/all/minarch/minarch.c:3532`). diff --git a/scripts/fetch_site_images.py b/scripts/fetch_site_images.py index 2cb4a8b9..49ae239b 100644 --- a/scripts/fetch_site_images.py +++ b/scripts/fetch_site_images.py @@ -41,30 +41,6 @@ ICON_SIZE = 48 AVATAR_HOST = "avatars.githubusercontent.com" USER_AGENT = "retrobios-site-images" -# Who owns each mark. Logos are shown to name the platform they belong to; -# the note says so, and gives the licence of the repository the file was -# taken from when that repository states one. -OWNERS = { - "retroarch": ("libretro", "repository licence GPL-3.0"), - "lakka": ("Lakka (libretro)", "file from libretro/retroarch-assets, CC-BY-4.0"), - "batocera": ("Batocera", "repository licence MIT"), - "recalbox": ("Recalbox", "file from homarr-labs/dashboard-icons, Apache-2.0"), - "retrobat": ("RetroBat", "repository states no licence"), - "emudeck": ("EmuDeck", "repository licence GPL-3.0"), - "retrodeck": ("RetroDECK", "repository licence GPL-3.0"), - "romm": ("RomM", "GitHub organisation avatar of rommapp"), - "rocknix": ("ROCKNIX", "GitHub organisation avatar of ROCKNIX"), - "amberelec": ("AmberELEC", "GitHub organisation avatar of AmberELEC"), - "emuelec": ("EmuELEC", "GitHub organisation avatar of EmuELEC"), - "knulli": ("KNULLI", "GitHub organisation avatar of knulli-cfw"), - "retropie": ("RetroPie", "GitHub organisation avatar of RetroPie"), - "bizhawk": ("TASVideos", "GitHub organisation avatar of TASEmulators"), - "misterfpga": ("MiSTer FPGA", "GitHub organisation avatar of MiSTer-devel"), - "minui": ("Shaun Inman", "GitHub avatar of shauninman, the author of MinUI"), - "nextui": ("LoveRetro", "GitHub organisation avatar of LoveRetro"), -} - - def fetch(url: str) -> bytes: request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(request, timeout=60) as response: @@ -107,6 +83,8 @@ def write_manifest(directory: Path, note: str, entries: list[dict]) -> None: def fetch_logos() -> None: + from siteguides import GUIDES + with open(ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle: platforms = yaml.safe_load(handle)["platforms"] LOGO_DIR.mkdir(parents=True, exist_ok=True) @@ -125,7 +103,10 @@ def fetch_logos() -> None: if stale.name != file_name: stale.unlink() (LOGO_DIR / file_name).write_bytes(payload) - owner, licence = OWNERS.get(name, (name, "ownership not recorded")) + # Who owns each mark: logo_owner and logo_licence in the guides. + guide = GUIDES.get(name) + owner = (guide and guide.logo_owner) or name + licence = (guide and guide.logo_licence) or "ownership not recorded" entries.append({ "file": file_name, "platform": name, "source": url, "owner": owner, "licence": licence, diff --git a/scripts/generate_site.py b/scripts/generate_site.py index b0c305fc..45b29724 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -75,7 +75,6 @@ from generate_readme import ( from provenance_report import build_report, distinct_missing from siteexports import generate_exports_page, load_export_reports from siteguides import ( - Guide, ALSO_RUNS, DEVICE_GROUPS, GROUP_ANCHORS, @@ -2339,22 +2338,15 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: lines += [note, ""] lines += [line for note in extract_notes("platforms") if f"The {display} pack " in note for line in (note, "")] - if name == "retroarch": + if guide.more_folders: + detected = _detected_systems(registry, name) if guide.more_folders_detected else [] lines += [ - '??? note "Other builds: Microsoft Store, Mac Steam, handhelds"', - "", - *_folder_rows(guide.more_folders, " "), - *(f" | {system} | `{folder}` |" for system, folder in _retroarch_handhelds(registry)), - "", - " LaunchBox and other launchers use the folder RetroArch reads.", - "", - ] - elif guide.more_folders: - lines += [ - '??? note "Other layouts"', + f'??? note "{guide.more_folders_title}"', "", *_folder_rows(guide.more_folders, " "), + *(f" | {system} | `{folder}` |" for system, folder in detected), "", + *([f" {guide.more_folders_note}", ""] if guide.more_folders_note else []), ] lines += [ *(["", ""] if guide.on_card else []), @@ -6172,11 +6164,12 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str: return f"{links} ({_fmt_size(size)})" if size else links -def _retroarch_handhelds(registry: dict) -> list[tuple[str, str]]: - """Handheld systems that run RetroArch, with the BIOS folder the installer - writes on each, read from the detection rules of the registry.""" +def _detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: + """Named systems a platform runs on, handhelds that run RetroArch for one, + with the BIOS folder the installer writes on each, read from the + detection rules of the registry.""" seen: dict[str, str] = {} - for rule in (registry.get("retroarch", {}).get("install") or {}).get("detect") or []: + for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: name, folder = rule.get("name"), rule.get("bios_path") if name and folder and rule.get("installs", True) and name not in seen: seen[name] = folder @@ -6310,7 +6303,7 @@ def generate_which_pack() -> str: " | System | Runs | BIOS folder |", " |--------|------|-------------|", *(f" | {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |" - for name, folder in _retroarch_handhelds(registry)), + for name, folder in _detected_systems(registry, "retroarch")), "", ] lines += [ @@ -6415,11 +6408,75 @@ def _platform_nav(coverages: dict, registry: dict) -> list: return nav +# The tabs in reading order, by the level their pages declare. +TABS = { + "start": "Start here", + "setup": "Your setup", + "explore": "Explore", + "reference": "Reference", + "contribute": "Contribute", +} + + +class NavError(ValueError): + """A wiki page does not say where it goes in the navigation.""" + + +def _h1(markdown: str) -> str: + match = re.search(r"^# (.+?)(?:\s*\{[^}]*\})?\s*$", markdown, re.M) + return match.group(1).strip() if match else "" + + +def wiki_nav(wiki_dir: Path | None = None) -> dict[str, list[tuple[int, object]]]: + """Wiki pages by tab, each at the place its front matter gives. + + A page states `level:` (its tab), `order:` (its place in the tab, the + smallest first) and, when the navigation names it otherwise than its + first heading, `nav_title:`. `nav: false` keeps a page out on purpose. + A page that says neither raises, naming what to add. + """ + wiki_dir = Path(wiki_dir) if wiki_dir else _REPO_ROOT / WIKI_SRC_DIR + tabs: dict[str, list[tuple[int, object]]] = {level: [] for level in TABS} + for page in sorted(wiki_dir.glob("*.md")): + where = f"{WIKI_SRC_DIR}/{page.name}" + text = page.read_text(encoding="utf-8") + meta = _front_matter(text) + level = meta.get("level") + if level not in TABS: + raise NavError( + f"{where}: front matter needs `level:`, one of {', '.join(TABS)}, " + f"to place the page in a tab (found {level!r})" + ) + if meta.get("nav") is False: + continue + order = meta.get("order") + if not isinstance(order, int) or isinstance(order, bool): + raise NavError( + f"{where}: front matter needs `order:`, a whole number giving its " + f"place in the {TABS[level]} tab, smallest first (found {order!r}), " + "or `nav: false` to keep it out of the navigation" + ) + path = f"{WIKI_SRC_DIR}/{page.name}" + if page.name == "index.md": + tabs[level].append((order, path)) + continue + title = meta.get("nav_title") or _h1(text) + if not title: + raise NavError(f"{where}: no `# ` heading and no `nav_title:` to name it") + tabs[level].append((order, {str(title): path})) + return tabs + + +def _nav_target(entry: object) -> str: + return entry if isinstance(entry, str) else str(next(iter(entry.values()))) + + def generate_mkdocs_nav( coverages: dict, manufacturers: dict, profiles: dict, registry: dict | None = None, + wiki_dir: Path | None = None, ) -> list: """The navigation, five tabs in reading order. @@ -6427,51 +6484,42 @@ def generate_mkdocs_nav( who knows their device, Explore for one system or emulator, Reference for checking the work, Contribute for adding to it. Each tab opens on its index page. + + The generated pages hold the places below. A wiki page takes its own + place from its front matter (wiki_nav), so a new page needs no edit here. """ - return [ - {"Start here": [ - "index.md", - {"What BIOS files are": "wiki/what-are-bios-files.md"}, - {"Get your files": "which-pack.md"}, - {"First install, step by step": "wiki/first-install.md"}, - {"When a game does not start": "wiki/troubleshooting.md"}, - {"Common questions": "wiki/faq.md"}, - {"Glossary": "wiki/glossary.md"}, - ]}, - {"Your setup": _platform_nav(coverages, registry or {})}, - {"Explore": [ - "explore.md", - {"Systems": _system_nav(manufacturers)}, - {"Emulators": _emulator_nav(profiles)}, - {"All installer options": "wiki/installer.md"}, - {"Is it legal?": "wiki/legal.md"}, - ]}, - {"Reference": [ - "wiki/index.md", - {"How each platform checks files": "wiki/verification-modes.md"}, - {"What the packs do not hold": "gaps.md"}, - {"Cores by platform": "cross-reference.md"}, - {"Dump provenance": "provenance.md"}, - {"Updated platform files": "exports.md"}, - {"Data and API": "data.md"}, - {"Find a file": "files/index.md"}, - {"Data model": "wiki/data-model.md"}, - {"Architecture": "wiki/architecture.md"}, - {"Build your own packs": "wiki/advanced-usage.md"}, - {"Command-line tools": "wiki/tools.md"}, - {"Technical questions": "wiki/technical-faq.md"}, - {"Project terms": "wiki/reference-terms.md"}, - {"Projects using this data": "wiki/community-tools.md"}, - ]}, - {"Contribute": [ - "contributing.md", - {"Profiling an emulator": "wiki/profiling.md"}, - {"Adding a platform": "wiki/adding-a-platform.md"}, - {"Adding a scraper": "wiki/adding-a-scraper.md"}, - {"Testing": "wiki/testing-guide.md"}, - {"Release process": "wiki/release-process.md"}, - ]}, - ] + generated: dict[str, list[tuple[int, object]]] = { + "start": [(0, "index.md"), (20, {"Get your files": "which-pack.md"})], + "setup": [(10 * i, item) + for i, item in enumerate(_platform_nav(coverages, registry or {}))], + "explore": [ + (0, "explore.md"), + (10, {"Systems": _system_nav(manufacturers)}), + (20, {"Emulators": _emulator_nav(profiles)}), + ], + "reference": [ + (20, {"What the packs do not hold": "gaps.md"}), + (30, {"Cores by platform": "cross-reference.md"}), + (40, {"Dump provenance": "provenance.md"}), + (50, {"Updated platform files": "exports.md"}), + (60, {"Data and API": "data.md"}), + (70, {"Find a file": "files/index.md"}), + (80, {"Data model": "wiki/data-model.md"}), + ], + "contribute": [(0, "contributing.md")], + } + wiki = wiki_nav(wiki_dir) + nav = [] + for level, label in TABS.items(): + entries = sorted(generated[level] + wiki[level], key=lambda item: item[0]) + for (first, a), (second, b) in zip(entries, entries[1:]): + if first == second: + raise NavError( + f"{label} tab: {_nav_target(a)} and {_nav_target(b)} both take " + f"order {first}; give one of them a free number" + ) + nav.append({label: [entry for _order, entry in entries]}) + return nav # Main @@ -6785,7 +6833,8 @@ def _generate(args: argparse.Namespace) -> None: # Update mkdocs.yml nav section only (avoid yaml.dump round-trip mangling quotes) print("Updating mkdocs.yml nav...") - nav = generate_mkdocs_nav(coverages, manufacturers, profiles, registry) + nav = generate_mkdocs_nav(coverages, manufacturers, profiles, registry, + Path(WIKI_SRC_DIR)) nav_yaml = yaml.dump( {"nav": nav}, default_flow_style=False, sort_keys=False, allow_unicode=True ) diff --git a/scripts/packreadme.py b/scripts/packreadme.py index 3b8bccc8..bcb8c05d 100644 --- a/scripts/packreadme.py +++ b/scripts/packreadme.py @@ -5,8 +5,10 @@ goes, and which ones the user has to rename.""" from __future__ import annotations -INSTALL_SH = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh" -INSTALL_PS1 = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1" +from siteguides import INSTALL_BASE, pack_manual + +INSTALL_SH = f"{INSTALL_BASE}/install.sh" +INSTALL_PS1 = f"{INSTALL_BASE}/install.ps1" def _detect_systems(detect: list[dict]) -> list[str]: """The operating systems the registry's detection rules cover, in order.""" @@ -114,147 +116,6 @@ def _generic_manual(bios_paths: list[str]) -> str: ) -# What a manual install needs on each platform: its shares, cards and folders. -MANUAL_GUIDES: dict[str, str] = { - "retroarch": ( - " Option B: Manual (PC)\n" - " ---------------------\n" - " 1. Find your RetroArch system directory:\n" - " - RetroArch > Settings > Directory > System/BIOS\n" - " - Default: retroarch/system/\n" - " 2. Extract all files from this archive directly into your system directory\n" - " 3. Overwrite if asked\n\n" - " Option C: Manual (handheld / SD card)\n" - " -------------------------------------\n" - " Anbernic, Retroid, Miyoo, Trimui, etc.:\n" - " 1. Connect your SD card to your PC\n" - " 2. Find the BIOS folder (usually BIOS/ or system/)\n" - " 3. Extract all files from this archive directly into that folder\n" - " 4. Eject SD card and reboot your device\n\n" - " Common paths by device:\n" - " Anbernic (ArkOS/JELOS): BIOS/\n" - " Retroid (RetroArch): RetroArch/system/\n" - " Miyoo Mini (Onion OS): BIOS/\n" - " Steam Deck (RetroArch): ~/.config/retroarch/system/\n\n" - ), - "batocera": ( - " The terminal is F1 from the Batocera menu, or SSH.\n\n" - " Option B: Manual (network share)\n" - " --------------------------------\n" - " 1. On your PC, open the Batocera network share:\n" - " - Windows: \\\\BATOCERA\\share\\bios\\\n" - " - Mac/Linux: smb://batocera/share/bios/\n" - " 2. Extract all files from this archive directly into the share\n" - " 3. Overwrite if asked\n\n" - " Option C: Manual (SD card)\n" - " --------------------------\n" - " 1. Put the SD card in your PC\n" - " 2. Navigate to /userdata/bios/ on the SHARE partition\n" - " 3. Extract all files from this archive directly into that folder\n\n" - " NOTE: Dreamcast flash memory is named dc_nvmem.bin\n" - " (if your setup asks for dc_flash.bin, same file).\n\n" - ), - "recalbox": ( - " Option B: Manual (network share)\n" - " --------------------------------\n" - " 1. On your PC, open the Recalbox network share:\n" - " - Windows: \\\\RECALBOX\\share\\bios\\\n" - " - Mac/Linux: smb://recalbox/share/bios/\n" - " 2. Extract all files from this archive directly into the share\n\n" - " Option C: Manual (SD card)\n" - " --------------------------\n" - " 1. Put the SD card in your PC\n" - " 2. Navigate to /recalbox/share/bios/\n" - " 3. Extract all files from this archive directly into that folder\n\n" - ), - "emudeck": ( - " Option B: Manual\n" - " ----------------\n" - " Extract all files from this archive directly into EmuDeck's bios\n" - " folder, inside the Emulation folder chosen in EmuDeck:\n" - " Steam Deck, Linux and macOS: ~/Emulation/bios/ by default\n" - " Windows: C:\\Emulation\\bios\\ by default\n\n" - ), - "retrodeck": ( - " Option B: Manual\n" - " ----------------\n" - " 1. Open Dolphin file manager\n" - " 2. Show hidden files (Ctrl+H)\n" - " 3. Navigate to ~/retrodeck/\n" - " 4. Extract all files from this archive directly into ~/retrodeck/\n" - " (the archive already holds the bios/ and roms/ folders)\n\n" - " NOTE: RetroDECK uses its own BIOS checker. After\n" - " copying, open RetroDECK > Tools > BIOS Checker to\n" - " verify everything is detected.\n\n" - ), - "retrobat": ( - " Option B: Manual\n" - " ----------------\n" - " 1. Open your RetroBat installation folder\n" - " 2. Navigate to the bios\\ subfolder\n" - " (default: C:\\RetroBat\\bios\\)\n" - " 3. Extract all files from this archive directly into your bios\\ folder\n" - " 4. Overwrite if asked\n\n" - ), - "bizhawk": ( - " Option B: Manual\n" - " ----------------\n" - " 1. Open the folder you extracted BizHawk to, the one holding\n" - " EmuHawk.exe (Windows) or EmuHawkMono.sh (Linux)\n" - " 2. Navigate to its Firmware subfolder\n" - " 3. Extract all files from this archive directly into your Firmware folder\n" - " 4. In BizHawk: Config > Paths > Firmware should\n" - " point to this folder\n\n" - ), - # RomM 5.4 places firmware by filesystem.structure.firmware in config.yml, - # bios/{platform} by default, and suggests {platform}/bios for a library - # laid out as {platform}/roms (rommapp/romm 68f71259, - # backend/config/config_manager.py:58,101-109,1105-1124). - "romm": ( - " Option B: Manual\n" - " ----------------\n" - " The archive holds one folder per RomM platform (psx/, gba/, ...).\n" - " Where they go depends on filesystem.structure.firmware in RomM's\n" - " config.yml, read relative to the library folder:\n\n" - " - Not set, or \"bios/{platform}\" (the default): extract all files\n" - " from this archive directly into library/bios/\n" - " - \"{platform}/bios\" (the layout RomM suggests for a library kept as\n" - " {platform}/roms): each folder goes under its platform, psx/ into\n" - " library/psx/bios/, gba/ into library/gba/bios/, and so on.\n" - " The installer reads config.yml and does this itself. Run from\n" - " the host of a container, --romm-firmware \"{platform}/bios\" says it.\n\n" - " Then restart the RomM service to detect new files.\n\n" - ), - "retropie": ( - " Option B: Via network share\n" - " --------------------------\n" - " 1. On your PC, open: \\\\RETROPIE\\bios\\\n" - " 2. Extract all files from this archive directly into that folder\n\n" - " Option C: Via SSH\n" - " -----------------\n" - " 1. SSH into your Pi: ssh pi@retropie\n" - " 2. Copy files to ~/RetroPie/BIOS/\n\n" - " NOTE: the BIOS folder is on the card's Linux partition,\n" - " which Windows and macOS cannot open: copying through the\n" - " card works from a Linux computer only\n" - " (/home/pi/RetroPie/BIOS/).\n\n" - ), - "lakka": ( - " Lakka reads its BIOS files from /storage/system/.\n\n" - " Option B: Via network share\n" - " --------------------------\n" - " 1. On Lakka: Settings > Services > Samba Enable\n" - " 2. On your PC, open the System share: \\\\LAKKA\\System\\\n" - " (macOS and Linux: smb://lakka/System)\n" - " 3. Extract all files from this archive directly into that share\n\n" - " Option C: Via SSH\n" - " -----------------\n" - " 1. On Lakka: Settings > Services > SSH Enable\n" - " 2. Copy the files to /storage/system/ (scp -r * root@lakka:/storage/system/)\n\n" - ), -} - - def _build_readme( platform_name: str, platform_display: str, @@ -303,7 +164,7 @@ def _build_readme( guide = ( "INSTALLATION GUIDE\n\n" + _automatic_section(platform_display, detect, standalone_copies) - + MANUAL_GUIDES.get(platform_name, _generic_manual(bios_paths)) + + (pack_manual(platform_name) or _generic_manual(bios_paths)) ) if regions: diff --git a/scripts/siteguides.py b/scripts/siteguides.py index b9c06631..990d435c 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -1,118 +1,28 @@ """The guide part of the site: where each setup keeps its BIOS files. A platform page opens on what someone holding the device needs: the folder, -the command, the pack, how to check. The registry says which group of -devices a platform belongs to (`device_group`), so the device picker and the -navigation list every registered platform. The prose below is per platform, -and a test holds that every registered platform has an entry. - -Every path here is one the installer detects, the registry records or the -platform documents. Menu labels of third-party software are named only where -this project already relies on them. +the command, the pack, how to check. The words live in +``platforms/_guides.yml``, one guide per registered platform, beside the +device groups, the terminal tabs and the safety notes every guide shares. +This module loads that file, checks it against the registry and renders the +parts the pages share. The registry keeps the folder the pack unzips into +(``extract_to``) and the group a platform belongs to (``device_group``). """ from __future__ import annotations -from dataclasses import dataclass, field +import textwrap +from dataclasses import dataclass, fields +from pathlib import Path -INSTALL_BASE = "https://raw.githubusercontent.com/Abdess/retrobios/main" +from safeparse import require_yaml -# Order of the groups on the picker, in the navigation and on the home page. -DEVICE_GROUPS: dict[str, tuple[str, str]] = { - "computer": ( - "Computers and Steam Deck", - "An emulator app on a computer or a Steam Deck.", - ), - "gaming-os": ( - "Game systems for PCs and small boards", - "A system that starts straight into a game menu, on a PC or a Raspberry Pi.", - ), - "handheld": ( - "Handhelds and TV boxes", - "The system your handheld or TV box starts.", - ), - "fpga": ( - "MiSTer FPGA", - "A board that recreates old consoles in hardware, not in software.", - ), - "server": ( - "Self-hosted game library", - "A RomM server. It plays games in your web browser.", - ), -} +PLATFORMS_DIR = Path(__file__).resolve().parent.parent / "platforms" +GUIDES_FILE = PLATFORMS_DIR / "_guides.yml" -# Section anchors on the device picker. Two keep the anchors the page had -# before it was grouped by device, which links outside the site may use. -GROUP_ANCHORS = { - "computer": "computers-and-steam-deck", - "gaming-os": "game-systems", - "handheld": "handhelds", - "fpga": "fpga", - "server": "self-hosted", -} -# Setups that are not platforms of their own: they run one, and use its pack. -# (setup, what it is, platform it uses, runs on, BIOS folder) -ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = { - "computer": ( - ("LaunchBox", "A game library and launcher. It starts RetroArch or " - "standalone emulators.", "retroarch", "Windows", - "The folder of the RetroArch it starts"), - ), - "handheld": ( - ("Android (Retroid Pocket, AYN Odin and others)", "RetroArch for Android.", - "retroarch", "Android", "`RetroArch/system/` on the internal storage"), - ), -} - -# Ways to run the installer. Each tab names how to open a terminal there, -# the shell the command is written for, and the options it needs. -TERMINALS: dict[str, tuple[str, str, str]] = { - "windows": ( - "Windows", - "Open the Start menu. Type **PowerShell** and open it. Paste the line. " - "Press Enter.", - "powershell", - ), - "macos": ( - "macOS", - "Open **Terminal**, in Applications, then Utilities. Paste the line. " - "Press Return.", - "sh", - ), - "linux": ( - "Linux", - "Open a terminal. Paste the line. Press Enter.", - "sh", - ), - "steamdeck": ( - "Steam Deck", - "Switch to Desktop Mode. Open **Konsole**. Paste with Ctrl+Shift+V. " - "Press Enter.", - "sh", - ), - "android": ( - "Android (Termux)", - "Install **Termux**, a terminal app. Run `pkg install python` and " - "`termux-setup-storage` once. Then paste the line. Press Enter.", - "sh", - ), - "device": ( - "On the device", - "Open a terminal on the device. Or connect from a computer with SSH, a " - "terminal over the network. Paste the line. Press Enter.", - "sh", - ), - "server": ( - "On the server", - "Open a terminal on the machine that holds your RomM library. Put the " - "path of its `bios` folder after `--dest`.", - "sh", - ), -} - -# Windows users of a card frontend run the PowerShell form with a drive letter. -CARD_TERMINALS = ("macos", "linux", "windows") +class GuideError(ValueError): + """The guides file does not say what a page needs, or contradicts the registry.""" @dataclass(frozen=True) @@ -120,10 +30,14 @@ class Guide: """What a platform page says before its file reference. `folders` are the common cases, `more_folders` the rarer builds shown - folded under them. `notes` stay in the guide, one plain fact each. - `reference_notes` carry the source citations and the details a reader - checking the work wants, and open the Reference part of the page. - `download_hint` is one more bullet under the pack download. + folded under them, under `more_folders_title`. `more_folders_detected` + adds a row for each named system the registry detects the platform on. + `notes` stay in the guide, one plain fact each. `reference_notes` carry + the source citations and the details a reader checking the work wants, + and open the Reference part of the page. `download_hint` is one more + bullet under the pack download. `pack_manual` is the manual part of the + README inside the pack. `logo_owner` and `logo_licence` credit the + logo the registry links. """ summary: str @@ -132,257 +46,198 @@ class Guide: notes: tuple[str, ...] = () dest: str = "" on_card: bool = False - extra_args: dict[str, str] = field(default_factory=dict) more_folders: tuple[tuple[str, str], ...] = () + more_folders_title: str = "Other layouts" + more_folders_detected: bool = False + more_folders_note: str = "" reference_notes: tuple[str, ...] = () download_hint: str = "" + pack_manual: str = "" + logo_owner: str = "" + logo_licence: str = "" -NETWORK_HINT = "From a computer, use the network folder above." +_GUIDE_KEYS = {f.name for f in fields(Guide)} +_REQUIRED = ("summary", "folders", "run") +_TOP_KEYS = {"install_base", "trust_items", "device_groups", "terminals", "guides"} +_GROUP_KEYS = {"label", "description", "anchor", "also_runs"} +_ALSO_KEYS = ("setup", "summary", "uses", "runs_on", "folder") +_TERMINAL_KEYS = ("label", "how", "shell") -GUIDES: dict[str, Guide] = { - "retroarch": Guide( - summary="An app that runs many consoles. It uses a different **core**, " - "a small emulator, for each one.", - folders=( - ("Windows", "`system\\` next to `retroarch.exe`"), - ("macOS", "`~/Documents/RetroArch/system/`"), - ("Linux", "`~/.config/retroarch/system/`"), - ("Steam Deck and Linux Flatpak", "`~/.var/app/org.libretro.RetroArch/config/retroarch/system/`"), - ("Android", "`RetroArch/system/` on the internal storage"), - ("Nintendo Switch", "`retroarch/cores/system/` on the SD card"), - ), - more_folders=( - ("Windows, Microsoft Store build", - "`%LOCALAPPDATA%\\Packages\\1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_*\\LocalState\\system\\`"), - ("macOS, Steam or portable build", "`~/Library/Application Support/RetroArch/system/`"), - ), - run=("windows", "macos", "linux", "steamdeck", "android"), - notes=( - "**Not sure?** In RetroArch, open **Settings > Directory > " - "System/BIOS**. It shows the folder.", - ), - reference_notes=( - "RetroArch reads its BIOS folder from the `system_directory` line of " - "`retroarch.cfg`, the value **Settings > Directory > System/BIOS** " - "shows.", - "On Windows, `retroarch.cfg` is read next to `retroarch.exe` first, " - "then from `%APPDATA%\\RetroArch\\`. A `system_directory` left at its " - "default is the `system` folder next to `retroarch.exe` (`:\\system` " - "in RetroArch's `frontend/drivers/platform_win32.c`, where `:` stands " - "for the folder of the program).", - ), - ), - "batocera": Guide( - summary="A system that starts straight into a game menu.", - folders=( - ("On Batocera", "`/userdata/bios/`"), - ("From Windows, over your home network", "`\\\\BATOCERA\\share\\bios\\`"), - ("From a Mac or Linux, over your home network", "`smb://batocera/share/bios/`"), - ), - run=("device",), - download_hint=NETWORK_HINT, - ), - "recalbox": Guide( - summary="A system that starts straight into a game menu and sets up its " - "emulators.", - folders=( - ("On Recalbox", "`/recalbox/share/bios/`"), - ("From Windows, over your home network", "`\\\\RECALBOX\\share\\bios\\`"), - ), - run=("device",), - download_hint=NETWORK_HINT, - ), - "retrobat": Guide( - summary="A game menu for Windows. It comes with RetroArch and other " - "emulators.", - folders=( - ("Inside the RetroBat folder", "`bios\\`"), - ("Default install", "`C:\\RetroBat\\bios\\`"), - ), - run=("windows",), - notes=("The command finds RetroBat wherever you installed it.",), - reference_notes=( - "RetroBat records its own folder each time it starts, and the " - "installer reads it there.", - ), - ), - "emudeck": Guide( - summary="Installs your emulators and adds your games to Steam.", - folders=( - ("Steam Deck and Linux", "`~/Emulation/bios/`"), - ("Windows, by default", "`C:\\Emulation\\bios\\`"), - ("macOS", "`~/Emulation/bios/`"), - ), - run=("steamdeck", "linux", "windows", "macos"), - notes=( - "The `bios` folder is inside the Emulation folder you chose in " - "EmuDeck. If you chose the SD card, it is on the card.", - ), - reference_notes=( - "The installer reads the Emulation folder from EmuDeck's settings " - "(`emulationPath` in `~/.config/EmuDeck/settings.sh`).", - ), - ), - "lakka": Guide( - summary="A system that starts straight into RetroArch.", - folders=(("On Lakka", "`/storage/system/`"),), - run=("device",), - notes=("You can also reach the folder from a computer, over your home network.",), - reference_notes=("The folder is reachable over SSH and over the network share.",), - ), - "retrodeck": Guide( - summary="One app with its emulators inside. Install it from Flathub or " - "Discover.", - folders=( - ("Steam Deck and Linux", "`~/retrodeck/bios/`"), - ("Installed on a microSD card", "for example `/run/media/mmcblk0p1/retrodeck/bios/`"), - ), - run=("steamdeck", "linux"), - ), - "romm": Guide( - summary="A game library on your own server. It plays games in the web " - "browser.", - folders=(("In the RomM library", "`bios/`, one subfolder per platform"),), - more_folders=( - ("With `firmware: \"{platform}/bios\"` in `config.yml`", - "`/bios/` for each platform"), - ), - run=("server",), - dest="/path/to/library/bios", - notes=("You can also add BIOS files in RomM's web page.",), - ), - "rocknix": Guide( - summary="A system for handhelds.", - folders=(("On ROCKNIX", "`/storage/roms/bios/`"),), - run=("device",), - notes=("You can also reach the folder from a computer, over your home network.",), - reference_notes=( - "ROCKNIX runs on ARM and x86 handhelds, among them the RG35XX (H700) " - "and the RG552 (RK3399). The folder is reachable over SSH and over " - "the network share.", - ), - ), - "amberelec": Guide( - summary="A system for Anbernic handhelds.", - folders=(("On AmberELEC", "`/storage/roms/`"),), - run=("device",), - reference_notes=( - "AmberELEC runs on the RG351P, RG351V, RG351MP, RG353, RG503 and " - "RG552. Its list names files under `/storage/roms`: `bios/` for most, " - "`neogeo/` for the rest. The pack holds those folders.", - ), - ), - "emuelec": Guide( - summary="A system for TV boxes and some handhelds.", - folders=(("On EmuELEC", "`/storage/roms/`"),), - run=("device",), - reference_notes=( - "EmuELEC runs on Amlogic TV boxes, the Odroid Go Advance, GameForce " - "and RK356x devices. Its list names files under `/storage/roms`: " - "`bios/` for most, `neogeo/` and `ports/` for the rest. The pack " - "holds those folders.", - ), - ), - "knulli": Guide( - summary="A system for handhelds, based on Batocera.", - folders=(("On KNULLI", "`/userdata/bios/`"),), - run=("device",), - reference_notes=( - "KNULLI runs on handhelds among them the RG35XX family (H700) and the " - "TrimUI Smart Pro.", - ), - ), - "retropie": Guide( - summary="Installs RetroArch and other emulators on a Raspberry Pi.", - folders=(("On the Raspberry Pi", "`~/RetroPie/BIOS/`"),), - run=("device",), - notes=( - "Use the RetroPie pack, not the RetroArch one. On RetroPie 4.8, " - "Python is too old for the command: use the pack there.", - ), - reference_notes=( - "RetroPie also installs standalone emulators (openMSX, PCSX2, XRoar, " - "Amiberry and others) whose files only the RetroPie pack carries.", - "RetroPie is archived in this project: its configuration is kept and " - "its pack is still built, but its upstream list is no longer read " - "again on a schedule.", - ), - ), - "bizhawk": Guide( - summary="An emulator app for many consoles, from TASVideos.", - folders=( - ("Inside the BizHawk folder", "`Firmware\\`"), - ("Default place the installer looks", "`%USERPROFILE%\\BizHawk\\Firmware\\`"), - ), - run=("windows", "linux"), - reference_notes=("BizHawk carries rerecording and debugging tools.",), - ), - "misterfpga": Guide( - summary="A board that recreates old consoles in hardware, not in software.", - folders=(("On the MiSTer SD card", "`/media/fat/games/`, one subfolder per core"),), - run=("device",), - notes=( - "Each file goes in its console's folder, like " - "`/media/fat/games/3DO/boot.rom`.", - ), - reference_notes=( - "MiSTer runs FPGA cores on a DE10-Nano board. Only the files the " - "MiSTer BIOS database lists with a download address are in the pack. " - "The others come with the MiSTer distribution and install themselves.", - ), - ), - "minui": Guide( - summary="A simple game menu for handhelds. It runs from the SD card.", - folders=(("On the SD card", "`Bios//`, one folder per console"),), - run=CARD_TERMINALS, - on_card=True, - notes=( - "Each console has its own folder, named after the tag in its Roms " - "folder. `Roms/Sony PlayStation (PS)/` uses `Bios/PS/`.", - ), - reference_notes=( - "MinUI gives each console its own folder, named after the tag in " - "parentheses of the Roms folder the game sits in. A game in " - "`Roms/Game Boy (GB)/` runs with `Bios/GB/` as its BIOS folder (MinUI " - "`workspace/all/minarch/minarch.c:2943`).", - "Without `--platform`, the installer reads the release name MinUI " - "writes in `.system/version.txt` on the card. A console started by a " - "standalone program instead of a libretro core (DraStic for NDS, " - "native PICO-8) is not covered.", - ), - ), - "nextui": Guide( - summary="A game menu for handhelds, based on MinUI. It runs from the SD card.", - folders=(("On the SD card", "`Bios//`, one folder per console"),), - run=CARD_TERMINALS, - on_card=True, - notes=( - "Each console has its own folder, named after the tag in its Roms " - "folder. `Roms/Sony PlayStation (PS)/` uses `Bios/PS/`.", - ), - reference_notes=( - "NextUI gives each console its own folder, named after the tag in " - "parentheses of the Roms folder the game sits in (NextUI " - "`workspace/all/minarch/ma_core.c:76`).", - ), - ), - "myminui": Guide( - summary="A game menu for handhelds, based on MinUI. It runs from the SD card.", - folders=(("On the SD card", "`Bios//`, one folder per console"),), - run=CARD_TERMINALS, - on_card=True, - notes=( - "Each console has its own folder, named after the tag in its Roms " - "folder. `Roms/Sony PlayStation (PS)/` uses `Bios/PS/`.", - ), - reference_notes=( - "MyMinUI gives each console its own folder, named after the tag in " - "parentheses of the Roms folder the game sits in (MyMinUI " - "`workspace/all/minarch/minarch.c:3532`).", - ), - ), -} + +def _text(value: object, where: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise GuideError(f"{where}: expected text, found {value!r}") + return value + + +def _texts(value: object, where: str) -> tuple[str, ...]: + if not isinstance(value, list): + raise GuideError(f"{where}: expected a list, found {value!r}") + return tuple(_text(item, f"{where}[{i}]") for i, item in enumerate(value)) + + +def _unknown(data: dict, allowed: set[str] | tuple[str, ...], where: str) -> None: + extra = sorted(set(data) - set(allowed)) + if extra: + raise GuideError(f"{where}: unknown key {', '.join(extra)} " + f"(known: {', '.join(sorted(allowed))})") + + +def _folder_rows(value: object, extract_to: str, where: str) -> tuple[tuple[str, str], ...]: + """Folder rows as (where, path). A row with no path is the registry's + extract_to, so that folder is written once.""" + if not isinstance(value, list) or not value: + raise GuideError(f"{where}: expected a list of folder rows") + rows = [] + for i, row in enumerate(value): + at = f"{where}[{i}]" + if not isinstance(row, dict): + raise GuideError(f"{at}: expected a mapping with `where` and `path`") + _unknown(row, ("where", "path"), at) + label = _text(row.get("where"), f"{at}.where") + if "path" in row: + rows.append((label, _text(row["path"], f"{at}.path"))) + elif extract_to: + rows.append((label, f"`{extract_to}`")) + else: + raise GuideError(f"{at}: no `path`, and the registry has no extract_to for it") + return tuple(rows) + + +def _guide(name: str, data: object, extract_to: str, terminals: dict) -> Guide: + where = f"{GUIDES_FILE.name}: guides.{name}" + if not isinstance(data, dict): + raise GuideError(f"{where}: expected a mapping") + _unknown(data, _GUIDE_KEYS, where) + missing = [key for key in _REQUIRED if key not in data] + if missing: + raise GuideError(f"{where}: missing {', '.join(missing)}") + run = _texts(data["run"], f"{where}.run") + unknown_tabs = [key for key in run if key not in terminals] + if unknown_tabs: + raise GuideError(f"{where}.run: no terminal named {', '.join(unknown_tabs)}") + values: dict = { + "summary": _text(data["summary"], f"{where}.summary"), + "folders": _folder_rows(data["folders"], extract_to, f"{where}.folders"), + "run": run, + } + if "more_folders" in data: + values["more_folders"] = _folder_rows( + data["more_folders"], extract_to, f"{where}.more_folders") + for key in ("notes", "reference_notes"): + if key in data: + values[key] = _texts(data[key], f"{where}.{key}") + for key in ("dest", "download_hint", "more_folders_title", "more_folders_note", + "logo_owner", "logo_licence"): + if key in data: + values[key] = _text(data[key], f"{where}.{key}") + for key in ("on_card", "more_folders_detected"): + if key in data: + if not isinstance(data[key], bool): + raise GuideError(f"{where}.{key}: expected true or false") + values[key] = data[key] + if "pack_manual" in data: + values["pack_manual"] = _text(data["pack_manual"], f"{where}.pack_manual") + return Guide(**values) + + +def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: + """The guides file, checked against the registry. + + Every registered platform, archived ones included, has a guide and a + device group the file describes. A mistake raises GuideError naming the + key, so the site is never built from half a guide. + """ + yaml = require_yaml() + if registry is None: + with open(PLATFORMS_DIR / "_registry.yml", encoding="utf-8") as handle: + registry = (yaml.safe_load(handle) or {}).get("platforms", {}) + with open(path, encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + if not isinstance(data, dict): + raise GuideError(f"{path.name}: expected a mapping") + _unknown(data, _TOP_KEYS, path.name) + + terminals: dict[str, tuple[str, str, str]] = {} + for key, entry in (data.get("terminals") or {}).items(): + at = f"{path.name}: terminals.{key}" + if not isinstance(entry, dict): + raise GuideError(f"{at}: expected a mapping") + _unknown(entry, _TERMINAL_KEYS, at) + terminals[key] = tuple(_text(entry.get(k), f"{at}.{k}") for k in _TERMINAL_KEYS) + if entry["shell"] not in ("sh", "powershell"): + raise GuideError(f"{at}.shell: sh or powershell, found {entry['shell']!r}") + + raw_guides = data.get("guides") or {} + groups: dict[str, tuple[str, str]] = {} + anchors: dict[str, str] = {} + also_runs: dict[str, tuple[tuple[str, str, str, str, str], ...]] = {} + for key, entry in (data.get("device_groups") or {}).items(): + at = f"{path.name}: device_groups.{key}" + if not isinstance(entry, dict): + raise GuideError(f"{at}: expected a mapping") + _unknown(entry, _GROUP_KEYS, at) + groups[key] = (_text(entry.get("label"), f"{at}.label"), + _text(entry.get("description"), f"{at}.description")) + anchors[key] = _text(entry.get("anchor"), f"{at}.anchor") + rows = [] + for i, row in enumerate(entry.get("also_runs") or []): + row_at = f"{at}.also_runs[{i}]" + if not isinstance(row, dict): + raise GuideError(f"{row_at}: expected a mapping") + _unknown(row, _ALSO_KEYS, row_at) + values = tuple(_text(row.get(k), f"{row_at}.{k}") for k in _ALSO_KEYS) + if values[2] not in raw_guides: + raise GuideError(f"{row_at}.uses: {values[2]!r} is not a platform with a guide") + rows.append(values) + if rows: + also_runs[key] = tuple(rows) + + unregistered = sorted(set(raw_guides) - set(registry)) + if unregistered: + raise GuideError(f"{path.name}: guides for unregistered platforms: " + f"{', '.join(unregistered)}") + guides = {} + for name, entry in registry.items(): + if name not in raw_guides: + raise GuideError(f"{path.name}: {name} is registered and has no guide " + f"(add guides.{name})") + group = entry.get("device_group") + if group not in groups: + raise GuideError(f"_registry.yml: {name}.device_group {group!r} is not " + f"one of {', '.join(groups)} in {path.name}") + guides[name] = _guide(name, raw_guides[name], str(entry.get("extract_to") or ""), + terminals) + # The order the file is written in, which the pages do not depend on. + guides = {name: guides[name] for name in raw_guides} + return { + "install_base": _text(data.get("install_base"), f"{path.name}: install_base"), + "trust_items": _texts(data.get("trust_items"), f"{path.name}: trust_items"), + "device_groups": groups, + "group_anchors": anchors, + "also_runs": also_runs, + "terminals": terminals, + "guides": guides, + } + + +_LOADED = load_guides() +INSTALL_BASE: str = _LOADED["install_base"] +DEVICE_GROUPS: dict[str, tuple[str, str]] = _LOADED["device_groups"] +GROUP_ANCHORS: dict[str, str] = _LOADED["group_anchors"] +ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also_runs"] +TERMINALS: dict[str, tuple[str, str, str]] = _LOADED["terminals"] +TRUST_ITEMS: tuple[str, ...] = _LOADED["trust_items"] +GUIDES: dict[str, Guide] = _LOADED["guides"] + + +def pack_manual(name: str) -> str: + """The manual part of a pack README, indented as the README is, or empty.""" + guide = GUIDES.get(name) + if not guide or not guide.pack_manual: + return "" + return textwrap.indent(guide.pack_manual.rstrip("\n"), " ") + "\n\n" def install_command(shell: str, args: str = "") -> str: @@ -433,16 +288,6 @@ def platform_args(name: str, guide: Guide) -> dict[str, str]: return args -TRUST_ITEMS = ( - "It checks each file before saving it.", - "It writes only in your BIOS folder.", - "It never touches games or saves.", - "It never moves or deletes files it does not know.", - "It keeps any file it replaces in a `retrobios-backup` folder, beside your " - "BIOS folder. The same command with `--restore` puts it back.", -) - - def trust_box(prefix: str, indent: str = "") -> list[str]: """Is it safe, under every command on a guide page: five plain facts.""" return [ diff --git a/tests/test_pack_readme.py b/tests/test_pack_readme.py index 4e122e46..14d3fea0 100644 --- a/tests/test_pack_readme.py +++ b/tests/test_pack_readme.py @@ -98,9 +98,15 @@ class TheOneLinerFollowsTheRegistry(unittest.TestCase): self.assertIn("--romm-firmware", text) def test_the_commands_are_written_once(self): + """The address comes from install_base in platforms/_guides.yml, the + one the site's commands use.""" + import packreadme + from siteguides import INSTALL_BASE + source = (REPO_ROOT / "scripts" / "packreadme.py").read_text(encoding="utf-8") - self.assertEqual(source.count("main/install.sh"), 1) - self.assertEqual(source.count("main/install.ps1"), 1) + self.assertNotIn("raw.githubusercontent.com", source) + self.assertEqual(packreadme.INSTALL_SH, f"{INSTALL_BASE}/install.sh") + self.assertEqual(packreadme.INSTALL_PS1, f"{INSTALL_BASE}/install.ps1") if __name__ == "__main__": diff --git a/tests/test_site_content_data.py b/tests/test_site_content_data.py new file mode 100644 index 00000000..fc77a2d3 --- /dev/null +++ b/tests/test_site_content_data.py @@ -0,0 +1,216 @@ +"""The site's words live in Markdown and YAML, not in the generator. + +Each platform's guide is in platforms/_guides.yml, read and checked by +siteguides.load_guides. A wiki page places itself in the navigation from its +front matter. Both are edited as data: a new platform or a new wiki page +needs no Python change, and a mistake stops the build with its name. +""" + +from __future__ import annotations + +import ast +import re +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site # noqa: E402 +import siteguides # noqa: E402 +from siteguides import GUIDES, GuideError, load_guides # noqa: E402 + +GUIDES_FILE = REPO_ROOT / "platforms" / "_guides.yml" +WIKI = REPO_ROOT / "wiki" + + +def _registry() -> dict: + with open(REPO_ROOT / "platforms" / "_registry.yml", encoding="utf-8") as handle: + return yaml.safe_load(handle)["platforms"] + + +def _guides_text() -> str: + return GUIDES_FILE.read_text(encoding="utf-8") + + +class GuidesLiveInYaml(unittest.TestCase): + + def _load(self, text: str, registry: dict | None = None) -> dict: + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + path = Path(tmp) / "_guides.yml" + path.write_text(text, encoding="utf-8") + return load_guides(path, registry if registry is not None else _registry()) + + def test_every_registered_platform_has_a_guide_in_the_file(self): + written = yaml.safe_load(_guides_text())["guides"] + self.assertEqual(sorted(written), sorted(_registry())) + self.assertEqual(sorted(GUIDES), sorted(_registry())) + + def test_a_platform_without_a_guide_is_named(self): + data = yaml.safe_load(_guides_text()) + del data["guides"]["knulli"] + with self.assertRaisesRegex(GuideError, r"knulli is registered and has no guide"): + self._load(yaml.safe_dump(data)) + + def test_a_guide_for_no_platform_is_named(self): + registry = _registry() + del registry["knulli"] + with self.assertRaisesRegex(GuideError, r"unregistered platforms: knulli"): + self._load(_guides_text(), registry) + + def test_a_misspelt_key_is_refused(self): + data = yaml.safe_load(_guides_text()) + data["guides"]["lakka"]["sumary"] = "A typo." + with self.assertRaisesRegex(GuideError, r"guides\.lakka: unknown key sumary"): + self._load(yaml.safe_dump(data)) + + def test_a_terminal_no_tab_describes_is_refused(self): + data = yaml.safe_load(_guides_text()) + data["guides"]["lakka"]["run"] = ["telnet"] + with self.assertRaisesRegex(GuideError, r"guides\.lakka\.run: no terminal named telnet"): + self._load(yaml.safe_dump(data)) + + def test_a_folder_row_without_path_is_the_extract_folder(self): + """The folder the pack unzips into is written once, in the registry.""" + registry = _registry() + written = yaml.safe_load(_guides_text())["guides"]["batocera"]["folders"][0] + self.assertNotIn("path", written) + self.assertEqual(GUIDES["batocera"].folders[0], + (written["where"], f"`{registry['batocera']['extract_to']}`")) + + def test_a_folder_row_without_path_needs_an_extract_folder(self): + registry = _registry() + del registry["lakka"]["extract_to"] + with self.assertRaisesRegex(GuideError, r"guides\.lakka\.folders\[0\]: no `path`"): + self._load(_guides_text(), registry) + + def test_a_device_group_the_file_does_not_describe_is_refused(self): + registry = _registry() + registry["lakka"]["device_group"] = "toaster" + with self.assertRaisesRegex(GuideError, r"lakka\.device_group 'toaster'"): + self._load(_guides_text(), registry) + + def test_the_pack_readme_keeps_its_indent(self): + manual = siteguides.pack_manual("retrobat") + self.assertTrue(manual.startswith(" Option B: Manual\n ----------------\n")) + self.assertTrue(manual.endswith(" 4. Overwrite if asked\n\n")) + self.assertEqual(siteguides.pack_manual("minui"), "") + + +# A table of prose keyed by platform: what the guides file replaced. +_PLATFORM_BRANCH = re.compile(r"""(?:==|!=)\s*["']([a-z0-9-]+)["']|["']([a-z0-9-]+)["']\s*(?:==|!=)""") +# The modules that write the site and the pack README. +_RENDERERS = ("generate_site.py", "packreadme.py", "generate_readme.py") + + +class NoPlatformProseInScripts(unittest.TestCase): + + def test_no_table_of_prose_keyed_by_platform(self): + platforms = set(_registry()) + found = [] + for path in sorted((REPO_ROOT / "scripts").rglob("*.py")): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not isinstance(node, ast.Dict): + continue + keyed = [(k.value, v) for k, v in zip(node.keys, node.values) + if isinstance(k, ast.Constant) and k.value in platforms] + prose = [key for key, value in keyed if any( + isinstance(c, ast.Constant) and isinstance(c.value, str) + and " " in c.value and len(c.value) >= 30 + for c in ast.walk(value))] + if len(keyed) >= 2 and prose: + found.append(f"{path.relative_to(REPO_ROOT)}:{node.lineno}") + self.assertEqual(found, [], "per-platform text belongs in platforms/_guides.yml") + + def test_no_page_branches_on_one_platform(self): + platforms = set(_registry()) + found = [] + scripts = REPO_ROOT / "scripts" + for path in [scripts / name for name in _RENDERERS] + sorted(scripts.glob("site*.py")): + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + for match in _PLATFORM_BRANCH.finditer(line): + if (match.group(1) or match.group(2)) in platforms: + found.append(f"{path.name}:{number}: {line.strip()}") + self.assertEqual(found, [], "a guide field says what one platform needs") + + +def _wiki_copy(tmp: str) -> Path: + target = Path(tmp) / "wiki" + shutil.copytree(WIKI, target) + return target + + +class NavFromFrontMatter(unittest.TestCase): + + def _nav(self, wiki: Path) -> dict: + nav = generate_site.generate_mkdocs_nav({}, {}, {}, {}, wiki) + return {next(iter(tab)): next(iter(tab.values())) for tab in nav} + + def test_the_repository_nav_matches_the_pages(self): + tabs = self._nav(WIKI) + self.assertEqual(tabs["Explore"][-2:], [ + {"All installer options": "wiki/installer.md"}, + {"Is it legal?": "wiki/legal.md"}, + ]) + self.assertEqual(tabs["Reference"][0], "wiki/index.md") + + def test_a_new_page_lands_in_its_tab_at_its_order(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + wiki = _wiki_copy(tmp) + (wiki / "save-files.md").write_text( + "---\nlevel: explore\norder: 35\n---\n\n# Save files\n\nText.\n", + encoding="utf-8") + explore = self._nav(wiki)["Explore"] + targets = [e if isinstance(e, str) else next(iter(e.values())) for e in explore] + self.assertEqual(targets.index("wiki/save-files.md"), + targets.index("wiki/installer.md") + 1) + self.assertIn({"Save files": "wiki/save-files.md"}, explore) + + def test_a_page_without_a_level_fails_by_name(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + wiki = _wiki_copy(tmp) + (wiki / "orphan.md").write_text("# Orphan\n\nText.\n", encoding="utf-8") + with self.assertRaisesRegex(generate_site.NavError, + r"wiki/orphan\.md: front matter needs `level:`"): + self._nav(wiki) + + def test_a_page_without_an_order_fails_by_name(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + wiki = _wiki_copy(tmp) + (wiki / "orphan.md").write_text( + "---\nlevel: reference\n---\n\n# Orphan\n", encoding="utf-8") + with self.assertRaisesRegex( + generate_site.NavError, + r"wiki/orphan\.md: front matter needs `order:`.*Reference tab", + ): + self._nav(wiki) + + def test_two_pages_on_one_place_fail_naming_both(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp: + wiki = _wiki_copy(tmp) + (wiki / "twin.md").write_text( + "---\nlevel: contribute\norder: 20\n---\n\n# Twin\n", encoding="utf-8") + with self.assertRaisesRegex(generate_site.NavError, + r"wiki/adding-a-platform\.md and wiki/twin\.md"): + self._nav(wiki) + + def test_nav_false_keeps_a_page_out(self): + tabs = self._nav(WIKI) + flat = repr(tabs) + self.assertNotIn("getting-started.md", flat) + + def test_every_wiki_page_states_its_place(self): + for page in sorted(WIKI.glob("*.md")): + meta = generate_site._front_matter(page.read_text(encoding="utf-8")) + with self.subTest(page=page.name): + self.assertIn(meta.get("level"), generate_site.TABS) + self.assertTrue(meta.get("nav") is False or isinstance(meta.get("order"), int)) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/adding-a-platform.md b/wiki/adding-a-platform.md index 061ea3e2..d564b04c 100644 --- a/wiki/adding-a-platform.md +++ b/wiki/adding-a-platform.md @@ -1,5 +1,6 @@ --- level: contribute +order: 20 --- # Adding a platform @@ -175,18 +176,8 @@ platforms: `device_group` places the platform among the devices a reader picks from: on the site's "Get your files" page, in the "Your setup" navigation and on the home -page. The groups and their labels are `DEVICE_GROUPS` in -`scripts/siteguides.py`, and the same file holds the platform's guide text -(what it is, where its BIOS folder is, where the install command runs). A -test fails when a registered platform has no group or no guide. - -The guide text is read by someone who knows their device and little else. -`summary` is one plain sentence, with no chip or board names. `notes` hold -one fact each that the reader acts on. A source citation, a list of models or -how the installer finds the folder goes in `reference_notes`, which open the -Reference part of the page, and a rare build goes in `more_folders`, shown -folded. The guide part of every setup page is held to the word budgets of -[Pages for newcomers](testing-guide.md#pages-for-newcomers). +page. The groups, their labels and their anchors are under `device_groups` in +`platforms/_guides.yml`, beside the platform's [guide text](#guide-text). A platform without a scraper (`scraper: null`, `schedule: null`) keeps its YAML as a hand-maintained file. Every registered platform has a scraper today, so @@ -235,6 +226,56 @@ platform hands it no folder. `platform_layouts` builds one view per directory, so the builder, the manifest, verify and slots read the same placement, and the resolver still looks the collection up by the core's own path. +### Guide text + +`platforms/_guides.yml` holds each platform's guide: what it is, where its +BIOS folder is, where the install command runs. It is data, edited like the registry, and +no Python changes when a platform is added. The registry keeps what tools read +and `_guides.yml` keeps what people read, so neither grows unreadable. + +```yaml +guides: + knulli: + summary: A system for handhelds, based on Batocera. + folders: + - where: On KNULLI # no path: the registry's extract_to + run: [device] # terminal tabs, in the order shown + reference_notes: + - KNULLI runs on handhelds among them the RG35XX family (H700). + logo_owner: KNULLI + logo_licence: GitHub organisation avatar of knulli-cfw +``` + +- `summary`, `folders` and `run` are required. Every value is Markdown. +- A folder row without `path` shows the registry's `extract_to`. That folder + is written once, where the installer and the release notes read it too. +- `run` names entries of `terminals` in the same file: `windows`, `macos`, + `linux`, `steamdeck`, `android`, `device` or `server`. +- `dest` puts `--dest` in the command, and `on_card: true` makes it the SD + card's path. +- `more_folders` lists rare builds, shown folded under `more_folders_title`. + `more_folders_detected: true` adds a row for each named system the + registry's `install.detect` rules find the platform on. +- `download_hint` adds a line under the pack download. +- `pack_manual` is the manual part of the README inside the pack, in plain + text. Without it the README names the `bios_path` of the detection rules. +- `logo_owner` and `logo_licence` credit the registry's `logo`, read by + `scripts/fetch_site_images.py`. + +The guide text is read by someone who knows their device and little else. +`summary` is one plain sentence, with no chip or board names. `notes` hold +one fact each that the reader acts on. A source citation, a list of models or +how the installer finds the folder goes in `reference_notes`, which open the +Reference part of the page. The guide part of every setup page is held to the +word budgets of [Pages for newcomers](testing-guide.md#pages-for-newcomers). + +`scripts/siteguides.py` loads the file and stops the build with the key at +fault: a registered platform without a guide, a guide for no registered +platform, a misspelt key, an unknown terminal, a group the file does not +describe. `tests/test_site_content_data.py` also fails when a script holds a +table of text keyed by platform or a page branches on one platform's name: +that text belongs in a guide field. + ### Optional fields ```yaml @@ -634,6 +675,7 @@ Verification is not optional. A platform that passes `pipeline.py` today may bre - [ ] `fetch_requirements()` and `validate_format()` implemented - [ ] System ID mapping covers all upstream systems - [ ] Entry added to `platforms/_registry.yml` +- [ ] Guide added to `platforms/_guides.yml` - [ ] Platform YAML generated and verified - [ ] `python scripts/pipeline.py --offline` passes - [ ] Exporter in `scripts/exporter/_exporter.py` (if applicable) diff --git a/wiki/adding-a-scraper.md b/wiki/adding-a-scraper.md index b5ad0e44..ece1c137 100644 --- a/wiki/adding-a-scraper.md +++ b/wiki/adding-a-scraper.md @@ -1,5 +1,6 @@ --- level: contribute +order: 30 --- # Adding a scraper diff --git a/wiki/advanced-usage.md b/wiki/advanced-usage.md index d9734659..461628fb 100644 --- a/wiki/advanced-usage.md +++ b/wiki/advanced-usage.md @@ -1,5 +1,6 @@ --- level: reference +order: 100 --- # Build your own packs diff --git a/wiki/architecture.md b/wiki/architecture.md index aa2de9aa..102ad182 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -1,5 +1,6 @@ --- level: reference +order: 90 --- # Architecture diff --git a/wiki/community-tools.md b/wiki/community-tools.md index 94ec875b..baa3bdb3 100644 --- a/wiki/community-tools.md +++ b/wiki/community-tools.md @@ -1,5 +1,6 @@ --- level: reference +order: 140 --- # Projects using this data diff --git a/wiki/faq.md b/wiki/faq.md index d4a3870a..78b03233 100644 --- a/wiki/faq.md +++ b/wiki/faq.md @@ -1,5 +1,7 @@ --- level: start +order: 50 +nav_title: Common questions --- # FAQ diff --git a/wiki/first-install.md b/wiki/first-install.md index 8e890871..7746aa0a 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -1,5 +1,7 @@ --- level: start +order: 30 +nav_title: First install, step by step --- # First install diff --git a/wiki/getting-started.md b/wiki/getting-started.md index f1630420..2d2cc6a5 100644 --- a/wiki/getting-started.md +++ b/wiki/getting-started.md @@ -1,5 +1,6 @@ --- level: start +nav: false --- # Getting started diff --git a/wiki/glossary.md b/wiki/glossary.md index aabab70c..b7b11749 100644 --- a/wiki/glossary.md +++ b/wiki/glossary.md @@ -1,5 +1,7 @@ --- level: start +order: 60 +nav_title: Glossary --- # Words used on this site diff --git a/wiki/index.md b/wiki/index.md index ce552536..68c06801 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -1,5 +1,6 @@ --- level: reference +order: 0 --- # Reference diff --git a/wiki/installer.md b/wiki/installer.md index 7c2463c0..3b299712 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1,5 +1,6 @@ --- level: explore +order: 30 --- # All installer options diff --git a/wiki/legal.md b/wiki/legal.md index b751783f..a0e282bf 100644 --- a/wiki/legal.md +++ b/wiki/legal.md @@ -1,5 +1,7 @@ --- level: explore +order: 40 +nav_title: "Is it legal?" --- # The project's legal position diff --git a/wiki/profiling.md b/wiki/profiling.md index af09f234..921a48e6 100644 --- a/wiki/profiling.md +++ b/wiki/profiling.md @@ -1,5 +1,7 @@ --- level: contribute +order: 10 +nav_title: Profiling an emulator --- # Profiling guide diff --git a/wiki/reference-terms.md b/wiki/reference-terms.md index 7f4d07b9..ddcdde12 100644 --- a/wiki/reference-terms.md +++ b/wiki/reference-terms.md @@ -1,5 +1,7 @@ --- level: reference +order: 130 +nav_title: Project terms --- # Terms of the reference pages diff --git a/wiki/release-process.md b/wiki/release-process.md index 5e280881..e6353421 100644 --- a/wiki/release-process.md +++ b/wiki/release-process.md @@ -1,5 +1,6 @@ --- level: contribute +order: 50 --- # Release process diff --git a/wiki/technical-faq.md b/wiki/technical-faq.md index 4b4c0306..45d027e7 100644 --- a/wiki/technical-faq.md +++ b/wiki/technical-faq.md @@ -1,5 +1,6 @@ --- level: reference +order: 120 --- # Technical questions diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index a22e7363..28022bda 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -1,5 +1,7 @@ --- level: contribute +order: 40 +nav_title: Testing --- # Testing guide @@ -54,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_site_availability`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `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_site_availability`, `test_site_copy`, `test_site_content_data`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_platform_hashes`, `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 @@ -362,6 +364,34 @@ and reads less on a phone. Facts a newcomer does not act on (legal analysis, source lines, annotated runs) are kept, on the page one level down. To see the numbers of every page, run `python -m tests.test_site_copy --report docs`. +### Adding a wiki page + +A page in `wiki/` is published as soon as it exists, and its front matter +places it in the navigation. No Python changes. + +```yaml +--- +level: explore # its tab: start, setup, explore, reference or contribute +order: 35 # its place in the tab, the smallest first +nav_title: Save files # only when the menu names it otherwise than its heading +--- +``` + +- `level` picks the tab and the label shown at the top of the page. +- `order` sorts the tab. Pages are spaced by ten, so a new page fits between + two without renumbering. The generated pages (systems, emulators, the gaps + and data pages) hold fixed places among them, set in `generate_mkdocs_nav`. +- `nav_title` is the menu label. Without it the menu shows the page's `# ` + heading. It is not `title`, which both MkDocs and Astro Starlight read as + the page's own title. +- `nav: false` keeps a page out of the menu on purpose. + `getting-started.md` does, since it only links the pages that took its + content. + +`python scripts/generate_site.py` stops with the page's name when `level` or +`order` is missing, or when two pages of a tab take the same `order`. +`tests/test_site_content_data.py` checks the same on every page in `wiki/`. + ## Landing a change A commit subject is English, 50 characters at most, `type: description` with a diff --git a/wiki/tools.md b/wiki/tools.md index 719c025e..bbe3917d 100644 --- a/wiki/tools.md +++ b/wiki/tools.md @@ -1,5 +1,6 @@ --- level: reference +order: 110 --- # Command-line tools diff --git a/wiki/troubleshooting.md b/wiki/troubleshooting.md index 13f4b16c..fb1827e8 100644 --- a/wiki/troubleshooting.md +++ b/wiki/troubleshooting.md @@ -1,5 +1,6 @@ --- level: start +order: 40 --- # When a game does not start diff --git a/wiki/verification-modes.md b/wiki/verification-modes.md index ab7eb3d4..6d7542d3 100644 --- a/wiki/verification-modes.md +++ b/wiki/verification-modes.md @@ -1,5 +1,6 @@ --- level: reference +order: 10 --- # How each platform checks files diff --git a/wiki/what-are-bios-files.md b/wiki/what-are-bios-files.md index 837751dc..149fdf37 100644 --- a/wiki/what-are-bios-files.md +++ b/wiki/what-are-bios-files.md @@ -1,5 +1,7 @@ --- level: start +order: 10 +nav_title: What BIOS files are --- # What is a BIOS?