mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 20:43:50 -06:00
chore: merge origin/main
This commit is contained in:
commit
45cf1accfb
28 files changed
+1084
-600
No files matched your search
+111
-265
@@ -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,281 +611,118 @@ 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"),
|
||||
"```",
|
||||
"",
|
||||
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"",
|
||||
"",
|
||||
"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:
|
||||
@@ -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
|
||||
|
||||
@@ -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'<InstallDemo description="{text}" width="{facts["width"]}" '
|
||||
f'height="{facts["height"]}" />', ""]
|
||||
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.",
|
||||
"",
|
||||
|
||||
+279
-95
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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'<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}">>_</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})
|
||||
Reference in new issue
Block a user