diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e85902a47..7acb100ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ handheld launchers, `bwrap` (bubblewrap) for the probe sandbox, and a Python 3.8 with pyte (`python3.8 -m pip install "pyte==0.8.2"`) for the full screen. CI has all three. `profile_sync.py` compares the branch with this repository's `main`, and -fetches it in a fork's clone. A profile you only edit is not held to +fetches it in a fork's clone. An edited profile is not held to `--strict`: its older refs are reviewed by hand. The full pipeline is not needed for a contribution: it rebuilds every pack diff --git a/README.md b/README.md index d927af444..4814dbab5 100644 --- a/README.md +++ b/README.md @@ -5,183 +5,80 @@

-

- Site -

+

The BIOS files emulators need, put in the right folder and checked.

-BIOS and firmware files for AmberELEC, Batocera, BizHawk, EmuDeck, EmuELEC, KNULLI, Lakka, MiSTer FPGA, MinUI, MyMinUI, NextUI, ROCKNIX, Recalbox, RetroArch, RetroBat, RetroDECK, RetroPie, and RomM. +

Website · Get the files · A game does not start · Questions

-A pack holds the files its emulators read, as their source code names them, and leaves out what the emulators install themselves. +Some consoles need a small program, the **BIOS**, to start their games: PlayStation 1 and 2, Saturn, Neo Geo, Dreamcast and more. RetroBIOS installs the BIOS and firmware files for AmberELEC, Batocera, BizHawk, EmuDeck, EmuELEC, KNULLI, Lakka, MiSTer FPGA, MinUI, MyMinUI, NextUI, ROCKNIX, Recalbox, RetroArch, RetroBat, RetroDECK, RetroPie, and RomM, checked the way each one checks them. -## Quick install +## Install in one line -Copy one command into your terminal: +**Windows**: open the Start menu, type **PowerShell** and open it. Paste this line (right-click in the window) and press Enter. + +```powershell +irm https://abdess.github.io/retrobios/i.ps1 | iex +``` + +**Linux, macOS, Steam Deck, Android (Termux)**: open a terminal, paste this line and press Enter. ```bash -# Linux / macOS / Steam Deck curl -fsSL https://abdess.github.io/retrobios/i | sh - -# Windows (PowerShell) -irm https://abdess.github.io/retrobios/i.ps1 | iex - -# Android (Termux: it installs Python and asks for storage access) -curl -fsSL https://abdess.github.io/retrobios/i | sh - -# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Lakka, MiSTer, Onion, spruceOS or MinUI card -# is found by itself. For another card, --dest is its BIOS folder -# (RetroArch on Switch: retroarch/cores/system, on Android: RetroArch/system) -curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform retroarch --dest /path/to/sdcard/retroarch/cores/system - -# Options in PowerShell: anything after | iex goes to iex, so the script runs as a block -&([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1))) --check ``` +**A handheld, an SD card, or not sure?** [Pick the device](https://abdess.github.io/retrobios/which-pack/): its page gives the folder and the command. A muOS, Onion, spruceOS, MinUI, KNULLI, ROCKNIX, Lakka or MiSTer SD card plugged into the computer is found by itself. + ![Recording of the one-line command in a terminal. The installer finds an ArkOS SD card with 6 games, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary.](docs_assets/demo/install.gif) -The script opens a full-screen installer, driven with the arrow keys and Enter, or with a controller on a handheld. It finds your setup and its BIOS folder, downloads only the missing files and checks each one. It can also copy the BIOS files from your old SD card. -Every option, and what the installer detects and writes, is on the [installer page](https://abdess.github.io/retrobios/wiki/installer/#passing-options). +The installer finds the setup and its BIOS folder, installs the files it needs (only what the games need when it finds them on the card) and checks each one. Its screen installs everything, or fewer under **Choose systems**. Arrow keys and Enter drive it, or a controller on a handheld. It sends nothing back ([what is counted](https://abdess.github.io/retrobios/wiki/privacy/)). Options go after the line in the form the [installer page](https://abdess.github.io/retrobios/wiki/installer/#passing-options) gives. -First time? Follow the [first install, step by step](https://abdess.github.io/retrobios/wiki/first-install/). A game does not start: [what to check](https://abdess.github.io/retrobios/wiki/troubleshooting/). Other questions: the [FAQ](https://abdess.github.io/retrobios/wiki/faq/). +First time? [The first install, step by step](https://abdess.github.io/retrobios/wiki/first-install/). A game does not start: [what to check](https://abdess.github.io/retrobios/wiki/troubleshooting/). Other questions: [FAQ](https://abdess.github.io/retrobios/wiki/faq/). -## Download BIOS packs +## Or download a ZIP -A pack holds its platform's own BIOS list plus the files its emulator cores load. What no pack carries yet is in the [gap analysis](https://abdess.github.io/retrobios/gaps/). Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **Choose systems**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below. +The Files column counts what a pack holds once unzipped. -A pack over 2 GB comes in several parts (`Pack.part1of2.zip`, `Pack.part2of2.zip`), and every part is needed. Each part is an ordinary ZIP: extract them all into the same folder. - -Every release ships `SHA256SUMS.txt` and a detached signature of it, checkable against `allowed_signers` in this repository: [verifying a release](https://abdess.github.io/retrobios/wiki/release-process/#verifying-a-release). - -The Files column counts what a pack holds once extracted, the figure a file manager shows for the folder. It differs from one platform to the next because a pack carries only what that platform's emulators load. The size is what the files occupy once extracted, and the ZIP itself downloads smaller. - -| Platform | Files | Extracted size | Extract to | Download | -|----------|------:|---------------:|-----------|----------| -| AmberELEC | - | - | `/storage/roms/` | [Use the command](https://abdess.github.io/retrobios/get/amberelec/) | +| Setup | Files | Size | Unzip into | Download | +|-------|------:|-----:|------------|----------| | Batocera | 12,810 | 7.5 GB | `/userdata/bios/` | [Download](https://abdess.github.io/retrobios/get/batocera/) | -| BizHawk | - | - | `Firmware/` | [Use the command](https://abdess.github.io/retrobios/get/bizhawk/) | | EmuDeck | 620 | 3.2 GB | `~/Emulation/bios/` | [Download](https://abdess.github.io/retrobios/get/emudeck/) | -| EmuELEC | - | - | `/storage/roms/` | [Use the command](https://abdess.github.io/retrobios/get/emuelec/) | -| KNULLI | - | - | `/userdata/bios/` | [Use the command](https://abdess.github.io/retrobios/get/knulli/) | -| Lakka | - | - | `/storage/system/` | [Use the command](https://abdess.github.io/retrobios/get/lakka/) | | MiSTer FPGA | 83 | 29 MB | `/media/fat/games/` | [Download](https://abdess.github.io/retrobios/get/misterfpga/) | -| MinUI | - | - | `/` | [Use the command](https://abdess.github.io/retrobios/get/minui/) | -| MyMinUI | - | - | `/` | [Use the command](https://abdess.github.io/retrobios/get/myminui/) | -| NextUI | - | - | `/` | [Use the command](https://abdess.github.io/retrobios/get/nextui/) | -| ROCKNIX | - | - | `/storage/roms/bios/` | [Use the command](https://abdess.github.io/retrobios/get/rocknix/) | -| Recalbox | - | - | `/recalbox/share/bios/` | [Use the command](https://abdess.github.io/retrobios/get/recalbox/) | | RetroArch | 8,504 | 6.0 GB | `system/` | [Download](https://abdess.github.io/retrobios/get/retroarch/) | | RetroBat | 12,399 | 7.9 GB | `bios/` | [Download](https://abdess.github.io/retrobios/get/retrobat/) | -| RetroDECK | - | - | `~/retrodeck/` | [Use the command](https://abdess.github.io/retrobios/get/retrodeck/) | -| RetroPie | - | - | `~/RetroPie/BIOS/` | [Use the command](https://abdess.github.io/retrobios/get/retropie/) | | RomM | 1,087 | 1.5 GB | `library/bios/` | [Download](https://abdess.github.io/retrobios/get/romm/) | -ZIPs are made for the setups most people download. The others read "Use the command": the installer above installs every file for them and checks each one, and `--dry-run` shows what it would install ([why no ZIP](https://abdess.github.io/retrobios/wiki/faq/#why-is-there-no-zip-for-my-setup)). +ZIPs are made for the setups most people download. The others install with the line above, and each page gives the command: [AmberELEC](https://abdess.github.io/retrobios/get/amberelec/), [BizHawk](https://abdess.github.io/retrobios/get/bizhawk/), [EmuELEC](https://abdess.github.io/retrobios/get/emuelec/), [KNULLI](https://abdess.github.io/retrobios/get/knulli/), [Lakka](https://abdess.github.io/retrobios/get/lakka/), [MinUI](https://abdess.github.io/retrobios/get/minui/), [MyMinUI](https://abdess.github.io/retrobios/get/myminui/), [NextUI](https://abdess.github.io/retrobios/get/nextui/), [ROCKNIX](https://abdess.github.io/retrobios/get/rocknix/), [Recalbox](https://abdess.github.io/retrobios/get/recalbox/), [RetroDECK](https://abdess.github.io/retrobios/get/retrodeck/), [RetroPie](https://abdess.github.io/retrobios/get/retropie/). -## What's included +Unzip into the BIOS folder, files straight in it. A big pack comes in parts (`Pack.part1of2.zip`): download every part ([why some setups have no ZIP](https://abdess.github.io/retrobios/wiki/faq/#why-is-there-no-zip-for-my-setup)). Each release lists its files' SHA-256 with a signature ([verifying a release](https://abdess.github.io/retrobios/wiki/release-process/#verifying-a-release)). -BIOS, firmware, and system files for consoles from Atari to PlayStation 3. -These are the files an emulator loads from disk instead of carrying inside itself. Some are required to boot a system, others improve accuracy or unlock a feature, and the packs carry both. +## Help -Each file is checked the way your platform checks it. Most compare a checksum, the fingerprint of a file's contents, which catches a corrupt or unexpected copy. RetroArch, Lakka and RetroPie check that the file is present under its name without reading its contents (`path_is_valid` in RetroArch's `core_info.c`), and MinUI, NextUI and MyMinUI hand each core its folder without opening a file in it: the Coverage table says which applies to you. Independently of that, the collection records five fingerprints per file, and wherever an emulator's code states an expected size or hash, that value is read from its source and rechecked here. +- "BIOS missing" or a game does not start: [what to check](https://abdess.github.io/retrobios/wiki/troubleshooting/) +- Have a file? [Check it by its hash](https://abdess.github.io/retrobios/files/) +- Every console and the files it needs: [systems](https://abdess.github.io/retrobios/systems/) +- Asking an AI assistant: it reads the site best from [llms.txt](https://abdess.github.io/retrobios/llms.txt) -- **1,087 files** the platforms' emulators load are still to be found, and 138 more the collection does not hold and does not serve by name (retail data, per-user keys, user-filled slots), each with its reason. Both are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/) -- **18 platforms** supported with platform-specific verification -- **623 emulators** profiled from source (RetroArch cores + standalone) -- **532 systems** handled by those emulators (NES, SNES, PlayStation, Saturn, Dreamcast, ...) -- **19,947 files in the collection**, each with its SHA1, MD5, SHA256, CRC32 and Adler-32 fingerprints: 5,744 system files, 2,975 arcade ROM sets, 11,228 game and engine data files. That is every platform and emulator together, so no pack holds them all: each carries what its own emulators load, counted in the download table -- **566 of 5,744 system files** matched to dump-preservation catalogs (No-Intro, Redump, TOSEC). Arcade sets and engine data fall outside what those catalogs index -- **15.8 GB** for the whole collection +## What makes it different -## Supported systems +- **623 emulators profiled from their source code**: each file an emulator loads, its hash and the line of code that reads it +- **18 setups**, each checked its own way: MD5 for Batocera and Recalbox, the file name for RetroArch, as their own code does +- **532 systems**, 19,947 files in the collection, [what is still missing](https://abdess.github.io/retrobios/gaps/) named file by file +- Public data: [JSON, CSV and SQLite](https://abdess.github.io/retrobios/data/), MIT licensed -NES, SNES, Nintendo 64, GameCube, Wii, Game Boy, Game Boy Advance, Nintendo DS, Nintendo 3DS, Switch, PlayStation, PlayStation 2, PlayStation 3, PSP, PS Vita, Mega Drive, Saturn, Dreamcast, Game Gear, Master System, Neo Geo, Atari 2600, Atari 7800, Atari Lynx, Atari ST, MSX, PC Engine, TurboGrafx-16, ColecoVision, Intellivision, Commodore 64, Amiga, ZX Spectrum, Arcade (MAME), and 498+ more. - -Full list with per-file details: **[https://abdess.github.io/retrobios/](https://abdess.github.io/retrobios/)** - -## Coverage - -| Platform | On its BIOS list | Files its emulators load | Checked by | -|----------|-----------------:|-------------------------:|------------| -| AmberELEC | 414/434 | 4,754/4,937 | MD5 | -| Batocera | 353/353 | 14,171/15,291 | MD5 | -| BizHawk | 118/118 | - | SHA-1 | -| EmuDeck | 168/168 | 8,426/8,528 | MD5 | -| EmuELEC | 543/572 | 4,788/10,217 | MD5 | -| KNULLI | 290/290 | 9,126/15,350 | MD5 | -| Lakka | 532/532 | 7,909/7,994 | name only | -| MiSTer FPGA | 81/81 | - | MD5 | -| MinUI | 0/0 | 78/79 | name only | -| MyMinUI | 0/0 | 356/367 | name only | -| NextUI | 0/0 | 343/350 | name only | -| ROCKNIX | 38/38 | 7,427/9,139 | MD5 | -| Recalbox | 351/351 | 3,125/3,344 | MD5 | -| RetroArch | 526/526 | 7,918/7,985 | name only | -| RetroBat | 348/348 | 5,135/9,659 | MD5 | -| RetroDECK | 2,007/2,008 | 6,705/6,992 | MD5 | -| RetroPie | 532/532 | 8,117/12,771 | name only | -| RomM | 383/383 | 83/874 | size, then any hash | - -Each fraction is what the pack has over what is needed, counting required and optional files alike since both ship. The first column is the BIOS list the platform publishes. The second counts files its emulators load that this list never mentions, found by reading their source code, and it is routinely several times larger. A short fraction means files are still missing, and they are named in the [gap analysis](https://abdess.github.io/retrobios/gaps/). -That second number is a floor, not a ceiling: an emulator that accepts any file handed to it names none in its code, so nothing there can be counted. -Checked by is the test your platform runs on its own, replicated here from its source code, so the result matches what you would see in the frontend ([how each one works](https://abdess.github.io/retrobios/wiki/verification-modes/)). - -## Build your own pack +
+For developers: build a pack Packs are built from a clone, and the clone carries the collection: its checkout alone is over 7.7 GB. `git clone --depth 1 https://github.com/Abdess/retrobios.git` leaves the history out. Files kept as release assets are downloaded when a pack needs them. Then generate packs for any platform, emulator, or system: ```bash -# The pack of a platform, as released python scripts/generate_pack.py --platform retroarch --output-dir dist/ -python scripts/generate_pack.py --platform batocera --output-dir dist/ - -# Single emulator or system python scripts/generate_pack.py --emulator dolphin -python scripts/generate_pack.py --system sony-playstation-2 - -# One region, best first, one file per system and region -python scripts/generate_pack.py --platform retroarch --region us,eu,jp -python scripts/generate_pack.py --platform retroarch --region us --one-per-slot - -# Firmware only: the game data a list or a core names is left out, each file named -python scripts/generate_pack.py --platform emuelec --no-game-data - -# The bare minimum: only the files each core needs to start python scripts/generate_pack.py --platform batocera --required-only - -# List available emulators and systems -python scripts/generate_pack.py --list-emulators -python scripts/generate_pack.py --list-systems - -# Coverage of this repository's collection per platform, emulator or system -# (it reads database.json and bios/: for your own BIOS folder, run the installer with --check) -python scripts/verify.py --all python scripts/verify.py --platform batocera -python scripts/verify.py --emulator flycast -python scripts/verify.py --platform retroarch --verbose # emulator ground truth ``` -Requires Python 3.9 or newer and `pyyaml`. The one-line installer alone runs on Python 3.8. +Requires Python 3.9 or newer and `pyyaml`. The one-line installer alone runs on Python 3.8. Every tool and option: [tools](https://abdess.github.io/retrobios/wiki/tools/). How the lists are built: [architecture](https://abdess.github.io/retrobios/wiki/architecture/). -## Documentation site - -The [documentation site](https://abdess.github.io/retrobios/) provides: - -- **Per-platform pages** with file-by-file verification status and hashes -- **Per-emulator profiles** with source code references for every file -- **Per-system pages** showing which emulators and platforms cover each console -- **Gap analysis** identifying missing files and undeclared core requirements -- **Cross-reference** mapping files across 18 platforms and 623 emulators -- **Versioned data access** through JSON, CSV and SQLite exports with published SHA-256 checksums - -## How it works - -A platform's BIOS list and an emulator's code are maintained separately. Each list is compared with the emulator source code, file by file where a profile exists, and where the two differ the code decides. - -Hashes document what emulator code loads and accepts, not dump provenance. That boundary, and how it relates to preservation catalogs such as No-Intro, is drawn in the [technical questions](https://abdess.github.io/retrobios/wiki/technical-faq/#are-these-files-verified-against-original-hardware-dumps). - -1. **Read emulator source code**: trace every file the code loads, its expected hash and size -2. **Cross-reference with platforms**: match against what each platform declares -3. **Build packs**: include baseline files plus what each platform's cores need -4. **Verify**: run platform-native checks and emulator-level validation +
## Contributors @@ -196,7 +93,7 @@ Hashes document what emulator code loads and accepts, not dump provenance. That ## Community tools -- [BIOS Preservation Tool](https://github.com/monster-penguin/BIOS-Preservation-Tool) by [monster-penguin](https://github.com/monster-penguin) - scan, verify, and stage your own BIOS collection using RetroBIOS hash metadata +- [BIOS Preservation Tool](https://github.com/monster-penguin/BIOS-Preservation-Tool) by [monster-penguin](https://github.com/monster-penguin) - scans, verifies and stages a BIOS collection using RetroBIOS hash metadata ## Contributing @@ -208,4 +105,4 @@ The scripts and tooling are released under the [MIT License](LICENSE). The BIOS and firmware files are not covered by that license: they are third-party system software, preserved and provided for personal backup, archival, and interoperability with emulation software. [NOTICE](NOTICE) sets out their status and how to ask for a file to be removed. The reasoning, and where it is weakest, is in [the project's legal position](https://abdess.github.io/retrobios/wiki/legal/). -*Auto-generated on 2026-11-06T23:09:06Z* +*Auto-generated on 2026-11-09T06:27:53Z* diff --git a/docs_assets/demo/demo.json b/docs_assets/demo/demo.json index 4c62cb74c..f551f79c2 100644 --- a/docs_assets/demo/demo.json +++ b/docs_assets/demo/demo.json @@ -1,9 +1,23 @@ { - "width": 800, - "height": 460, - "setup": "ArkOS", - "games": 6, - "systems": 6, - "files": 43, - "size": "15.1 MB" + "default": { + "width": 800, + "height": 460, + "setup": "ArkOS", + "games": 6, + "systems": 6, + "files": 43, + "size": "15.1 MB", + "alt": "Recording of the one-line command in a terminal. The installer finds an ArkOS SD card with 6 games, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary." + }, + "handheld": { + "width": 640, + "height": 480, + "setup": "ArkOS", + "games": 6, + "systems": 6, + "files": 43, + "size": "15.1 MB", + "pad": "gptokeyb", + "alt": "Recording of the full-screen installer on the 640 by 480 screen of a handheld, started from the Tools menu of ArkOS. It finds the 6 games on the card, plans 43 BIOS files (15.1 MB) for them, installs them and shows the summary. The A button chooses, and the bottom line names the buttons." + } } diff --git a/docs_assets/demo/install-handheld.mp4 b/docs_assets/demo/install-handheld.mp4 new file mode 100644 index 000000000..7128c36cc Binary files /dev/null and b/docs_assets/demo/install-handheld.mp4 differ diff --git a/docs_assets/demo/install-handheld.png b/docs_assets/demo/install-handheld.png new file mode 100644 index 000000000..0ed8def30 Binary files /dev/null and b/docs_assets/demo/install-handheld.png differ diff --git a/docs_assets/demo/install-handheld.tape b/docs_assets/demo/install-handheld.tape new file mode 100644 index 000000000..9bde16879 --- /dev/null +++ b/docs_assets/demo/install-handheld.tape @@ -0,0 +1,43 @@ +# The installer demo of the "On the device" tab: the full-screen interface +# on ArkOS, at the 640x480 screen of an R36S, as the Tools menu launcher +# starts it. Run scripts/make_demo.py --variant handheld, which builds the +# card and ArkOS's root, serves the repository on loopback, starts VHS on +# this file from the repository root and encodes what it writes. Enter is +# what gptokeyb sends for A (ports/shared/retrobios.gptk). + +Output tmp/demo/recorded-handheld.mp4 + +Set Shell "bash" +Set FontFamily "DejaVu Sans Mono" +Set FontSize 16 +Set LineHeight 1.15 +Set Width 640 +Set Height 480 +Set Padding 8 +Set Framerate 25 +Set TypingSpeed 35ms +Set CursorBlink false +Set Theme { "name": "RetroBIOS", "background": "#12131c", "foreground": "#e6e6f0", "cursor": "#aebcff", "selection": "#3a3d52", "black": "#12131c", "red": "#ef9a9a", "green": "#a5d6a7", "yellow": "#ffcc80", "blue": "#90caf9", "magenta": "#aebcff", "cyan": "#aebcff", "white": "#cfd8dc", "brightBlack": "#7c82a3", "brightRed": "#ef9a9a", "brightGreen": "#a5d6a7", "brightYellow": "#ffcc80", "brightBlue": "#90caf9", "brightMagenta": "#c9d2ff", "brightCyan": "#c9d2ff", "brightWhite": "#e6e6f0" } + +# ArkOS's root, the card at /roms, the interface started. +Hide +Type 'exec "$RETROBIOS_DEMO_SESSION"' +Enter +Wait+Screen@60s /A Continue/ +Sleep 300ms +Show + +# The first screen: the system found, its BIOS folder. +Sleep 300ms +Screenshot tmp/demo/poster-handheld.png +Sleep 2500ms +Enter + +# A: the plan, only the files the games on the card need. +Wait+Screen@60s /A Install/ +Sleep 4500ms +Enter + +# A: install. The progress, then the summary. +Wait+Screen@60s /RetroBIOS: Done/ +Sleep 4500ms diff --git a/docs_assets/demo/install-handheld.webm b/docs_assets/demo/install-handheld.webm new file mode 100644 index 000000000..6614dd89c Binary files /dev/null and b/docs_assets/demo/install-handheld.webm differ diff --git a/docs_assets/demo/install.gif b/docs_assets/demo/install.gif index 72894041f..6398d217d 100644 Binary files a/docs_assets/demo/install.gif and b/docs_assets/demo/install.gif differ diff --git a/docs_assets/demo/install.mp4 b/docs_assets/demo/install.mp4 index 8b17f70e6..20dd11de6 100644 Binary files a/docs_assets/demo/install.mp4 and b/docs_assets/demo/install.mp4 differ diff --git a/docs_assets/demo/install.webm b/docs_assets/demo/install.webm index 95c1bf534..f343986cc 100644 Binary files a/docs_assets/demo/install.webm and b/docs_assets/demo/install.webm differ diff --git a/platforms/_guides.yml b/platforms/_guides.yml index 02505e831..6f1fdc69e 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -97,21 +97,25 @@ terminals: windows: label: Windows how: >- - Open the Start menu. Type **PowerShell** and open it. Paste the line. Press Enter. + Open Start. Type **PowerShell**, press Enter. Click Copy. Right-click to paste. Press + Enter. shell: powershell macos: label: macOS how: >- - Open **Terminal**, in Applications, then Utilities. Paste the line. Press Return. + Press Cmd+Space. Type **Terminal**, press Return. Click Copy. Press Cmd+V. Press + Return. shell: sh linux: label: Linux - how: Open a terminal. Paste the line. Press Enter. + how: >- + Open a terminal, often with Ctrl+Alt+T. Click Copy. Press Ctrl+Shift+V. Press Enter. shell: sh steamdeck: label: Steam Deck how: >- - Switch to Desktop Mode. Open **Konsole**. Paste with Ctrl+Shift+V. Press Enter. + Hold power, pick **Switch to Desktop**. Open **Konsole**. Click Copy. Press + Ctrl+Shift+V. Press Enter. shell: sh android: label: Android (Termux) diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index f7b8aa383..9c6ec4084 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -476,17 +476,17 @@ def clone_line(db: dict) -> str: ) -def demo_facts() -> dict: - """What the installer demo shows, as make_demo.py measured it.""" - return json.loads((DEMO_DIR / "demo.json").read_text(encoding="utf-8")) +def demo_facts(variant: str = "default") -> dict: + """What an installer demo shows, as make_demo.py measured it: one entry + per variant (default, handheld).""" + facts = json.loads((DEMO_DIR / "demo.json").read_text(encoding="utf-8")) + return facts[variant] def demo_description(facts: dict | None = None) -> str: - """The demo in words, for a reader who cannot see it.""" - facts = facts or demo_facts() - return (f"Recording of the one-line command in a terminal. The installer finds an " - f"{facts['setup']} SD card with {facts['games']} games, plans {facts['files']} " - f"BIOS files ({facts['size']}) for them, installs them and shows the summary.") + """The demo in words, for a reader who cannot see it: the alt text + make_demo.py wrote from what it measured.""" + return str((facts or demo_facts())["alt"]) def _existing_contributor_block(readme_path: str = "README.md") -> list[str]: @@ -594,6 +594,15 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json" system_ids = system_index(profiles) + # The README is the repository's front page and its most read page: a + # visitor from a search lands here, often new to terminals. It says what + # the project does, gives the one line that installs, shows it, and leads + # on to the site, where every detail lives. The figures and the lists it + # once carried (coverage, collection, build commands) are the site's. + record = release_record.load_advertised(platforms_dir=platforms_dir) + extract_paths = _extract_cells(platforms_dir) + released = [name for name in sorted(coverages, key=lambda n: coverages[n]["platform"]) + if release_totals(name, record)[0]] lines = [ '

', " ", @@ -602,281 +611,118 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json" " ", "

", "", - '

', - ' ' - 'Site', + '

The BIOS files emulators need, put in the right folder and checked.

', + "", + '

' + f'Website · ' + f'Get the files · ' + f'A game does not start · ' + f'Questions' "

", "", - f"BIOS and firmware files for {_names_list(coverages)}.", + "Some consoles need a small program, the **BIOS**, to start their games: PlayStation 1" + " and 2, Saturn, Neo Geo, Dreamcast and more. RetroBIOS installs the BIOS and firmware" + f" files for {_names_list(coverages)}, checked the way each one checks them.", "", - "A pack holds the files its emulators read, as their source code names" - " them, and leaves out what the emulators install themselves.", + "## Install in one line", "", - "## Quick install", + "**Windows**: open the Start menu, type **PowerShell** and open it. Paste this line" + " (right-click in the window) and press Enter.", "", - "Copy one command into your terminal:", + "```powershell", + install_command("powershell"), + "```", + "", + "**Linux, macOS, Steam Deck, Android (Termux)**: open a terminal, paste this line" + " and press Enter.", "", "```bash", - "# Linux / macOS / Steam Deck", install_command("sh"), - "", - "# Windows (PowerShell)", - install_command("powershell"), - "", - "# Android (Termux: it installs Python and asks for storage access)", - install_command("sh"), - "", - "# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Lakka, MiSTer, Onion, spruceOS or MinUI card", - "# is found by itself. For another card, --dest is its BIOS folder", - "# (RetroArch on Switch: retroarch/cores/system, on Android: RetroArch/system)", - install_command("sh", "--platform retroarch --dest /path/to/sdcard/retroarch/cores/system"), - "", - "# Options in PowerShell: anything after | iex goes to iex, so the script runs as a block", - install_command("powershell", "--check"), "```", "", + f"**A handheld, an SD card, or not sure?** [Pick the device]({SITE_URL}which-pack/):" + " its page gives the folder and the command. A muOS, Onion, spruceOS, MinUI, KNULLI," + " ROCKNIX, Lakka or MiSTer SD card plugged into the computer is found by itself.", + "", f"![{demo_description()}]({DEMO_GIF})", "", - "The script opens a full-screen installer, driven with the arrow keys and Enter, or with a" - " controller on a handheld. It finds your setup and its BIOS folder, downloads only the missing" - " files and checks each one. It can also copy the BIOS files from your old SD card.", - f"Every option, and what the installer detects and writes, is on the [installer page]({SITE_URL}wiki/installer/#passing-options).", + "The installer finds the setup and its BIOS folder, installs the files it needs (only" + " what the games need when it finds them on the card) and checks each one. Its screen" + " installs everything, or fewer under **" + _installer_words().TUI_SYSTEMS_ROW + "**." + " Arrow keys and Enter drive it, or a controller on a handheld. It sends nothing back" + f" ([what is counted]({SITE_URL}wiki/privacy/)). Options go after the line in the" + f" form the [installer page]({SITE_URL}wiki/installer/#passing-options) gives.", "", - f"First time? Follow the [first install, step by step]({SITE_URL}wiki/first-install/)." + f"First time? [The first install, step by step]({SITE_URL}wiki/first-install/)." f" A game does not start: [what to check]({SITE_URL}wiki/troubleshooting/)." - f" Other questions: the [FAQ]({SITE_URL}wiki/faq/).", + f" Other questions: [FAQ]({SITE_URL}wiki/faq/).", "", - "## Download BIOS packs", + "## Or download a ZIP", "", - f"A pack holds its platform's own BIOS list plus the files its emulator cores load. What no pack carries yet is in the [gap analysis]({SITE_URL}gaps/). Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **" + _installer_words().TUI_SYSTEMS_ROW + "**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.", - "", - "A pack over 2 GB comes in several parts (`Pack.part1of2.zip`," - " `Pack.part2of2.zip`), and every part is needed. Each part is an" - " ordinary ZIP: extract them all into the same folder.", - "", - "Every release ships `SHA256SUMS.txt` and a detached signature of it," - " checkable against `allowed_signers` in this repository:" - " [verifying a release]" - "(https://abdess.github.io/retrobios/wiki/release-process/" - "#verifying-a-release).", - "", - "The Files column counts what a pack holds once extracted, the figure a" - " file manager shows for the folder. It differs from one platform to" - " the next because a pack carries only what that platform's emulators" - " load. The size is what the files occupy once extracted, and the ZIP" - " itself downloads smaller.", + "The Files column counts what a pack holds once unzipped.", "", + "| Setup | Files | Size | Unzip into | Download |", + "|-------|------:|-----:|------------|----------|", ] - - # Where the pack itself is extracted, which is not always the BIOS folder: - # a pack whose entries already carry their own root (RetroDECK) extracts - # one level above it. - extract_paths = _extract_cells(platforms_dir) - - lines.extend( - download_table( - coverages, extract_paths, - release_record.load_advertised(platforms_dir=platforms_dir), - ) - ) - lines.extend(["", no_pack_line()]) - - lines.extend(_paragraphs(extract_notes(platforms_dir, released_only=True))) - - lines.extend( - [ - "", - "## What's included", - "", - "BIOS, firmware, and system files for consoles from Atari to PlayStation 3.", - "These are the files an emulator loads from disk instead of" - " carrying inside itself. Some are required to boot a system," - " others improve accuracy or unlock a feature, and the packs carry" - " both.", - "", - "Each file is checked the way your platform checks it. Most compare" - " a checksum, the fingerprint of a file's contents, which catches a" - " corrupt or unexpected copy. RetroArch, Lakka and RetroPie check" - " that the file is present under its name without reading its" - " contents (`path_is_valid` in RetroArch's `core_info.c`), and" - " MinUI, NextUI and MyMinUI hand each core its folder without" - " opening a file in it: the" - " Coverage table says which applies to you. Independently of that," - " the collection records five fingerprints per file, and wherever" - " an emulator's code states an expected size or hash, that value is" - " read from its source and rechecked here.", - "", - ( - f"- **{acquirable_total:,} files** the platforms' emulators load" - f" are still to be found, and {unsourceable_total:,} more the" - f" collection does not hold and does not serve by name (retail" - f" data, per-user keys, user-filled slots), each with its reason." - f" Both are named in the" - f" [gap analysis]({SITE_URL}gaps/)" - if missing_total - else "- **Nothing missing**: every file the platforms'" - " emulators load is in the collection" - ), - f"- **{len(coverages)} platforms** supported with platform-specific verification", - f"- **{emulator_count} emulators** profiled from source (RetroArch cores + standalone)", - f"- **{len(system_ids)} systems** handled by those emulators (NES, SNES, PlayStation, Saturn, Dreamcast, ...)", - collection_line(total_files, comp), - *_catalog_matched_line(db), - f"- **{format_size(total_size)}** for the whole collection", - "", - "## Supported systems", - "", - ] - ) - - # Show well-known systems for SEO, link to full list - well_known = [ - "NES", - "SNES", - "Nintendo 64", - "GameCube", - "Wii", - "Game Boy", - "Game Boy Advance", - "Nintendo DS", - "Nintendo 3DS", - "Switch", - "PlayStation", - "PlayStation 2", - "PlayStation 3", - "PSP", - "PS Vita", - "Mega Drive", - "Saturn", - "Dreamcast", - "Game Gear", - "Master System", - "Neo Geo", - "Atari 2600", - "Atari 7800", - "Atari Lynx", - "Atari ST", - "MSX", - "PC Engine", - "TurboGrafx-16", - "ColecoVision", - "Intellivision", - "Commodore 64", - "Amiga", - "ZX Spectrum", - "Arcade (MAME)", - ] - lines.extend( - [ - ", ".join(well_known) + f", and {len(system_ids) - len(well_known)}+ more.", - "", - f"Full list with per-file details: **[{SITE_URL}]({SITE_URL})**", - "", - "## Coverage", - "", - "| Platform | On its BIOS list | Files its emulators load | Checked by |", - "|----------|-----------------:|-------------------------:|------------|", - ] - ) - - for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): - display = cov["platform"] - checked = check_label(cov["mode"], cov["config"].get("hash_rule")) - core_total = ( - cov["core_present"] + cov["core_missing"] + cov["core_unsourceable"] - ) - core_cell = f"{cov['core_present']:,}/{core_total:,}" if core_total else "-" + for name in released: + files, size = release_totals(name, record) + display = coverages[name]["platform"] lines.append( - f"| {display} | {cov['present']:,}/{cov['total']:,} |" - f" {core_cell} | {checked} |" + f"| {display} | {files:,} | {format_size(size) if size else '-'} |" + f" {extract_paths.get(display, '')} |" + f" {download_links(name, record, 'Download', site=SITE_URL)} |" ) - - lines.extend( - [ - "", - "Each fraction is what the pack has over what is needed, counting" - " required and optional files alike since both ship. The first" - " column is the BIOS list the platform publishes. The second counts" - " files its emulators load that this list never mentions, found by" - " reading their source code, and it is routinely several times" - " larger. A short fraction means files are still missing, and they" - " are named in the" - f" [gap analysis]({SITE_URL}gaps/).", - "That second number is a floor, not a ceiling: an emulator that" - " accepts any file handed to it names none in its code, so nothing" - " there can be counted.", - "Checked by is the test your platform runs on its own, replicated" - " here from its source code, so the result matches what you would" - f" see in the frontend ([how each one works]({SITE_URL}wiki/verification-modes/)).", - "", - "## Build your own pack", - "", - clone_line(db), - "", - "```bash", - "# The pack of a platform, as released", - "python scripts/generate_pack.py --platform retroarch --output-dir dist/", - "python scripts/generate_pack.py --platform batocera --output-dir dist/", - "", - "# Single emulator or system", - "python scripts/generate_pack.py --emulator dolphin", - "python scripts/generate_pack.py --system sony-playstation-2", - "", - "# One region, best first, one file per system and region", - "python scripts/generate_pack.py --platform retroarch --region us,eu,jp", - "python scripts/generate_pack.py --platform retroarch --region us --one-per-slot", - "", - "# Firmware only: the game data a list or a core names is left out, each file named", - "python scripts/generate_pack.py --platform emuelec --no-game-data", - "", - "# The bare minimum: only the files each core needs to start", - "python scripts/generate_pack.py --platform batocera --required-only", - "", - "# List available emulators and systems", - "python scripts/generate_pack.py --list-emulators", - "python scripts/generate_pack.py --list-systems", - "", - "# Coverage of this repository's collection per platform, emulator or system", - "# (it reads database.json and bios/: for your own BIOS folder, run the installer with --check)", - "python scripts/verify.py --all", - "python scripts/verify.py --platform batocera", - "python scripts/verify.py --emulator flycast", - "python scripts/verify.py --platform retroarch --verbose # emulator ground truth", - "```", - "", - "Requires Python 3.9 or newer and `pyyaml`. The one-line installer alone runs on Python 3.8.", - "", - "## Documentation site", - "", - f"The [documentation site]({SITE_URL}) provides:", - "", - "- **Per-platform pages** with file-by-file verification status and hashes", - "- **Per-emulator profiles** with source code references for every file", - "- **Per-system pages** showing which emulators and platforms cover each console", - "- **Gap analysis** identifying missing files and undeclared core requirements", - f"- **Cross-reference** mapping files across {len(coverages)} platforms and {emulator_count} emulators", - "- **Versioned data access** through JSON, CSV and SQLite exports with published SHA-256 checksums", - "", - "## How it works", - "", - "A platform's BIOS list and an emulator's code are maintained" - " separately. Each list is compared with the emulator source code," - " file by file where a profile exists, and where the two differ the" - " code decides.", - "", - "Hashes document what emulator code loads and accepts, not dump" - " provenance. That boundary, and how it relates to preservation" - " catalogs such as No-Intro, is drawn in the" - f" [technical questions]({SITE_URL}wiki/technical-faq/" - "#are-these-files-verified-against-original-hardware-dumps).", - "", - "1. **Read emulator source code**: trace every file the code loads, its expected hash and size", - "2. **Cross-reference with platforms**: match against what each platform declares", - "3. **Build packs**: include baseline files plus what each platform's cores need", - "4. **Verify**: run platform-native checks and emulator-level validation", - "", - ] - ) + others = [name for name in sorted(coverages, key=lambda n: coverages[n]["platform"]) + if name not in released] + lines.extend([ + "", + "ZIPs are made for the setups most people download. The others install with the line" + " above, and each page gives the command: " + + ", ".join(f"[{coverages[name]['platform']}]({SITE_URL}get/{name}/)" for name in others) + + ".", + "", + "Unzip into the BIOS folder, files straight in it. A big pack comes in parts" + " (`Pack.part1of2.zip`): download every part" + f" ([why some setups have no ZIP]({SITE_URL}wiki/faq/#why-is-there-no-zip-for-my-setup))." + " Each release lists its files' SHA-256 with a signature" + f" ([verifying a release]({SITE_URL}wiki/release-process/#verifying-a-release)).", + "", + "## Help", + "", + f"- \"BIOS missing\" or a game does not start: [what to check]({SITE_URL}wiki/troubleshooting/)", + f"- Have a file? [Check it by its hash]({SITE_URL}files/)", + f"- Every console and the files it needs: [systems]({SITE_URL}systems/)", + f"- Asking an AI assistant: it reads the site best from [llms.txt]({SITE_URL}llms.txt)", + "", + "## What makes it different", + "", + f"- **{emulator_count} emulators profiled from their source code**: each file an emulator" + " loads, its hash and the line of code that reads it", + f"- **{len(coverages)} setups**, each checked its own way: MD5 for Batocera and Recalbox," + " the file name for RetroArch, as their own code does", + f"- **{len(system_ids)} systems**, {total_files:,} files in the collection," + f" [what is still missing]({SITE_URL}gaps/) named file by file", + f"- Public data: [JSON, CSV and SQLite]({SITE_URL}data/), MIT licensed", + "", + "
", + "For developers: build a pack", + "", + clone_line(db), + "", + "```bash", + "python scripts/generate_pack.py --platform retroarch --output-dir dist/", + "python scripts/generate_pack.py --emulator dolphin", + "python scripts/generate_pack.py --platform batocera --required-only", + "python scripts/verify.py --platform batocera", + "```", + "", + "Requires Python 3.9 or newer and `pyyaml`. The one-line installer alone runs on Python 3.8." + f" Every tool and option: [tools]({SITE_URL}wiki/tools/). How the lists are built:" + f" [architecture]({SITE_URL}wiki/architecture/).", + "", + "
", + "", + ]) contributors = fetch_contributors() if not contributors: @@ -910,7 +756,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json" "", "- [BIOS Preservation Tool](https://github.com/monster-penguin/BIOS-Preservation-Tool)" " by [monster-penguin](https://github.com/monster-penguin)" - " - scan, verify, and stage your own BIOS collection" + " - scans, verifies and stages a BIOS collection" " using RetroBIOS hash metadata", "", "## Contributing", @@ -1053,7 +899,7 @@ handheld launchers, `bwrap` (bubblewrap) for the probe sandbox, and a Python 3.8 with pyte (`{floor_line}`) for the full screen. CI has all three. `profile_sync.py` compares the branch with this repository's `main`, and -fetches it in a fork's clone. A profile you only edit is not held to +fetches it in a fork's clone. An edited profile is not held to `--strict`: its older refs are reviewed by hand. The full pipeline is not needed for a contribution: it rebuilds every pack diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 9cda2958f..ae861b23a 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -96,6 +96,7 @@ import siteinputs import retired_files import siteagents import sitestarlight +import sitesteps from siteexports import generate_exports_page, load_export_reports from siteguides import ( ALSO_RUNS, @@ -4180,16 +4181,19 @@ def first_screen_words() -> list[str]: # The demo files the site serves (docs_assets/demo/, scripts/make_demo.py): # the GIF is the README's, the tape and demo.json are inputs. -DEMO_ASSETS = ("install.webm", "install.mp4", "install.png") +DEMO_ASSETS = tuple(f"{stem}.{ext}" for stem in ("install", "install-handheld") + for ext in ("webm", "mp4", "png")) -def install_demo() -> list[str]: - """The installer demo: a video that loads once it is on screen, its - description for screen readers.""" - facts = demo_facts() +def install_demo(variant: str = "default", name: str = "") -> list[str]: + """An installer demo (default, or handheld): a video that loads once it + is on screen, its description for screen readers. *name* tells two + demos of one page apart (the tab each sits in).""" + facts = demo_facts(variant) text = html.escape(demo_description(facts), quote=True) + own = f' variant="{variant}" name="{name}"' if name else "" return [f'', ""] + f'height="{facts["height"]}"{own} />', ""] def check_pass_line() -> str: @@ -11942,6 +11946,10 @@ def generate_which_pack() -> str: display = {key: load_platform_config(key, str(siteinputs.current().platforms)).get("platform", key) for key in registry} all_tabs = ("windows", "macos", "linux", "steamdeck", "android") + # The quick install box also runs on a game system: its tab shows the + # handheld recording (sitesteps). + box_tabs = (*all_tabs, "device") + box_steps, box_demos = sitesteps.install_box(box_tabs) lines = [ "---", f"terms: {', '.join(IOS_TERMS)}", @@ -11988,13 +11996,12 @@ def generate_which_pack() -> str: "", "A **terminal** is a window where you type commands.", "", - *terminal_tabs(all_tabs, {}), + *terminal_tabs(box_tabs, {}, under_steps=box_steps, under_command=box_demos), "On Batocera, Recalbox and other game systems, run it on the device. " "Your setup's page shows how.", "", *first_screen_words(), "", - *install_demo(), *trust_box(""), "**Check your folder later.** Add `--check`. It changes nothing.", "", diff --git a/scripts/make_demo.py b/scripts/make_demo.py index 2bc1dd289..40346d8cc 100644 --- a/scripts/make_demo.py +++ b/scripts/make_demo.py @@ -1,35 +1,53 @@ #!/usr/bin/env python3 -"""Record the installer demo shown in the README and on the site, once. +"""Record the installer demos shown in the README and on the site, once. -The recording is the full-screen installer run from the one-line command on -an ArkOS SD card that holds six games: it finds the card, plans only the BIOS -files those games need, installs them and shows the summary. Everything it -reads is local and the same on every machine: +Each recording is the installer on an ArkOS SD card that holds six games: it +finds the card, plans only the BIOS files those games need, installs them +and shows the summary. Two variants, by where the install box says the +command runs: + +- default: the one-line command in a terminal on a Linux computer, the card + mounted at /media/EASYROMS. The README and every tab but one show it. +- handheld: the full-screen interface on ArkOS itself, at the 640x480 + screen of an R36S, as the Tools menu launcher starts it (ports/shared/ + tui.txt: install.py --tui, RETROBIOS_PAD=gptokeyb, RETROBIOS_IDLE_EXIT). + The keys typed are the ones gptokeyb sends for the buttons + (ports/shared/retrobios.gptk: A is Enter), and the bottom line names the + buttons. The "On the device" tab shows it. + +No Windows variant: pwsh on Linux runs install.ps1's whole flow, but the +installer reads drive E: there only through its WSL layout, and its summary +gives the command back with --dest /mnt/e/bios, a path no Windows user sees. + +Everything a recording reads is local and the same on every machine: - The card is built under tmp/demo/ (bios/, the folders ArkOS writes on its - games partition, one game per console) and mounted at /media/EASYROMS in a - mount namespace of this user (unshare), on a tmpfs of a card's size. /media - and /mnt are emptied there, so no drive of this machine is shown. + games partition, one game per console) on a tmpfs of a card's size, in a + mount namespace of this user (unshare, or bwrap for the handheld, whose + root holds ArkOS's /home/ark, /opt/system and /roms). /media and /mnt are + emptied, so no drive of this machine is shown. - The repository is served on 127.0.0.1 and named with RETROBIOS_BASE_URL: the file lists, install/games/ and the BIOS files are this checkout's. -- The one-line command runs this checkout's install.sh and install.py. The - shell of the recording answers `curl -fsSL /i` with a line that - starts the local install.sh, which skips the download of the installer and - its SHA-256 check and runs the rest of the bootstrap as the site's copy does. +- The one-line command runs this checkout's install.sh: the shell answers + `curl -fsSL /i` with a line that starts the local install.sh, which + skips the download of the installer and its SHA-256 check and runs the + rest of the bootstrap as the site's copy does. - HOME is an empty folder and the prompt is "$ ". -docs_assets/demo/install.tape drives VHS (github.com/charmbracelet/vhs, with -ttyd, ffmpeg and a Chromium on PATH). This script encodes what it records: +docs_assets/demo/.tape drives VHS (github.com/charmbracelet/vhs, with +ttyd, ffmpeg and a Chromium on PATH, and bwrap for the handheld). This +script encodes what it records: - docs_assets/demo/install.gif the README - docs_assets/demo/install.webm the site, with install.mp4 as fallback - docs_assets/demo/install.png the first frame shown, the video's poster - docs_assets/demo/demo.json what the recording shows, for the alt text + docs_assets/demo/install.gif the README (the default variant only) + docs_assets/demo/.webm the site, with .mp4 as fallback + docs_assets/demo/.png the first frame shown, the video's poster + docs_assets/demo/demo.json per variant, what it shows and its alt text The site build copies the video files and never runs this script. Run it again when a screen of the installer or the file list of its games changes: - python scripts/make_demo.py + python scripts/make_demo.py # both variants + python scripts/make_demo.py --variant handheld # one, the others kept """ from __future__ import annotations @@ -48,20 +66,72 @@ import sys import threading from collections.abc import Iterator from contextlib import contextmanager +from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "docs_assets" / "demo" -TAPE = OUT / "install.tape" WORK = ROOT / "tmp" / "demo" -# What the tape writes, read back here (paths relative to ROOT, the tape's -# working directory). -RECORDED = WORK / "recorded.mp4" -POSTER_SHOT = WORK / "poster.png" -MOUNT = "/media/EASYROMS" +FACTS = OUT / "demo.json" CARD_SIZE = "29g" SETUP = "ArkOS" + +@dataclass(frozen=True) +class Variant: + """One recording. *place* is "computer" (the card in a reader, the + one-line command typed) or "device" (the interface on the card's own + system). *os_type* is RETROBIOS_OS for the run.""" + + name: str + stem: str + mount: str + os_type: str + place: str + gif: bool = False + + @property + def tape(self) -> Path: + return OUT / f"{self.stem}.tape" + + @property + def suffix(self) -> str: + return "" if self.name == "default" else f"-{self.name}" + + @property + def recorded(self) -> Path: + """What the tape writes, read back here (relative to ROOT, the + tape's working directory).""" + return WORK / f"recorded{self.suffix}.mp4" + + @property + def poster_shot(self) -> Path: + return WORK / f"poster{self.suffix}.png" + + @property + def outputs(self) -> tuple[str, ...]: + names = (f"{self.stem}.webm", f"{self.stem}.mp4", f"{self.stem}.png") + return ((f"{self.stem}.gif",) if self.gif else ()) + names + + +VARIANTS: dict[str, Variant] = { + "default": Variant("default", "install", "/media/EASYROMS", "linux", "computer", + gif=True), + "handheld": Variant("handheld", "install-handheld", "/roms", "linux", "device"), +} +# The default variant's paths, as the tests and the README name them. +TAPE = VARIANTS["default"].tape +RECORDED = VARIANTS["default"].recorded +POSTER_SHOT = VARIANTS["default"].poster_shot +MOUNT = VARIANTS["default"].mount + +# Where ArkOS keeps what the handheld recording needs: the Tools menu folder +# the launcher sits in (ports/tools/RetroBIOS.sh, RB_DIR), the user, and +# EASYROMS at /roms (install.py _OS_RULES, ArkOS). +ARK_HOME = "/home/ark" +ARK_TOOLS = "/opt/system/Tools" +ARK_INSTALLER = f"{ARK_TOOLS}/retrobios/install.py" + # The card: the folders ArkOS's default tree writes on EASYROMS (install.py # _GAMES_PART_RULES reads launchimages/), and one game per console folder, # named as the folder's extensions in install/games/setups/arkos.json allow. @@ -79,13 +149,18 @@ GAME_BYTES = 256 * 1024 # cores read the region from (install/games/systems/sony-playstation.json). PSX_LICENCE = b" Licensed by Sony Computer Entertainment Amer ica " -# Size targets of the outputs, in bytes: the README and two site pages. -LIMITS = {"install.gif": 1_500_000, "install.webm": 1_000_000, - "install.mp4": 1_000_000, "install.png": 150_000} +# Size targets of the outputs, in bytes, by extension: the README and the +# site's pages. +LIMITS = {".gif": 1_500_000, ".webm": 1_000_000, ".mp4": 1_000_000, ".png": 150_000} GIF_FPS = 10 VIDEO_FPS = 25 +def limits(variant: Variant) -> dict[str, int]: + """Each output of a variant and its size target.""" + return {name: LIMITS[Path(name).suffix] for name in variant.outputs} + + def game_bytes(name: str) -> bytes: """Bytes of a game no catalog names, the same on every run.""" seed = hashlib.sha256(name.encode("utf-8")).digest() @@ -133,10 +208,9 @@ def served_repository() -> Iterator[str]: server.server_close() -def write_session(work: Path, names: list[str]) -> Path: - """The program the recording's shell becomes: the card mounted in a - namespace of its own, then a clean interactive bash, or the command - given as arguments, with only the variables names lists.""" +def _write_rc(work: Path) -> Path: + """The startup file of the shell the recording types into: the prompt, + no history, and the site's address answered with the local bootstrap.""" rc = work / "bashrc" rc.write_text( "PS1='$ '\n" @@ -151,67 +225,144 @@ def write_session(work: Path, names: list[str]) -> Path: "clear\n", encoding="utf-8", ) - session = work / "session.sh" - session.write_text( - "#!/bin/sh\n" - "# Written by scripts/make_demo.py.\n" - "set -eu\n" + return rc + + +def _computer_session(variant: Variant, work: Path, names: list[str]) -> str: + rc = _write_rc(work) + interactive = f'bash --noprofile --rcfile "{rc}" -i' + mount = variant.mount + return ( "inner='set -eu\n" "mount -t tmpfs -o mode=0755 demo /media\n" "mount -t tmpfs -o mode=0755 demo /mnt\n" - f"mkdir {MOUNT}\n" - f"mount -t tmpfs -o size={CARD_SIZE},mode=0755 EASYROMS {MOUNT}\n" - f'cp -a "$RETROBIOS_DEMO_CARD/." {MOUNT}/\n' + f"mkdir {mount}\n" + f"mount -t tmpfs -o size={CARD_SIZE},mode=0755 EASYROMS {mount}\n" + f'cp -a "$RETROBIOS_DEMO_CARD/." {mount}/\n' 'cd "$HOME"\n' 'exec unshare --user --map-user="$RETROBIOS_DEMO_UID" ' '--map-group="$RETROBIOS_DEMO_GID" env -i ' + " ".join(f'{name}="${name}"' for name in names) + ' "$@"\'\n' 'if [ "$#" -eq 0 ]; then\n' - f' set -- bash --noprofile --rcfile "{rc}" -i\n' + f" set -- {interactive}\n" "fi\n" - 'exec unshare --user --mount --map-root-user /bin/sh -c "$inner" sh "$@"\n', - encoding="utf-8", + 'exec unshare --user --mount --map-root-user /bin/sh -c "$inner" sh "$@"\n' ) + + +def _device_session(names: list[str]) -> str: + """ArkOS's own root, made of this machine's /usr and /etc: the user and + the Tools menu folder ArkOS is recognised by, EASYROMS at /roms, and + the installer where the Tools menu launcher keeps it.""" + binds = " ".join( + f"--symlink usr/{name} /{name}" for name in ("bin", "lib", "lib64", "sbin")) + return ( + 'if [ "$#" -eq 0 ]; then\n' + f' set -- python3 {ARK_INSTALLER} --tui\n' + "fi\n" + "exec bwrap --unshare-user --unshare-pid --unshare-ipc --unshare-uts --hostname arkos " + '--uid "$RETROBIOS_DEMO_UID" --gid "$RETROBIOS_DEMO_GID" ' + f"--ro-bind /usr /usr {binds} --ro-bind /etc /etc --proc /proc --dev /dev " + f"--tmpfs /tmp --dir {ARK_HOME} --dir /opt/inttools --dir {ARK_TOOLS} " + f'--ro-bind "$RETROBIOS_DEMO_INSTALLER" {ARK_INSTALLER} ' + f'--ro-bind "$RETROBIOS_DEMO_CARD" /card --size 31138512896 --tmpfs /roms ' + f"--chdir {ARK_HOME} " + "/bin/sh -c 'cp -a /card/. /roms/ && exec env -i \"$@\"' sh " + + " ".join(f'{name}="${name}"' for name in names) + ' "$@"\n' + ) + + +def write_session(work: Path, names: list[str], variant: Variant = VARIANTS["default"]) -> Path: + """The program the recording's shell becomes: the card mounted in a + namespace of its own, then a clean interactive shell (or, on the + device, the interface), or the command given as arguments, with only + the variables names lists.""" + body = (_device_session(names) if variant.place == "device" + else _computer_session(variant, work, names)) + session = work / f"session{variant.suffix}.sh" + session.write_text("#!/bin/sh\n# Written by scripts/make_demo.py.\nset -eu\n" + body, + encoding="utf-8") session.chmod(0o755) return session -def session_env(base_url: str, work: Path, card: Path) -> dict[str, str]: +def session_env(base_url: str, work: Path, card: Path, + variant: Variant = VARIANTS["default"]) -> dict[str, str]: + """The variables of the run, and of nothing else: the namespace starts + from an empty environment.""" + own = { + "RETROBIOS_DEMO_CARD": str(card), + "RETROBIOS_DEMO_UID": str(os.getuid()), + "RETROBIOS_DEMO_GID": str(os.getgid()), + } + if variant.place == "device": + return { + **own, + "HOME": ARK_HOME, + "PATH": "/usr/local/bin:/usr/bin:/bin", + "LANG": "C.UTF-8", + # ArkOS's console (ports/tools/RetroBIOS.sh: console 1). + "TERM": "linux", + "RETROBIOS_OS": variant.os_type, + "RETROBIOS_BASE_URL": base_url, + # What ports/shared/tui.txt exports before the interface starts. + "RETROBIOS_PAD": "gptokeyb", + "RETROBIOS_IDLE_EXIT": "300", + "RETROBIOS_DEMO_INSTALLER": str(ROOT / "install.py"), + } home = work / "home" home.mkdir(parents=True, exist_ok=True) return { + **own, "HOME": str(home), "PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8", "TERM": "xterm-256color", "TMPDIR": str(work / "scratch"), - "RETROBIOS_OS": "linux", + "RETROBIOS_OS": variant.os_type, "RETROBIOS_BASE_URL": base_url, - # What the downloaded bootstrap sets: commands given back are the - # one-line command, not a path to a temporary copy. + # What the downloaded bootstrap sets: commands given back are + # the one-line command, not a path to a temporary copy. "RETROBIOS_BOOTSTRAP": "sh", "RETROBIOS_DEMO_BOOTSTRAP": str(ROOT / "install.sh"), - "RETROBIOS_DEMO_CARD": str(card), - "RETROBIOS_DEMO_UID": str(os.getuid()), - "RETROBIOS_DEMO_GID": str(os.getgid()), } -def format_size(size: int) -> str: - """The size as the installer writes it.""" +def _installer() -> object: spec = importlib.util.spec_from_file_location("retrobios_install", ROOT / "install.py") if spec is None or spec.loader is None: raise RuntimeError("install.py cannot be loaded") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return str(module.format_size(size)) + return module -def measure(session: Path, env: dict[str, str]) -> dict: +def format_size(size: int) -> str: + """The size as the installer writes it.""" + return str(_installer().format_size(size)) # type: ignore[attr-defined] + + +def describe(variant: str, facts: dict) -> str: + """A recording in words, for a reader who cannot see it: its alt text.""" + plan = (f"plans {facts['files']} BIOS files ({facts['size']}) for them, installs them " + "and shows the summary.") + if variant == "handheld": + return (f"Recording of the full-screen installer on the {facts['width']} by " + f"{facts['height']} screen of a handheld, started from the Tools menu of " + f"{facts['setup']}. It finds the {facts['games']} games on the card, {plan} The " + "A button chooses, and the bottom line names the buttons.") + return (f"Recording of the one-line command in a terminal. The installer finds an " + f"{facts['setup']} SD card with {facts['games']} games, {plan}") + + +def measure(session: Path, env: dict[str, str], variant: Variant = VARIANTS["default"]) -> dict: """What the recording shows, read from the same run without the screen: the games read on the card and the files planned for them.""" - command = [str(session), sys.executable, str(ROOT / "install.py"), "--platform", "retroarch", - "--dest", f"{MOUNT}/bios", "--for-games", "--dry-run", "--events"] + installer = ARK_INSTALLER if variant.place == "device" else str(ROOT / "install.py") + options = ([] if variant.place == "device" else + ["--platform", "retroarch", "--dest", f"{variant.mount}/bios", "--for-games"]) + command = [str(session), "python3" if variant.place == "device" else sys.executable, + installer, *options, "--dry-run", "--events", "--no-input"] result = subprocess.run(command, env=env, capture_output=True, text=True, check=False) if result.returncode != 0: raise RuntimeError(f"the dry run failed ({result.returncode}):\n{result.stderr}") @@ -219,8 +370,9 @@ def measure(session: Path, env: dict[str, str]) -> dict: games = next(e for e in events if e["event"] == "games") check = next(e for e in events if e["event"] == "check") size = {key.lower(): int(value) for key, value in - re.findall(r"^Set (Width|Height) (\d+)$", TAPE.read_text(encoding="utf-8"), re.M)} - return { + re.findall(r"^Set (Width|Height) (\d+)$", variant.tape.read_text(encoding="utf-8"), + re.M)} + facts = { "width": size["width"], "height": size["height"], "setup": SETUP, @@ -229,61 +381,93 @@ def measure(session: Path, env: dict[str, str]) -> dict: "files": check["missing"], "size": format_size(check["bytes_needed"]), } + if variant.name == "handheld": + facts["pad"] = env["RETROBIOS_PAD"] + facts["alt"] = describe(variant.name, facts) + return facts def ffmpeg(*args: str) -> None: subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", *args], check=True) -def encode(recorded: Path, poster: Path, out: Path, scratch: Path) -> None: - palette = scratch / "palette.png" - ffmpeg("-i", str(recorded), "-vf", f"fps={GIF_FPS},palettegen=max_colors=48:stats_mode=full", - str(palette)) - ffmpeg("-i", str(recorded), "-i", str(palette), "-lavfi", - f"fps={GIF_FPS}[v];[v][1:v]paletteuse=dither=none:diff_mode=rectangle", - "-loop", "0", str(out / "install.gif")) - ffmpeg("-i", str(recorded), "-an", "-r", str(VIDEO_FPS), "-c:v", "libvpx-vp9", +def encode(variant: Variant, out: Path, scratch: Path) -> None: + recorded, stem = str(variant.recorded), variant.stem + if variant.gif: + palette = scratch / "palette.png" + ffmpeg("-i", recorded, "-vf", f"fps={GIF_FPS},palettegen=max_colors=48:stats_mode=full", + str(palette)) + ffmpeg("-i", recorded, "-i", str(palette), "-lavfi", + f"fps={GIF_FPS}[v];[v][1:v]paletteuse=dither=none:diff_mode=rectangle", + "-loop", "0", str(out / f"{stem}.gif")) + ffmpeg("-i", recorded, "-an", "-r", str(VIDEO_FPS), "-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "42", "-row-mt", "1", "-deadline", "good", - "-pix_fmt", "yuv420p", str(out / "install.webm")) - ffmpeg("-i", str(recorded), "-an", "-r", str(VIDEO_FPS), "-c:v", "libx264", + "-pix_fmt", "yuv420p", str(out / f"{stem}.webm")) + ffmpeg("-i", recorded, "-an", "-r", str(VIDEO_FPS), "-c:v", "libx264", "-preset", "veryslow", "-crf", "30", "-tune", "animation", "-pix_fmt", "yuv420p", - "-movflags", "+faststart", str(out / "install.mp4")) - ffmpeg("-i", str(poster), "-pred", "mixed", "-compression_level", "100", - str(out / "install.png")) + "-movflags", "+faststart", str(out / f"{stem}.mp4")) + ffmpeg("-i", str(variant.poster_shot), "-pred", "mixed", "-compression_level", "100", + str(out / f"{stem}.png")) -def check_sizes(out: Path) -> list[str]: +def check_sizes(out: Path, variant: Variant = VARIANTS["default"]) -> list[str]: return [f"{name}: {(out / name).stat().st_size:,} bytes, over {limit:,}" - for name, limit in LIMITS.items() if (out / name).stat().st_size > limit] + for name, limit in limits(variant).items() if (out / name).stat().st_size > limit] + + +def load_facts() -> dict: + """demo.json, an empty mapping before the first recording.""" + if not FACTS.is_file(): + return {} + return json.loads(FACTS.read_text(encoding="utf-8")) + + +def record(variant: Variant) -> dict: + """Record one variant into OUT and return what it shows.""" + card = WORK / "card" + build_card(card) + with served_repository() as base_url: + env = session_env(base_url, WORK, card, variant) + session = write_session(WORK, sorted(env), variant) + facts = measure(session, env, variant) + vhs_env = {**os.environ, **env, "PATH": os.environ.get("PATH", ""), + "HOME": os.environ.get("HOME", ""), + "RETROBIOS_DEMO_SESSION": str(session)} + subprocess.run(["vhs", str(variant.tape.relative_to(ROOT))], cwd=ROOT, env=vhs_env, + check=True) + encode(variant, OUT, WORK / "scratch") + return facts + + +def needed_tools(variant: Variant) -> list[str]: + tools = ["vhs", "ttyd", "ffmpeg"] + return [*tools, "bwrap" if variant.place == "device" else "unshare"] def main() -> None: parser = argparse.ArgumentParser(allow_abbrev=False, - description="Record the installer demo (VHS).") - parser.parse_args() - missing = [tool for tool in ("vhs", "ttyd", "ffmpeg", "unshare") if shutil.which(tool) is None] + description="Record the installer demos (VHS).") + parser.add_argument("--variant", action="append", choices=sorted(VARIANTS), + help="record this variant only (repeatable); every variant by default") + args = parser.parse_args() + chosen = [VARIANTS[name] for name in (args.variant or VARIANTS)] + missing = sorted({tool for variant in chosen for tool in needed_tools(variant) + if shutil.which(tool) is None}) if missing: sys.exit(f"Missing on PATH: {', '.join(missing)} (VHS: github.com/charmbracelet/vhs)") - if WORK.exists(): - shutil.rmtree(WORK) - (WORK / "scratch").mkdir(parents=True) + facts = load_facts() + over: list[str] = [] try: - card = WORK / "card" - build_card(card) - with served_repository() as base_url: - env = session_env(base_url, WORK, card) - session = write_session(WORK, sorted(env)) - facts = measure(session, env) - vhs_env = {**os.environ, **env, "PATH": os.environ.get("PATH", ""), - "HOME": os.environ.get("HOME", ""), - "RETROBIOS_DEMO_SESSION": str(session)} - subprocess.run(["vhs", str(TAPE.relative_to(ROOT))], cwd=ROOT, env=vhs_env, - check=True) - encode(RECORDED, POSTER_SHOT, OUT, WORK / "scratch") - (OUT / "demo.json").write_text(json.dumps(facts, indent=2) + "\n", encoding="utf-8") - for name in (*LIMITS, "demo.json"): - print(f"{(OUT / name).relative_to(ROOT)} {(OUT / name).stat().st_size:,} bytes") - over = check_sizes(OUT) + for variant in chosen: + if WORK.exists(): + shutil.rmtree(WORK) + (WORK / "scratch").mkdir(parents=True) + facts[variant.name] = record(variant) + for name in variant.outputs: + print(f"{(OUT / name).relative_to(ROOT)} {(OUT / name).stat().st_size:,} bytes") + over += check_sizes(OUT, variant) + ordered = {name: facts[name] for name in VARIANTS if name in facts} + FACTS.write_text(json.dumps(ordered, indent=2) + "\n", encoding="utf-8") if over: sys.exit("Over the size targets:\n " + "\n ".join(over)) finally: diff --git a/scripts/site_markdown.py b/scripts/site_markdown.py index c72b972e3..db9e5b143 100644 --- a/scripts/site_markdown.py +++ b/scripts/site_markdown.py @@ -20,11 +20,15 @@ on the built site: from __future__ import annotations import argparse +import os import re import sys from html.parser import HTMLParser from pathlib import Path +sys.path.insert(0, os.path.dirname(__file__)) +from common import run_cli # noqa: E402 + SITE = "https://abdess.github.io" BASE = "/retrobios/" # Elements whose content is never words a reader needs. @@ -342,4 +346,4 @@ def main() -> int: if __name__ == "__main__": - sys.exit(main()) + run_cli(main) diff --git a/scripts/siteguides.py b/scripts/siteguides.py index a71a3edf7..762cf5f0f 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -460,11 +460,14 @@ PLACES = ("device", "server") def terminal_tabs( tabs: tuple[str, ...], args: dict[str, str], indent: str = "", steps: bool = True, how: dict[str, str] | None = None, + under_steps: dict[str, list[str]] | None = None, + under_command: dict[str, list[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. *how* replaces the shared text of a tab - for one setup.""" + for one setup. *under_steps* and *under_command* add lines to a tab + after its text and after its command (sitesteps.install_box).""" items = [] for key in tabs: label, shared, shell = TERMINALS[key] @@ -472,7 +475,9 @@ def terminal_tabs( label = "" # one place, which the text around already names text = (how or {}).get(key) or shared body = [text, ""] if steps else [] - items.append((label, body + fence(shell, install_command(shell, args.get(key, ""))))) + body += (under_steps or {}).get(key, []) + items.append((label, body + fence(shell, install_command(shell, args.get(key, ""))) + + [""] + (under_command or {}).get(key, []))) return markup.tabs(items, indent) diff --git a/scripts/sitesteps.py b/scripts/sitesteps.py new file mode 100644 index 000000000..e84d630ce --- /dev/null +++ b/scripts/sitesteps.py @@ -0,0 +1,269 @@ +"""What the install box of Get your files shows under each tab. + +Two things per tab, both drawn from data: + +- The steps that differ by system, opening a terminal and pasting the line, + as an inline SVG: one card per step, a pictogram and a short caption, + drawn with sitediagrams' primitives so it takes the theme's colours in + light and dark. The text steps above it (platforms/_guides.yml, + terminals) stay the accessible source. The SVG says the same in its title + and desc. The pictograms are generic shapes (a menu, a search field, a + window, keys, a mouse, the power symbol), no vendor's interface or logo. +- The installer demo recorded for that place (scripts/make_demo.py): the + full-screen interface on a handheld under "On the device", the terminal + recording under the others but Windows. No Windows recording is + published (make_demo.py says why), and a curl line recorded in a Linux + terminal under the PowerShell command would contradict it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from xml.sax.saxutils import escape + +import sitemarkup as markup +from sitediagrams import ( + FONT, + MONO_STYLE, + NARROW, + STROKE, + TEXT_SIZE, + WIDE, + _badge, + _badge_side, + _line, + _n, + _rect, + _text, + wrap, +) + +KEY_SIZE = 11.5 +ICON_H = 44.0 + + +@dataclass(frozen=True) +class Step: + """One card: a pictogram (*icon* and its *word*, the text it shows: + keys joined by "+", the word typed in a search field) and a caption.""" + + icon: str + caption: str + word: str = "" + + +STEPS: dict[str, tuple[str, tuple[Step, ...]]] = { + "windows": ("Windows: open PowerShell and paste the line", ( + Step("menu", "Open the Start menu"), + Step("search", "Type PowerShell and press Enter", "PowerShell"), + Step("copy", "Click Copy, the button by the line"), + Step("mouse", "Right-click in the window to paste"), + Step("keys", "Press Enter", "Enter"), + )), + "macos": ("macOS: open Terminal and paste the line", ( + Step("keys", "Press Cmd+Space", "Cmd+Space"), + Step("search", "Type Terminal and press Return", "Terminal"), + Step("copy", "Click Copy, the button by the line"), + Step("keys", "Press Cmd+V to paste", "Cmd+V"), + Step("keys", "Press Return", "Return"), + )), + "linux": ("Linux: open a terminal and paste the line", ( + Step("keys", "Open a terminal, often Ctrl+Alt+T", "Ctrl+Alt+T"), + Step("copy", "Click Copy, the button by the line"), + Step("keys", "Press Ctrl+Shift+V to paste", "Ctrl+Shift+V"), + Step("keys", "Press Enter", "Enter"), + )), + "steamdeck": ("Steam Deck: open Konsole and paste the line", ( + Step("power", "Hold power, pick Switch to Desktop"), + Step("window", "Open Konsole"), + Step("copy", "Click Copy, the button by the line"), + Step("keys", "Press Ctrl+Shift+V to paste", "Ctrl+Shift+V"), + Step("keys", "Press Enter", "Enter"), + )), +} + +# The recording each tab shows (docs_assets/demo/demo.json keys), "" for none. +TAB_DEMOS = {"device": "handheld", "windows": ""} +DEFAULT_DEMO = "default" + + +def demo_variant(tab: str) -> str: + return TAB_DEMOS.get(tab, DEFAULT_DEMO) + + +def description(tab: str) -> str: + """The steps in words: the SVG's desc.""" + return " ".join(f"{index}. {step.caption}." for index, step in + enumerate(STEPS[tab][1], start=1)) + + +# Pictograms, drawn in a box *w* wide and ICON_H high from (x, y). + + +def _stroke(d: str, cls: str = "rb-d-line", width: float = 1.5) -> str: + return f'' + + +def _key_width(label: str) -> float: + return max(20.0, len(label) * KEY_SIZE * 0.62 + 10) + + +def _keys(x: float, y: float, w: float, word: str) -> list[str]: + labels = word.split("+") + total = sum(_key_width(label) for label in labels) + 4 * (len(labels) - 1) + left = x + (w - total) / 2 + out = [] + top = y + ICON_H / 2 - 12 + for label in labels: + width = _key_width(label) + out.append(_rect(left, top, width, 24, accent=True)) + out.append( + f'{escape(label)}') + left += width + 4 + return out + + +def _menu(x: float, y: float, w: float) -> list[str]: + cx, top = x + w / 2, y + 4 + out = [_rect(cx - 22, top, 44, 36)] + out += [_stroke(f"M{_n(cx - 14)} {_n(top + 10 + 8 * i)} H{_n(cx + 14)}") for i in range(3)] + return out + + +def _search(x: float, y: float, w: float, word: str) -> list[str]: + width = min(w - 4, len(word) * KEY_SIZE * 0.62 + 34) + left, top = x + (w - width) / 2, y + ICON_H / 2 - 12 + return [ + _rect(left, top, width, 24, accent=True), + f'', + _stroke(f"M{_n(left + 15)} {_n(top + 15)} L{_n(left + 19)} {_n(top + 19)}"), + f'{escape(word)}', + ] + + +def _window(x: float, y: float, w: float) -> list[str]: + cx, top = x + w / 2, y + 2 + return [ + _rect(cx - 32, top, 64, 40), + _stroke(f"M{_n(cx - 32)} {_n(top + 9)} H{_n(cx + 32)}"), + f'>_', + ] + + +def _copy(x: float, y: float, w: float) -> list[str]: + cx, top = x + w / 2, y + 6 + return [_rect(cx - 12, top, 18, 24), _rect(cx - 6, top + 7, 18, 24, accent=True)] + + +def _mouse(x: float, y: float, w: float) -> list[str]: + cx, top = x + w / 2, y + 2 + return [ + f'', + # The right button, the one to press. + f'', + _stroke(f"M{_n(cx)} {_n(top)} V{_n(top + 14)} M{_n(cx - 13)} {_n(top + 14)} " + f"H{_n(cx + 13)}"), + ] + + +def _power(x: float, y: float, w: float) -> list[str]: + cx, cy = x + w / 2, y + ICON_H / 2 + 2 + return [ + _stroke(f"M{_n(cx - 9)} {_n(cy - 11)} A14 14 0 1 0 {_n(cx + 9)} {_n(cy - 11)}", width=2.5), + _stroke(f"M{_n(cx)} {_n(cy - 18)} V{_n(cy - 2)}", width=2.5), + ] + + +def _icon(step: Step, x: float, y: float, w: float) -> list[str]: + if step.icon == "keys": + return _keys(x, y, w, step.word) + if step.icon == "search": + return _search(x, y, w, step.word) + return {"menu": _menu, "window": _window, "copy": _copy, "mouse": _mouse, + "power": _power}[step.icon](x, y, w) + + +def _wide(steps: tuple[Step, ...]) -> tuple[float, float, list[str]]: + """The cards in a row, joined by arrows.""" + gap = 14.0 + width = (WIDE - gap * (len(steps) - 1) - 2) / len(steps) + captions = [wrap(step.caption, width - 12, TEXT_SIZE) for step in steps] + height = 34 + ICON_H + 10 + max(len(lines) for lines in captions) * 18 + 8 + out: list[str] = [] + for index, (step, lines) in enumerate(zip(steps, captions)): + x = 1 + index * (width + gap) + out.append(_rect(x, 1, width, height)) + out += _badge(x + 8, 8, str(index + 1)) + out += _icon(step, x + 4, 30, width - 8) + y = 30 + ICON_H + 24 + for line in lines: + out.append(_text(x + width / 2, y, line, anchor="middle")) + y += 18 + if index < len(steps) - 1: + mid = 1 + height / 2 + out += _line([(x + width + 1, mid), (x + width + gap - 1, mid)]) + return WIDE, height + 2, out + + +def _narrow(steps: tuple[Step, ...]) -> tuple[float, float, list[str]]: + """One card a row: number, pictogram, caption.""" + icon_w, row_gap = 112.0, 8.0 + text_x = 8 + _badge_side("9") + 6 + icon_w + 8 + out: list[str] = [] + y = 1.0 + for index, step in enumerate(steps): + lines = wrap(step.caption, NARROW - text_x - 8, TEXT_SIZE) + height = max(ICON_H + 12, len(lines) * 18 + 16) + out.append(_rect(1, y, NARROW - 2, height)) + out += _badge(8, y + height / 2 - 10, str(index + 1)) + out += _icon(step, 8 + _badge_side("9") + 6, y + (height - ICON_H) / 2, icon_w) + ty = y + height / 2 - (len(lines) - 1) * 9 + 5 + for line in lines: + out.append(_text(text_x, ty, line)) + ty += 18 + y += height + row_gap + return NARROW, y - row_gap + 1, out + + +def _svg(tab: str, layout: str) -> str: + title, steps = STEPS[tab] + width, height, body = (_wide if layout == "wide" else _narrow)(steps) + base = f"rb-steps-{tab}-{layout}" + return ( + f'' + f'{escape(title)}' + f'{escape(description(tab))}' + + "".join(body) + "" + ) + + +def steps_figure(tab: str) -> list[str]: + """The figure of a tab's steps, both layouts, or nothing for a tab with + no drawing.""" + if tab not in STEPS: + return [] + return [f'
', + _svg(tab, "wide") + _svg(tab, "narrow"), "
", ""] + + +def tab_demo(tab: str) -> list[str]: + """The recording a tab shows, as generate_site writes the component.""" + from generate_site import install_demo # noqa: PLC0415 + + variant = demo_variant(tab) + return install_demo(variant, tab) if variant else [] + + +def install_box(tabs: tuple[str, ...]) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """What terminal_tabs puts under each tab's steps and under its command.""" + return ({tab: steps_figure(tab) for tab in tabs}, + {tab: tab_demo(tab) for tab in tabs}) diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 38832433e..ea06e86c9 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -56,8 +56,8 @@ class ReadmeRegressions(unittest.TestCase): def _quick_install(self) -> str: readme = (ROOT / "README.md").read_text(encoding="utf-8") - return readme.split("## Quick install", 1)[1].split( - "## Download BIOS packs", 1 + return readme.split("## Install in one line", 1)[1].split( + "## Or download a ZIP", 1 )[0] def test_advertised_bootstrap_urls_are_the_shipped_ones(self): @@ -1121,7 +1121,10 @@ class CatalogRatioRegressions(unittest.TestCase): match = re.search( r"\*\*([\d,]+) of ([\d,]+) system files\*\* matched to", readme ) - self.assertIsNotNone(match, "README no longer states the catalog ratio") + if match is None: + # The README states no ratio since it leads to the site: a ratio + # it states must still be the database's. + return matched, total = (int(g.replace(",", "")) for g in match.groups()) self.assertEqual(matched, count_catalog_matched(db)) self.assertEqual(total, compute_composition(db)["systems"]["files"]) diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 6ca5886f1..0b025c755 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -340,7 +340,7 @@ class FilesColumnParagraph(unittest.TestCase): paragraph = next(p for p in readme.split("\n\n") if "The Files column" in p) self.assertNotIn("\n", paragraph.strip()) after = readme.split(paragraph, 1)[1].lstrip("\n") - self.assertTrue(after.startswith("| Platform | Files |")) + self.assertTrue(after.startswith("| Setup | Files |")) class PageOpenings(unittest.TestCase): @@ -769,14 +769,12 @@ class ReadmeInstallerOptions(unittest.TestCase): import generate_readme source = inspect.getsource(generate_readme.generate_readme) - self.assertIn('install_command("powershell", "--check")', source) self.assertIn("wiki/installer/#passing-options", source) self.assertIn("## Passing options", _page("installer.md")) readme = (ROOT / "README.md").read_text(encoding="utf-8") - quick = _section(readme, "Quick install") - from siteguides import INSTALL_PS1 - - self.assertIn(f"&([scriptblock]::Create((irm {INSTALL_PS1}))) --check", quick) + quick = _section(readme, "Install in one line") + # The README gives the bare line and sends options to the page that + # writes their PowerShell form: an option after `| iex` reaches iex. self.assertIn("wiki/installer/#passing-options", quick) self.assertNotRegex(readme, r"(?m)^irm \S+ \| iex \S") @@ -791,7 +789,7 @@ class PublishedNumbersShareOneFormat(unittest.TestCase): readme_source = inspect.getsource(generate_readme.generate_readme) self.assertNotIn("size_mb", readme_source) - self.assertIn("format_size(total_size)", readme_source) + self.assertIn("format_size(size)", readme_source) self.assertIn("{total_files:,}", inspect.getsource(generate_site)) self.assertEqual(generate_readme.format_size(16011 * 1024 * 1024), "15.6 GB") @@ -1713,9 +1711,7 @@ class ReadmeKeepsItsPointers(unittest.TestCase): """The README sent a reader to 'build your own pack below' for a bare minimum, and the block below never named --required-only.""" readme = (ROOT / "README.md").read_text(encoding="utf-8") - self.assertIn("For a bare minimum, build your own pack below.", readme) - block = readme[readme.index("## Build your own pack"):] - block = block[:block.index("\n## ", 1)] + block = readme[readme.index("For developers"):readme.index("")] self.assertIn("--required-only", block) def test_unsourceable_files_are_not_said_to_exist_nowhere(self): @@ -1725,7 +1721,8 @@ class ReadmeKeepsItsPointers(unittest.TestCase): readme = (ROOT / "README.md").read_text(encoding="utf-8") self.assertNotIn("sourced at all", readme) self.assertNotIn("nobody has made", readme) - self.assertRegex(readme, r"more the collection does not hold and does not serve by name") + # The README names no count: the gap analysis it links does, by reason. + self.assertIn("gaps/", readme) class ArchitectureStatesTheArbitration(unittest.TestCase): @@ -2041,8 +2038,8 @@ class BuildYourOwnPackSaysWhatTheCloneCosts(unittest.TestCase): line = generate_readme.clone_line(db) self.assertIn("git clone --depth 1", line) self.assertRegex(line, r"over \d+(\.\d)? GB") - section = _section((ROOT / "README.md").read_text(encoding="utf-8"), - "Build your own pack") + readme = (ROOT / "README.md").read_text(encoding="utf-8") + section = readme[readme.index("For developers"):readme.index("")] self.assertIn("git clone --depth 1", section) def test_advanced_usage_names_the_shallow_clone(self): diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index c87d74052..df04632bf 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -191,16 +191,17 @@ class DownloadLinksKeepTheirAddress(unittest.TestCase): from common import list_registered_platforms, list_release_pack_platforms readme = _read("README.md") - table = _section(readme, "## Download BIOS packs") + table = _section(readme, "## Or download a ZIP") self.assertNotIn("/releases/download/", table) released = set(list_release_pack_platforms("platforms")) + rows = [line for line in table.splitlines() if line.startswith("| ")] for key in list_registered_platforms("platforms"): with self.subTest(platform=key): + # Every setup's lasting page is linked: a ZIP's from its row, + # the others from the line that says they install by command. self.assertIn(f"]({SITE_URL}get/{key}/)", table) - row = next(line for line in table.splitlines() - if f"get/{key}/)" in line) - if key not in released: - self.assertIn("[Use the command]", row) + in_rows = any(f"get/{key}/)" in row for row in rows) + self.assertEqual(in_rows, key in released) def test_the_download_page_rows_link_get_pages(self): page = _which_pack() @@ -258,7 +259,7 @@ class TheReadmeLeadsBeginnersToTheirPages(unittest.TestCase): def test_quick_install_links_the_start_pages(self): # The code block holds `#` comment lines, so the section runs to # the next level-two heading. - section = _read("README.md").split("## Quick install", 1)[1].split("\n## ", 1)[0] + section = _read("README.md").split("## Install in one line", 1)[1].split("\n## ", 1)[0] for page in ("first-install", "troubleshooting", "faq"): with self.subTest(page=page): self.assertIn(f"wiki/{page}/)", section) diff --git a/tests/test_release_packs.py b/tests/test_release_packs.py index e3d54147a..65a859c26 100644 --- a/tests/test_release_packs.py +++ b/tests/test_release_packs.py @@ -284,9 +284,9 @@ class EverySurfaceSaysWhy(unittest.TestCase): def test_the_readme_rows_read_use_the_command(self): readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") - row = next(line for line in readme.splitlines() if line.startswith("| KNULLI |")) - self.assertIn(f"[Use the command]({SITE}get/knulli/)", row) - self.assertIn("ZIPs are made for the setups most people download.", readme) + line = next(text for text in readme.split("\n\n") if "ZIPs are made for the setups most people download." in text) + self.assertIn(f"[KNULLI]({SITE}get/knulli/)", line) + self.assertIn("install with the line above", line) def test_the_faq_answers_why(self): faq = (REPO_ROOT / "wiki" / "faq.md").read_text(encoding="utf-8") diff --git a/tests/test_site_bootstrap.py b/tests/test_site_bootstrap.py index 3b736cad1..b4ed3814d 100644 --- a/tests/test_site_bootstrap.py +++ b/tests/test_site_bootstrap.py @@ -271,7 +271,7 @@ class GeneratedSurfaces(unittest.TestCase): def test_the_readme_shows_the_short_form(self): readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") - quick = readme.split("## Quick install", 1)[1].split("\n## ", 1)[0] + quick = readme.split("## Install in one line", 1)[1].split("\n## ", 1)[0] self.assertIn(f"\n{install_command('sh')}\n", quick) self.assertIn(f"\n{install_command('powershell')}\n", quick) self.assertNotIn("raw.githubusercontent.com", quick) diff --git a/tests/test_site_demo.py b/tests/test_site_demo.py index 3d653e213..5ee81e0f5 100644 --- a/tests/test_site_demo.py +++ b/tests/test_site_demo.py @@ -1,9 +1,12 @@ -"""The installer demo of the README and the site (scripts/make_demo.py). +"""The installer demos of the README and the site (scripts/make_demo.py). -The home page and Get your files score 98 to 100 on a phone: the video must +The home page and Get your files score 98 to 100 on a phone: a video must cost nothing before it is on screen, keep still under reduced motion, and say in words what it shows. The files are committed, so their sizes are held -here, and the words come from what the recording measured. +here, and the words come from what each recording measured. Get your files +shows one recording per tab of its install box: the handheld's under "On the +device", none under Windows (no Windows recording is published), the +terminal recording under the others. """ from __future__ import annotations @@ -29,43 +32,90 @@ COMPONENT = ROOT / "website" / "src" / "components" / "InstallDemo.astro" DIST = ROOT / "website" / "dist" -def _facts() -> dict: +def _all_facts() -> dict: return json.loads((DEMO / "demo.json").read_text(encoding="utf-8")) +def _facts(variant: str = "default") -> dict: + return _all_facts()[variant] + + class TheFilesAreThereAndSmall(unittest.TestCase): def test_each_output_is_under_its_target(self): - for name, limit in make_demo.LIMITS.items(): - with self.subTest(name=name): - self.assertLessEqual((DEMO / name).stat().st_size, limit) + for variant in make_demo.VARIANTS.values(): + for name, limit in make_demo.limits(variant).items(): + with self.subTest(name=name): + self.assertLessEqual((DEMO / name).stat().st_size, limit) - def test_the_gif_and_the_poster_have_the_recorded_size(self): + def test_only_the_readme_variant_has_a_gif(self): + gifs = sorted(p.name for p in DEMO.glob("*.gif")) + self.assertEqual(gifs, [Path(generate_readme.DEMO_GIF).name]) + self.assertEqual([v.name for v in make_demo.VARIANTS.values() if v.gif], ["default"]) + + def test_the_gif_and_the_posters_have_the_recorded_size(self): facts = _facts() head = (DEMO / "install.gif").read_bytes()[:10] self.assertEqual(head[:6], b"GIF89a") self.assertEqual(struct.unpack("II", png[16:24]), (facts["width"], facts["height"])) + for variant in make_demo.VARIANTS.values(): + facts = _facts(variant.name) + png = (DEMO / f"{variant.stem}.png").read_bytes()[:24] + with self.subTest(variant=variant.name): + self.assertEqual(png[:8], b"\x89PNG\r\n\x1a\n") + self.assertEqual(struct.unpack(">II", png[16:24]), + (facts["width"], facts["height"])) def test_the_videos_are_webm_and_mp4(self): - self.assertEqual((DEMO / "install.webm").read_bytes()[:4], b"\x1a\x45\xdf\xa3") - self.assertEqual((DEMO / "install.mp4").read_bytes()[4:8], b"ftyp") + for variant in make_demo.VARIANTS.values(): + with self.subTest(variant=variant.name): + self.assertEqual((DEMO / f"{variant.stem}.webm").read_bytes()[:4], + b"\x1a\x45\xdf\xa3") + self.assertEqual((DEMO / f"{variant.stem}.mp4").read_bytes()[4:8], b"ftyp") + + def test_demo_json_holds_each_variant_and_its_alt_text(self): + facts = _all_facts() + self.assertEqual(list(facts), list(make_demo.VARIANTS)) + for name, entry in facts.items(): + with self.subTest(variant=name): + self.assertEqual(entry["alt"], make_demo.describe(name, entry)) + # Written into an MDX attribute: no quote of either kind. + self.assertNotRegex(entry["alt"], "[\"']") class TheTapeAndTheDriverAgree(unittest.TestCase): def setUp(self): self.tape = make_demo.TAPE.read_text(encoding="utf-8") - def test_the_tape_writes_what_the_driver_encodes(self): - self.assertIn(f"Output {make_demo.RECORDED.relative_to(ROOT).as_posix()}\n", self.tape) - self.assertIn(f"Screenshot {make_demo.POSTER_SHOT.relative_to(ROOT).as_posix()}\n", - self.tape) + def test_each_tape_writes_what_the_driver_encodes(self): + for variant in make_demo.VARIANTS.values(): + tape = variant.tape.read_text(encoding="utf-8") + with self.subTest(variant=variant.name): + self.assertIn(f"Output {variant.recorded.relative_to(ROOT).as_posix()}\n", tape) + self.assertIn( + f"Screenshot {variant.poster_shot.relative_to(ROOT).as_posix()}\n", tape) - def test_the_size_in_demo_json_is_the_tapes(self): - facts = _facts() - self.assertIn(f"Set Width {facts['width']}\n", self.tape) - self.assertIn(f"Set Height {facts['height']}\n", self.tape) + def test_the_size_in_demo_json_is_each_tapes(self): + for variant in make_demo.VARIANTS.values(): + facts, tape = _facts(variant.name), variant.tape.read_text(encoding="utf-8") + with self.subTest(variant=variant.name): + self.assertIn(f"Set Width {facts['width']}\n", tape) + self.assertIn(f"Set Height {facts['height']}\n", tape) + + def test_the_handheld_is_an_r36s_screen_driven_by_its_buttons(self): + variant = make_demo.VARIANTS["handheld"] + tape = variant.tape.read_text(encoding="utf-8") + self.assertIn("Set Width 640\n", tape) + self.assertIn("Set Height 480\n", tape) + # Nothing typed but the hidden start of the session: the buttons + # gptokeyb maps (ports/shared/retrobios.gptk: a = enter) do the rest. + self.assertEqual(re.findall(r"^Type (.*)$", tape, re.M), + ["'exec \"$RETROBIOS_DEMO_SESSION\"'"]) + gptk = (ROOT / "ports" / "shared" / "retrobios.gptk").read_text(encoding="utf-8") + self.assertRegex(gptk, r"(?m)^a = enter$") + env = make_demo.session_env("http://127.0.0.1:1", ROOT / "tmp", ROOT / "tmp", variant) + self.assertEqual(env["RETROBIOS_PAD"], "gptokeyb") + self.assertEqual(env["RETROBIOS_OS"], "linux") + self.assertEqual(variant.mount, "/roms") def test_the_command_typed_is_the_one_the_pages_give(self): from siteguides import install_command # noqa: PLC0415 @@ -84,8 +134,8 @@ class TheReadmeShowsTheGif(unittest.TestCase): readme = (ROOT / "README.md").read_text(encoding="utf-8") line = f"![{generate_readme.demo_description()}]({generate_readme.DEMO_GIF})" self.assertIn(line, readme) - self.assertLess(readme.index("## Quick install"), readme.index(line)) - self.assertLess(readme.index(line), readme.index("## Download BIOS packs")) + self.assertLess(readme.index("## Install in one line"), readme.index(line)) + self.assertLess(readme.index(line), readme.index("## Or download a ZIP")) def test_the_description_names_what_the_recording_measured(self): facts = _facts() @@ -112,7 +162,13 @@ class TheVideoLoadsOnlyOnScreen(unittest.TestCase): self.assertRegex(self.markup, rf"\n\t\t{attribute}\n") def test_webm_comes_before_mp4(self): - self.assertLess(self.markup.index("install.webm"), self.markup.index("install.mp4")) + self.assertLess(self.markup.index("asset('webm')"), self.markup.index("asset('mp4')")) + + def test_each_variant_has_its_files_and_its_own_ids(self): + self.assertIn("const stem = variant === 'default' ? 'install' : `install-${variant}`;", + self.source) + self.assertIn("const textId = name ? `rb-demo-text-${name}` : 'rb-demo-text';", + self.source) def test_reduced_motion_keeps_the_poster_with_a_button(self): self.assertIn("matchMedia('(prefers-reduced-motion: reduce)')", self.source) @@ -121,44 +177,93 @@ class TheVideoLoadsOnlyOnScreen(unittest.TestCase): self.markup) def test_screen_readers_get_the_description(self): - self.assertIn('aria-describedby="rb-demo-text"', self.markup) - self.assertIn('
{description}
', + self.assertIn("aria-describedby={textId}", self.markup) + self.assertIn('
{description}
', self.markup) def test_the_component_is_registered(self): self.assertEqual(sitestarlight.COMPONENTS["InstallDemo"], "components/InstallDemo.astro") +def _tab_bodies(markdown: str) -> dict[str, str]: + """Each tab of the quick install box of Get your files, by label.""" + box = markdown[markdown.index("{ #quick-install }"):] + box = box[:box.index("")] + return dict(re.findall(r'(.*?)', box, re.S)) + + class TheTwoPagesShowIt(unittest.TestCase): - def test_home_and_get_your_files(self): + def test_home_shows_the_terminal_recording(self): import generate_site # noqa: PLC0415 tag = generate_site.install_demo()[0] self.assertIn(f'description="{generate_readme.demo_description()}"', tag) source = Path(generate_site.__file__).read_text(encoding="utf-8") - for function in ("def generate_home(", "def generate_which_pack("): - body = source[source.index(function):] - body = body[:body.index("\ndef ", 1)] - with self.subTest(function=function): - self.assertIn("*install_demo(),", body) + body = source[source.index("def generate_home("):] + body = body[:body.index("\ndef ", 1)] + self.assertIn("*install_demo(),", body) - def test_the_site_serves_the_video_not_the_gif(self): + def test_each_tab_of_get_your_files_shows_its_variant(self): import generate_site # noqa: PLC0415 - self.assertEqual(generate_site.DEMO_ASSETS, ("install.webm", "install.mp4", "install.png")) + tabs = _tab_bodies(generate_site.generate_which_pack()) + expected = {"Windows": None, "macOS": "default", "Linux": "default", + "Steam Deck": "default", "Android (Termux)": "default", + "On the device": "handheld"} + self.assertEqual(set(tabs), set(expected)) + for label, variant in expected.items(): + demos = re.findall(r"]*/>", tabs[label]) + with self.subTest(tab=label): + if variant is None: + self.assertEqual(demos, []) + continue + self.assertEqual(len(demos), 1) + self.assertIn(f'description="{_facts(variant)["alt"]}"', demos[0]) + self.assertIn(f'width="{_facts(variant)["width"]}"', demos[0]) + self.assertIn(f'variant="{variant}"', demos[0]) + # The demo below the box moved into its tabs: one per tab, no other. + page = generate_site.generate_which_pack() + self.assertEqual(page.count("', html, re.S) + figures = re.findall(r'
', html, re.S) with self.subTest(page=page): - self.assertIsNotNone(figure) - self.assertIn('preload="none"', figure.group(0)) - self.assertNotRegex(figure.group(0), r"\s(src|poster)=") - for name in ("install.webm", "install.mp4", "install.png"): - self.assertTrue((DIST / "assets" / "demo" / name).is_file()) + self.assertTrue(figures) + for figure in figures: + self.assertIn('preload="none"', figure) + self.assertNotRegex(figure, r"\s(src|poster)=") + for name in generate_site_assets(): + self.assertTrue((DIST / "assets" / "demo" / name).is_file()) + + def test_the_built_tabs_hold_their_variant(self): + page = DIST / "which-pack" / "index.html" + if not page.is_file(): + self.skipTest("no built site") + html = page.read_text(encoding="utf-8") + panel = r']*role="tabpanel"' + panels = re.findall(rf"{panel}[^>]*>(.*?)(?={panel}|)", html, re.S) + variants = [re.findall(r'data-variant="([a-z]+)"', panel) for panel in panels] + # The quick install box: Windows, macOS, Linux, Steam Deck, Android, device. + self.assertIn([[], ["default"], ["default"], ["default"], ["default"], ["handheld"]], + [variants[i:i + 6] for i in range(len(variants) - 5)]) + self.assertIn("assets/demo/install-handheld.png", html) + + +def generate_site_assets() -> tuple[str, ...]: + import generate_site # noqa: PLC0415 + + return generate_site.DEMO_ASSETS if __name__ == "__main__": diff --git a/tests/test_site_integration.py b/tests/test_site_integration.py index d2f58137c..c1cfd155a 100644 --- a/tests/test_site_integration.py +++ b/tests/test_site_integration.py @@ -237,7 +237,7 @@ class FaqInstallsFromWhatYouHold(unittest.TestCase): word = rule.name.split()[0] with self.subTest(share=rule.name): self.assertIn(word, section) - self.assertRegex(readme, rf"# SD card mounted on a PC: .*\b{word}\b") + self.assertRegex(readme, rf"\b{word}\b[^.]*SD card plugged into the computer is found by itself") # The label the first screen gives a card found: "Onion SD card". self.assertEqual(install._card_word(("retroarch", Path("/x/BIOS"), "Onion")), "SD card") self.assertEqual(install._card_word(("batocera", Path("/x/bios"), "Batocera")), diff --git a/tests/test_site_steps.py b/tests/test_site_steps.py new file mode 100644 index 000000000..77e4319ab --- /dev/null +++ b/tests/test_site_steps.py @@ -0,0 +1,94 @@ +"""The drawn steps of the install box (scripts/sitesteps.py). + +Each drawing says in words what it shows, to a screen reader and to a +reader whose images do not load: a title and a desc the SVG names. It tells +the same steps as the text above it, the keys included, so the picture and +the words never disagree. And it draws in the theme's colours. +""" + +from __future__ import annotations + +import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache + +import re +import sys +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) + +import sitesteps # noqa: E402 +from siteguides import TERMINALS # noqa: E402 + +SVG = "{http://www.w3.org/2000/svg}" +DIST = ROOT / "website" / "dist" + + +class EachDrawingNamesItsSteps(unittest.TestCase): + def test_title_and_desc_are_what_aria_points_at(self): + for tab in sitesteps.STEPS: + for layout in ("wide", "narrow"): + root = ET.fromstring(sitesteps._svg(tab, layout)) + with self.subTest(tab=tab, layout=layout): + self.assertEqual(root.get("role"), "img") + title, desc = root.find(f"{SVG}title"), root.find(f"{SVG}desc") + self.assertIsNotNone(title) + self.assertIsNotNone(desc) + self.assertEqual(root.get("aria-labelledby").split(), + [title.get("id"), desc.get("id")]) + self.assertEqual(desc.text, sitesteps.description(tab)) + + def test_every_caption_drawn_is_in_the_desc(self): + for tab, (_title, steps) in sitesteps.STEPS.items(): + desc = sitesteps.description(tab) + for step in steps: + with self.subTest(tab=tab, step=step.caption): + self.assertIn(step.caption, desc) + + def test_the_drawing_and_the_text_name_the_same_keys(self): + for tab, (_title, steps) in sitesteps.STEPS.items(): + text = TERMINALS[tab][1].replace("**", "") + keys = {step.word for step in steps if step.icon == "keys"} + with self.subTest(tab=tab): + for word in keys: + self.assertIn(word, text) + drawn = set(re.findall(r"\b(?:Ctrl|Cmd|Shift|Alt)\+\w+(?:\+\w+)?", text)) + self.assertLessEqual(drawn, keys) + self.assertIn("Click Copy", text) + + def test_one_drawing_per_computer_tab_none_elsewhere(self): + self.assertEqual(set(sitesteps.STEPS), {"windows", "macos", "linux", "steamdeck"}) + self.assertEqual(sitesteps.steps_figure("device"), []) + + def test_colours_come_from_the_theme(self): + for tab in sitesteps.STEPS: + root = ET.fromstring(sitesteps._svg(tab, "wide")) + for node in root.iter(): + tag = node.tag.removeprefix(SVG) + if tag in ("rect", "path", "circle"): + with self.subTest(tab=tab, node=ET.tostring(node)[:60]): + self.assertTrue(node.get("class", "").startswith("rb-d-")) + + def test_ids_are_unique_on_the_page(self): + ids = re.findall(r'id="([^"]+)"', "".join( + sitesteps._svg(tab, layout) for tab in sitesteps.STEPS for layout in ("wide", "narrow"))) + self.assertEqual(len(ids), len(set(ids))) + + +class TheBuiltPageHoldsThem(unittest.TestCase): + def test_under_each_tab_of_get_your_files(self): + page = DIST / "which-pack" / "index.html" + if not page.is_file(): + self.skipTest("no built site") + html = page.read_text(encoding="utf-8") + for tab in sitesteps.STEPS: + with self.subTest(tab=tab): + self.assertIn(f'aria-labelledby="rb-steps-{tab}-wide-t rb-steps-{tab}-wide-d"', + html) + self.assertIn(f'', html) + + +if __name__ == "__main__": + unittest.main() diff --git a/website/src/components/InstallDemo.astro b/website/src/components/InstallDemo.astro index a35a89d4e..f8dfeedc8 100644 --- a/website/src/components/InstallDemo.astro +++ b/website/src/components/InstallDemo.astro @@ -4,18 +4,25 @@ // poster and the sources are set by the script below once the video is in // view (preload="none", no src in the HTML). With reduced motion or Save-Data // the poster stays, and the button plays it. The button pauses it otherwise. +// variant names the recording (default, handheld), name the tab a demo sits +// in when a page holds several: a demo in a closed tab is not on screen, so +// it loads nothing until its tab is chosen. interface Props { description: string; width: string; height: string; + variant?: 'default' | 'handheld'; + name?: string; } -const { description, width, height } = Astro.props; +const { description, width, height, variant = 'default', name = '' } = Astro.props; const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); -const asset = (name: string) => `${base}assets/demo/${name}`; +const stem = variant === 'default' ? 'install' : `install-${variant}`; +const asset = (ext: string) => `${base}assets/demo/${stem}.${ext}`; +const textId = name ? `rb-demo-text-${name}` : 'rb-demo-text'; --- -
+
-
{description}
+
{description}