chore: merge origin/main

This commit is contained in:
Abdessamad Derraz committed 2026-11-09 08:35:52 +01:00
commit e6c83859d5
70 files changed
+1605 -896

No files matched your search

+114 -268
View File
@@ -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 = [
'<p align="center">',
" <picture>",
@@ -602,284 +611,121 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
" </picture>",
"</p>",
"",
'<p align="center">',
' <a href="https://github.com/Abdess/retrobios/actions/workflows/deploy-site.yml">'
'<img src="https://github.com/Abdess/retrobios/actions/workflows/deploy-site.yml/badge.svg" alt="Site"></a>',
'<p align="center"><strong>The BIOS files emulators need, put in the right folder and checked.</strong></p>',
"",
'<p align="center">'
f'<a href="{SITE_URL}">Website</a> · '
f'<a href="{SITE_URL}which-pack/">Get the files</a> · '
f'<a href="{SITE_URL}wiki/troubleshooting/">A game does not start</a> · '
f'<a href="{SITE_URL}wiki/faq/">Questions</a>'
"</p>",
"",
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"),
"",
"# With Python 3.8 or newer: the same installer as a package (or pipx run retrobios)",
PACKAGE_COMMAND,
"```",
"",
f"**With Python 3.8 or newer**: `{PACKAGE_COMMAND}` runs the same installer, and so"
" does `pipx run retrobios`.",
"",
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",
"",
"<details>",
"<summary><strong>For developers: build a pack</strong></summary>",
"",
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/).",
"",
"</details>",
"",
])
contributors = fetch_contributors()
if not contributors:
@@ -913,7 +759,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",
@@ -1056,7 +902,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
+73 -71
View File
@@ -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,
@@ -488,13 +489,13 @@ def generate_home(
works_with = ", ".join(named) + (f" and {others} more" if others else "")
lines = [
"# BIOS files for your emulators",
"# BIOS files for emulators",
"",
"Some consoles need a small program to start their games. It is called "
"the **BIOS**. Your emulator needs a copy of it. We put the right copy "
"the **BIOS**. The emulator needs a copy of it. RetroBIOS puts the right copy "
"in the right folder.",
"",
markup.button("Get your files", "which-pack.md", primary=True) + " "
markup.button("Get the files", "which-pack.md", primary=True) + " "
+ markup.button("What is a BIOS?", "wiki/what-are-bios-files.md"),
"",
f"Free. No account. Works with {works_with}.",
@@ -503,9 +504,9 @@ def generate_home(
"[When a game does not start](wiki/troubleshooting.md)",
"- Have a file? [Check it](files/index.md)",
"",
"## Do I need it?",
"## Is it needed? { #do-i-need-it }",
"",
"- **Yes**, if your emulator says a BIOS is missing.",
"- **Yes**, if the emulator says a BIOS is missing.",
"- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.",
"- **Better with it** for Dreamcast: some games need it.",
"- **Usually not** for NES, Super Nintendo, Mega Drive and Game Boy.",
@@ -515,12 +516,12 @@ def generate_home(
"## Three steps",
"",
*markup.steps([
"**Pick your device.** Its page shows your BIOS folder.",
"**Pick the device.** Its page shows the BIOS folder.",
"**Run one command.** Some setups have a ZIP.",
"**Start a game.**",
]),
*install_demo(),
"## Pick your device",
"## Pick the device { #pick-your-device }",
"",
*setup_picker(registry, display),
"## Is it safe?",
@@ -574,7 +575,7 @@ def generate_explore_index(db: dict, coverages: dict, profiles: dict) -> str:
"",
"## Help the project",
"",
"A file you hold, an emulator profile or a correction fills a gap for "
"A file sent in, an emulator profile or a correction fills a gap for "
"every setup at once. For contributors: [How to contribute](contributing.md).",
"",
]
@@ -752,7 +753,7 @@ def _folder_words(display: str, extract_to: str | None) -> str:
"""The folder a pack or a copy goes into, in words: the device's path,
or a folder of the setup named as the setup's."""
if not extract_to:
return "your BIOS folder"
return "the BIOS folder"
if _setup_relative(extract_to):
return f"{display}'s `{extract_to.rstrip('/')}` folder"
return f"`{extract_to}`"
@@ -824,7 +825,7 @@ def generate_get_page(platform_name: str) -> str:
"",
f"- {_fmt_size(pack['download_size'])} to download, "
f"{_fmt_size(pack['extracted_size'])} once unzipped.",
*(["- You need both parts." if parts == 2 else f"- You need all {parts} parts."]
*(["- Both parts are needed." if parts == 2 else f"- All {parts} parts are needed."]
if parts > 1 else []),
*_unzip_into(platform_name, display, extract_to, parts),
*(line for text in release for line in (text, "")),
@@ -2349,7 +2350,7 @@ def generate_data_page(exports: list[dict], measurements_sqlite: Path | None = N
"per `bios/<Manufacturer>/<Console>/` folder of the collection, named "
"`Manufacturer\\Console` so the tools look in that subfolder, each file a "
"rom named by its path below that folder. It audits a copy of the "
"collection's `bios/` tree, or rebuilds that layout from your own dumps.",
"collection's `bios/` tree, or rebuilds that layout from a reader's own dumps.",
"",
"A setup's DAT, `downloads/dat/<platform>.dat`, describes its BIOS folder "
"once installed: one game named after the folder (`bios`, `system`), each "
@@ -2449,7 +2450,7 @@ def _browser_title(relative: Path, title: str) -> str:
# Material appends " - RetroBIOS": the home title says what the site
# is, instead of "RetroBIOS - RetroBIOS".
"index.md": "BIOS and firmware packs for RetroArch, Batocera and more",
"platforms/index.md": "Your setup",
"platforms/index.md": "Setups",
"systems/index.md": "Systems",
"emulators/index.md": "Emulators",
"wiki/index.md": "Reference",
@@ -2510,7 +2511,7 @@ def _shorten(plain: str, limit: int = 157) -> str:
def _page_description(markdown: str, relative: Path, title: str) -> str:
key = relative.as_posix()
specific = {
"index.md": "The BIOS files your emulators need, for RetroArch, Batocera, EmuDeck and more: the folder they go in, one command that installs and checks them, and packs to download.",
"index.md": "The BIOS files emulators need, for RetroArch, Batocera, EmuDeck and more: the folder they go in, one command that installs and checks them, and packs to download.",
"data.md": "Versioned RetroBIOS JSON API, CSV exports, SQLite snapshot, schemas, checksums and data interpretation guidance.",
"cross-reference.md": "Every emulator core each retrogaming platform runs, the systems it emulates and the upstream project it comes from.",
"gaps.md": "Current RetroBIOS verification gaps, platform-to-emulator divergences, missing files and documented source limitations.",
@@ -2518,7 +2519,7 @@ def _page_description(markdown: str, relative: Path, title: str) -> str:
sitemeasurements.EXPLORER_PAGE: "The records of emulator cores run headless on demand, each with its claim, verdict, the BIOS it ran, method, computer and full record, filterable, and as SQLite.",
"provenance.md": "Hash-based comparison of RetroBIOS metadata with No-Intro, Redump and TOSEC catalog snapshots.",
"explore.md": "Systems, emulators and installer options to explore, with the figures of the BIOS collection.",
"which-pack.md": "Pick your device or setup and get its BIOS files: the folder they go in, one command that installs and checks them, or a ZIP pack to extract.",
"which-pack.md": "Pick a device or setup and get its BIOS files: the folder they go in, one command that installs and checks them, or a ZIP pack to extract.",
"contributing.md": "Add a BIOS file the collection lacks from the browser, checked on arrival, or add a platform or an emulator profile.",
}
if key in specific:
@@ -2571,7 +2572,7 @@ def _lead(markdown: str) -> str:
def _emulator_description(markdown: str, key: str, title: str, intro: str) -> str:
"""An emulator page by its intro sentence ("DuckStation emulates Sony
PlayStation. It picks the BIOS for your game's region: ..."), or by
PlayStation. It picks the BIOS for the game's region: ..."), or by
its profile table when the profile names no system."""
notes = re.search(r"<summary>Technical notes</summary>\s*\n\n(.+?)(?:\n\n|$)", markdown, re.S)
if not intro and notes:
@@ -3357,12 +3358,12 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
def generate_platform_index(coverages: dict, registry: dict | None = None) -> str:
"""The Your setup tab: every platform by the device it runs on, and one
"""The Setups tab: every platform by the device it runs on, and one
line to the table that sets them side by side (setup_table), which is
reference matter and sits with how each setup checks its files."""
registry = registry or {}
lines = [
"# Your setup",
"# Setups",
"",
"Each setup's page gives the folder and the command.",
"",
@@ -4181,16 +4182,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'<InstallDemo description="{text}" width="{facts["width"]}" '
f'height="{facts["height"]}" />', ""]
f'height="{facts["height"]}"{own} />', ""]
def check_pass_line() -> str:
@@ -4238,7 +4242,7 @@ def _share_tabs(name: str, share: str) -> tuple[dict[str, str], dict[str, str]]:
how = {
"macos": (f"In Finder, Go > Connect to Server: `{url}`. Then paste the line in "
"**Terminal**."),
"linux": (f"Open `{url}` in your file manager. Paste the line in a terminal, "
"linux": (f"Open `{url}` in the file manager. Paste the line in a terminal, "
f"with the folder it opened for `/path/to/{volume}`."),
}
return args, how
@@ -4279,7 +4283,7 @@ def _card_route(name: str, display: str, guide: Guide) -> list[str]:
return [lead, "", *terminal_tabs(tabs, args, steps=False)]
# A folder on the card's Linux partition: Windows and macOS show the
# small boot partition alone, and no folder there fits --dest.
put = ("Put the device's card in your computer." if "windows" in tabs
put = ("Put the device's card in a computer." if "windows" in tabs
else "Only a Linux computer opens the card.")
lead = (f"No network? {put} After `--dest`, "
f"name the folder the device calls `{extract_to}`.")
@@ -4306,14 +4310,14 @@ def _setup_routes(name: str, display: str, page_link: bool = False) -> list[str]
card = _card_route(name, display, guide)
shared = _get_page_line(name, display) if page_link else []
if share or (card and guide.run == ("device",)):
computer = ["## From your computer { #from-your-computer }", ""]
computer = ["## From a computer { #from-your-computer }", ""]
if share:
args, how = _share_tabs(name, share)
# The setup page stays in its word budget: its Mac and Linux
# lines are on the get/ page, the one the command is shared from.
tabs = ("windows",) if page_link else ("windows", "macos", "linux")
computer += [
"Needs the device on your network.",
"Needs the device on the same network.",
"",
*terminal_tabs(tabs, args, how=how),
*([f"[Mac or Linux](../{get_page_link(name)}#from-your-computer)", ""]
@@ -4327,7 +4331,7 @@ def _setup_routes(name: str, display: str, page_link: bool = False) -> list[str]
# Shown in the "On the device" tab of a guide that has no `terminal:` of its own.
DEVICE_TERMINAL_FALLBACK = "Or fill the folder from your computer, with the ZIP."
DEVICE_TERMINAL_FALLBACK = "Or fill the folder from a computer, with the ZIP."
def _computer_first(name: str, guide: Guide) -> bool:
@@ -4362,11 +4366,11 @@ def _guide_download(name: str, display: str, guide: Guide, extract_to: str | Non
f"{_fmt_size(pack['extracted_size'])} once unzipped."
]
if parts > 1:
bullets.append("You need both parts." if parts == 2 else f"You need all {parts} parts.")
bullets.append("Both parts are needed." if parts == 2 else f"All {parts} parts are needed.")
if _computer_first(name, guide) and network:
target = "the network folder above"
else:
target = f"`{extract_to}`" if extract_to else "your BIOS folder"
target = f"`{extract_to}`" if extract_to else "the BIOS folder"
bullets.append(f"Unzip {'them all' if parts > 1 else 'it'} into {target}."
+ (" Files like `neogeo.zip` stay zipped." if _ships_neogeo(name) else ""))
if guide.download_hint and not _computer_first(name, guide):
@@ -4392,7 +4396,7 @@ def _guide_download(name: str, display: str, guide: Guide, extract_to: str | Non
def only_some_consoles(name: str, prefix: str = "") -> list[str]:
"""The way to a smaller download, in the words Get your files uses."""
"""The way to a smaller download, in the words Get the files uses."""
return [
f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}"
f"#only-some-consoles), or the command's **{_installer().TUI_SYSTEMS_ROW}**.",
@@ -4483,7 +4487,7 @@ def generate_handheld_guide(guide: HandheldGuide) -> str:
"",
"## Where the files go { #where-the-files-go }",
"",
"Put the SD card in a computer. Find your system below.",
"Put the SD card in a computer. Find the system below.",
"",
"| System | Devices | BIOS folder on the card |",
"|--------|---------|-------------------------|",
@@ -4539,7 +4543,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
if _computer_first(name, guide):
lines += [
*_guide_download(name, display, guide, extract_to,
"## From your computer { #download }"),
"## From a computer { #download }"),
*_offline_line(name),
*_guide_install(name, guide, "## On the device { #install }"),
]
@@ -5502,7 +5506,7 @@ def generate_provenance_page(
"",
*([f'<p class="rb-timestamp">Catalogs as of: {as_of}.</p>', ""] if as_of else []),
"Provenance says whether a file is, byte for byte, a dump that Redump, "
"No-Intro or TOSEC catalogues. It tells you where a dump is known from, "
"No-Intro or TOSEC catalogues. It says where a dump is known from, "
"not whether an emulator accepts it: that is the emulator's code, read "
"in its profile. Example: `scph5501.bin` is Redump's `ps-30a.bin`.",
"",
@@ -5539,7 +5543,7 @@ def generate_provenance_page(
"arcade sets tracked by MAME driver source instead.",
"",
"When the two views disagree, this project follows the code, because "
"that is what decides whether your emulator boots. The reasoning is in "
"that is what decides whether the emulator boots. The reasoning is in "
"the [technical questions](wiki/technical-faq.md#are-these-files-verified-against-original-"
"hardware-dumps).",
"",
@@ -6993,7 +6997,7 @@ def _group_lines(
lines += [f"- **{key}**: {text.lstrip(': ')}"
for key, block in blocks.items() for _lead, text in block]
return [*lines, ""]
lines = ["**Start with** the files of your machine:", ""]
lines = ["**Start with** the files of the machine:", ""]
for key, block in blocks.items():
for lead, text in block:
if lead == "*":
@@ -7205,7 +7209,7 @@ def _start_lines(block: list[tuple[str, str]], console: str = "") -> list[str]:
below = " They come first below." if _sep else ""
return [" ".join(filter(None, [f"**Start with**: {named}.{below}", instead])), ""]
bios = f"the {console} BIOS" if console else "the BIOS"
lines = [f"**Start with** {bios} for the region of your games:", ""]
lines = [f"**Start with** {bios} for the region of the games:", ""]
for lead, text in block:
lines.append(f"- **{lead or 'Some emulators also require'}**: {text}")
if instead:
@@ -7262,7 +7266,7 @@ def _romset_note(consoles: dict[str, list]) -> list[str]:
_NEXT_FROM_SYSTEMS = [
"[Get your files](../which-pack.md), for the setup you play on",
"[Get the files](../which-pack.md), for the setup in use",
"[When a game does not start](../wiki/troubleshooting.md)",
"[Every emulator](../emulators/index.md), and the files each one reads",
]
@@ -8976,7 +8980,7 @@ def _install_block(name: str, profile: dict) -> list[str]:
f"BIOS folder, then downloads only the files {emu} reads there and checks "
"each one.")
else:
lead = ("The installer finds your setup and its BIOS folder, then downloads "
lead = ("The installer finds the setup and its BIOS folder, then downloads "
"only the files this emulator reads there and checks each one.")
if profile.get("type") == "standalone + libretro":
# The command fills a setup, never the app's own folder.
@@ -9242,7 +9246,7 @@ def _install_picker(declared: list[tuple[str, str]]) -> list[str]:
"",
'<div class="rb-install" data-rb-install="" hidden>',
"",
f'<label class="rb-install-setup">Your setup <select>{options}</select></label>',
f'<label class="rb-install-setup">Setup <select>{options}</select></label>',
"",
*terminal_tabs(INSTALL_PICKER_OSES, args, steps=False),
"",
@@ -9505,7 +9509,7 @@ def _emulator_file_summary(files: list[dict]) -> list[str]:
if _regional_choice([f for f in files if f.get("required") is True and not comes_with_app(f)]):
# DuckStation takes the BIOS of the game's region: "3 required"
# read as "get all three".
line = (f"{total}. One of {req_count:,} is required, for your games' region. "
line = (f"{total}. One of {req_count:,} is required, for the games' region. "
f"{len(slots) - req_count:,} {'is' if len(slots) - req_count == 1 else 'are'} "
"optional.")
if hle_count:
@@ -9913,7 +9917,7 @@ def _any_name_folder(profile: dict, entry: dict) -> str:
own = ("[the app's data folder](#data-folder)" if profile.get("data_root")
else "the app's own data folder")
return f"the {_code(folder.strip('/'))} folder inside {own}"
return f"{_code(folder)}, inside your BIOS folder"
return f"{_code(folder)}, inside the BIOS folder"
def _any_name_steps(emu_name: str, profile: dict, entry: dict, href: str,
@@ -9998,7 +10002,7 @@ def _emulator_intro(emu_name: str, profile: dict, href: str = "", name: str = ""
# DuckStation needs the BIOS of the game's region, not all three: the
# page said "3 of them required" and read as "get all of these".
others = len(files) - len(required)
sentences.append(f"It picks the BIOS for your game's region: {by_region}.")
sentences.append(f"It picks the BIOS for the game's region: {by_region}.")
if others:
sentences.append(
f"The {_count(others, 'other file')} {'is' if others == 1 else 'are'} optional.")
@@ -10290,7 +10294,7 @@ def emulator_pages(
lines[trees_start:] = search_excluded(lines[trees_start:])
lines.extend(_next_block([
"[Get your files](../which-pack.md), for the setup you play on",
"[Get the files](../which-pack.md), for the setup in use",
"[Every system](../systems/index.md), and the files each one needs",
"[When a game does not start](../wiki/troubleshooting.md)",
]))
@@ -10852,15 +10856,15 @@ def generate_gap_analysis(
lines = [
"# What the packs do not hold",
"",
"What each pack still cannot give you, and why. Most gaps are not "
"What each pack still cannot give, and why. Most gaps are not "
"firmware: a platform's list can also name game files, such as "
"Diablo's `diabdat.mpq`, which you copy from your own game, or files "
"Diablo's `diabdat.mpq`, which comes from an owned copy of the game, or files "
"no emulator of that platform opens. Those are counted apart. A missing "
"firmware file means a console that does not start, and each one is "
"named below with the platforms that list it.",
"",
"Each file here is a request: if you hold one of the files under "
"\"Files to source\", [adding it](contributing.md) fills the gap for "
"Each file here is a request: for a file under \"Files to source\", "
"[adding it](contributing.md) fills the gap for "
"every platform at once.",
"",
" ".join([
@@ -11581,7 +11585,7 @@ def generate_not_found() -> str:
'<p><button type="button" class="rb-notfound-search" data-rb-open-search>Search the site</button></p>',
"",
"- [Start here](index.md): what BIOS files are and how to install them",
"- [Get your files](which-pack.md): the pack or the command for your device",
"- [Get the files](which-pack.md): the pack or the command for each device",
"- Browse [platforms](platforms/index.md), [systems](systems/index.md) "
"or [emulators](emulators/index.md)",
"",
@@ -11622,7 +11626,7 @@ def generate_wiki_data_model(db: dict, profiles: dict) -> str:
"lists every file the collection holds, keyed by its SHA1. A platform YAML "
"copies the BIOS list a setup publishes. An emulator profile records what "
"one emulator opens, read from its code. The sections below show the "
"fields of each, with a real entry, so you can read the data or build on it.",
"fields of each, with a real entry, to read the data or build on it.",
"",
"Example: `scph5501.bin` is one entry of `database.json` (key "
"`0555c6fa...`), one entry of `platforms/batocera.yml` (its MD5) and one "
@@ -11943,22 +11947,26 @@ 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)}",
"---",
"",
"# Get your files",
"# Get the files",
"",
"Pick your setup. Its page gives the folder and the command.",
"Pick the setup. Its page gives the folder and the command.",
"",
"## Pick your setup",
"## Pick the setup { #pick-your-setup }",
"",
*setup_picker(registry, display),
"## Folders and ZIP files { #manual-download }",
"",
"- A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.",
"- Unzip them into your BIOS folder. Put the files straight in it, "
"- Unzip them into the BIOS folder. Put the files straight in it, "
"not in a new folder.",
"- Leave the ZIPs inside, like `neogeo.zip`, as they are.",
"- A few ZIPs hold their own top folder. Their setup's page says where "
@@ -11987,21 +11995,22 @@ def generate_which_pack() -> str:
*ios_section(),
"## Not in the list? One command finds it { #quick-install }",
"",
"A **terminal** is a window where you type commands.",
"A **terminal** is a window for typing 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.",
"The setup's page shows how.",
"",
f"With Python, `{PACKAGE_COMMAND}` does the same.",
"",
*first_screen_words(),
"",
*install_demo(),
*trust_box(""),
"**Check your folder later.** Add `--check`. It changes nothing.",
"**Check the folder later.** Add `--check`. It changes nothing.",
"",
*terminal_tabs(all_tabs, dict.fromkeys(TERMINALS, "--check"), steps=False),
*markup.details("note", "Fill an SD card from a computer", [
"Name your setup and the BIOS folder on the card. For RetroArch on a "
"Name the setup and the BIOS folder on the card. For RetroArch on a "
"Nintendo Switch:",
"",
"Linux, macOS:",
@@ -12014,14 +12023,7 @@ def generate_which_pack() -> str:
*fence("powershell", install_command(
"powershell", "--platform retroarch --dest E:\\retroarch\\cores\\system")),
]),
*markup.details("note", "Have Python? Run it as a package", [
"The same installer, with Python 3.8 or newer:",
"",
*fence("sh", PACKAGE_COMMAND),
"",
"`pipx run retrobios` works too, or `pip install retrobios` and then `retrobios`.",
]),
*markup.details("note", "Copy the BIOS files from your old card", [
*markup.details("note", "Copy the BIOS files from an old card", [
"Plug the old card in and add `--from` with its folder. The BIOS files "
"found there are copied, and the rest downloaded. The old card is only read.",
"",
@@ -12113,7 +12115,7 @@ def _platform_nav(coverages: dict, registry: dict) -> list:
# The tabs in reading order, by the level their pages declare.
TABS = {
"start": "Start here",
"setup": "Your setup",
"setup": "Setups",
"explore": "Explore",
"reference": "Reference",
"contribute": "Contribute",
@@ -12213,7 +12215,7 @@ def generate_nav(
) -> list:
"""The navigation, five tabs in reading order.
Start here is for someone who knows nothing yet, Your setup for someone
Start here is for someone who knows nothing yet, Setups for someone
who knows their device, Explore for one system or emulator, Reference for
checking the work, Contribute for adding to it. Each tab opens on its
index page.
@@ -12224,7 +12226,7 @@ def generate_nav(
own order.
"""
generated: dict[str, list[tuple]] = {
"start": [(0, "index.md"), (20, {"Get your files": "which-pack.md"})],
"start": [(0, "index.md"), (20, {"Get the files": "which-pack.md"})],
"setup": [(10 * i, item)
for i, item in enumerate(_platform_nav(coverages, registry or {}))],
"explore": [
+279 -95
View File
@@ -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 <site>/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 <site>/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/<stem>.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/<stem>.webm the site, with <stem>.mp4 as fallback
docs_assets/demo/<stem>.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:
+5 -1
View File
@@ -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)
+7 -7
View File
@@ -32,7 +32,7 @@ QUOTING = (f"A folder goes in single quotes, attached to its option. One that ho
"command and refuses such a folder.")
START_PAGES = (
("Which pack do I need", "which-pack/", "pick a device or setup, get the folder its BIOS files go in, "
("Which pack is needed", "which-pack/", "pick a device or setup, get the folder its BIOS files go in, "
"one command that installs and checks them, or a ZIP pack"),
("First install", "wiki/first-install/", "the steps from nothing to a working BIOS folder"),
("What are BIOS files", "wiki/what-are-bios-files/", "why emulators need them and where they come from"),
@@ -57,7 +57,7 @@ MORE = (
("Emulators", "emulators/", "one page per emulator"),
("Systems", "systems/", "one page per console maker, every file with its hashes"),
("Installer", "wiki/installer/", "every option of install.py"),
("What we count", "wiki/privacy/", "the site's cookie-free counting"),
("What is counted", "wiki/privacy/", "the site's cookie-free counting"),
("Is it legal?", "wiki/legal/", "the project's position"),
("Contributing", "contributing/", "add a missing file or an emulator profile"),
)
@@ -142,7 +142,7 @@ ANSWER_RULES = (
f"up: {SITE}files/?hash=<value> (the page) or api/v1/lookup/<first two hex>.json (the data).",
"Prefer the installer to a manual copy: it puts each file in its folder and checks its hash. "
"Give the command the setup's page gives, with `--for-games --required-only` for the fewest files.",
"Say where an answer comes from: link the page you read.",
"Say where an answer comes from: link the source page.",
"On a page of the site, the WebMCP tools answer from the data: setup_bios_files (folder and "
"files of a setup), emulator_bios_files, lookup_bios_hash, device_setup_command, search_retrobios.",
f"Whether sharing BIOS files is legal depends on the country: point to [Is it legal?]({SITE}wiki/legal/), "
@@ -173,7 +173,7 @@ def skill_md() -> str:
"",
"## Steps",
"",
"1. Ask where the games are: on the device itself (you have its terminal or SSH), or on "
"1. Ask where the games are: on the device itself (with its terminal or SSH), or on "
"its card plugged into this computer.",
f"2. Preview, nothing is written: `{SH} --for-games --required-only --dry-run` "
f"(Windows PowerShell: `{PS} --for-games --required-only --dry-run`).",
@@ -221,9 +221,9 @@ def ard_manifest() -> dict:
"description": "An agent skill that installs the BIOS files a device needs, only "
"what the games on its card need to start.",
"representativeQueries": [
"install the minimum BIOS files for the games on my R36S running dArkOS",
"set up BIOS files for RetroArch on my Steam Deck",
"my emulator says BIOS missing, fix it",
"install the minimum BIOS files for the games on an R36S running dArkOS",
"set up BIOS files for RetroArch on a Steam Deck",
"the emulator says BIOS missing, fix it",
],
"capabilities": ["SetupBios"],
"tags": ["bios", "firmware", "emulation", "retrogaming"],
+7 -7
View File
@@ -397,8 +397,8 @@ def _emulator_names_in(emulators_dir: str) -> dict[str, str]:
def _bios_chain_boxes() -> list[Box]:
platform, _emulator = CHAIN_EXAMPLES[0]
return [
Box("Your device", ["A PC, a Steam Deck, a handheld or a TV box"], number="1"),
Box("Setup", ["The menu you pick a game from"], number="2"),
Box("The device", ["A PC, a Steam Deck, a handheld or a TV box"], number="1"),
Box("Setup", ["The menu that picks a game"], number="2"),
Box("Emulator", ["Starts the game and reads its BIOS"], number="3"),
Box("BIOS folder", [f"{_display(platform)} reads", Mono(_folder(platform))],
accent=True, number="4"),
@@ -421,8 +421,8 @@ def _bios_chain_caption() -> str:
def _install_boxes() -> list[Box]:
return [
Box("Finds your setup", ["And the folder it reads"], number="1"),
Box("Looks at your files", ["The ones already in the folder"], number="2"),
Box("Finds the setup", ["And the folder it reads"], number="1"),
Box("Looks at the files", ["The ones already in the folder"], number="2"),
Box("Downloads the rest", ["Only what is missing or different"], number="3"),
Box("Checks each file", ["Before saving it"], accent=True, number="4"),
]
@@ -604,7 +604,7 @@ def _figures() -> dict[str, Figure]:
"install-flow": Figure(
"install-flow",
"What the command does",
"The command finds your setup and its BIOS folder, looks at the files already "
"The command finds the setup and its BIOS folder, looks at the files already "
"there, downloads what is missing or different, then checks each file before "
"saving it.",
"Running the command again repeats the same steps and downloads only what changed.",
@@ -904,7 +904,7 @@ GLANCE_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
"--cores-dir")),
("Look first", ("--check", "--dry-run", "--list-platforms", "--list-targets",
"--list-systems", "--list-cores", "--list-emulators")),
("From files you have", ("--from", "--from-pack", "--manifest", "--offline", "--restore")),
("From files on hand", ("--from", "--from-pack", "--manifest", "--offline", "--restore")),
("How the run talks", ("--tui", "--plain", "--no-input", "--yes", "--quiet", "--verbose",
"--events", "--jobs", "--version", "--help")),
)
@@ -999,7 +999,7 @@ def prepare_page(markdown: str, page: Path) -> str:
OG_SECTIONS: dict[str, tuple[str, str]] = {
"home": ("BIOS and firmware packs", "Checked file by file, for {frontends}"),
"start": ("Start here", "What BIOS files are, and how to install them with one command"),
"setup": ("Your setup", "Where the files go on each device and frontend"),
"setup": ("Setups", "Where the files go on each device and frontend"),
"explore": ("Systems and emulators", "Which files each console and emulator reads"),
"reference": ("Reference", "Verification modes, gaps, provenance and the data API"),
"contribute": ("Contribute", "Adding files, emulator profiles and platforms"),
+7 -2
View File
@@ -462,11 +462,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]
@@ -474,7 +477,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)
+3 -3
View File
@@ -651,8 +651,8 @@ def _sidebar_link(rel: str) -> str:
PRUNED_TABS = ("explore",)
# Reading runs from one tab into the next only here: the last Start here
# page leads to Your setup. Every other tab is read on its own.
CHAINED_TABS = (("start-here", "your-setup"),)
# page leads to Setups. Every other tab is read on its own.
CHAINED_TABS = (("start-here", "setups"),)
def topic_id(label: str) -> str:
@@ -804,7 +804,7 @@ def sidebar(nav: list, present: set[str], anchors: dict[str, set[str]] | None =
def _reading(nav: list, present: set[str]) -> list[list[dict]]:
"""The previous and next links, in runs. An overview is named after the
group or tab it opens ("Your setup", not "Overview")."""
group or tab it opens ("Setups", not "Overview")."""
def walk(value: list, name: str) -> Iterator[dict]:
for entry in value:
if isinstance(entry, str):
+269
View File
@@ -0,0 +1,269 @@
"""What the install box of Get the 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'<path d="{d}" fill="none" stroke="{STROKE}" stroke-width="{_n(width)}" class="{cls}"/>'
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'<text x="{_n(left + width / 2)}" y="{_n(top + 16.5)}" font-size="{_n(KEY_SIZE)}" '
f'text-anchor="middle" fill="currentColor" style="{MONO_STYLE}">{escape(label)}</text>')
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'<circle cx="{_n(left + 11)}" cy="{_n(top + 11)}" r="5" fill="none" stroke="{STROKE}" '
'stroke-width="1.5" class="rb-d-line"/>',
_stroke(f"M{_n(left + 15)} {_n(top + 15)} L{_n(left + 19)} {_n(top + 19)}"),
f'<text x="{_n(left + 25)}" y="{_n(top + 16.5)}" font-size="{_n(KEY_SIZE)}" '
f'fill="currentColor" style="{MONO_STYLE}">{escape(word)}</text>',
]
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'<text x="{_n(cx - 24)}" y="{_n(top + 28)}" font-size="{_n(KEY_SIZE)}" '
f'fill="currentColor" style="{MONO_STYLE}">&gt;_</text>',
]
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'<rect x="{_n(cx - 13)}" y="{_n(top)}" width="26" height="40" rx="12" fill="none" '
f'stroke="{STROKE}" stroke-width="1.5" class="rb-d-line"/>',
# The right button, the one to press.
f'<path d="M{_n(cx + 1)} {_n(top + 1)} A12 12 0 0 1 {_n(cx + 12)} {_n(top + 13)} '
f'L{_n(cx + 1)} {_n(top + 13)} Z" fill="currentColor" class="rb-d-num"/>',
_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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {_n(width)} {_n(height)}" '
f'width="{_n(width)}" height="{_n(height)}" role="img" '
f'aria-labelledby="{base}-t {base}-d" font-family="{FONT}" '
f'class="rb-diagram rb-diagram-{layout}">'
f'<title id="{base}-t">{escape(title)}</title>'
f'<desc id="{base}-d">{escape(description(tab))}</desc>'
+ "".join(body) + "</svg>"
)
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'<figure class="rb-figure rb-steps" {markup.search_exclude()}>',
_svg(tab, "wide") + _svg(tab, "narrow"), "</figure>", ""]
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})