chore: merge origin/main

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 21:18:18 +01:00
commit 399b5c33c1
19 files changed
+689 -8

No files matched your search

+18
View File
@@ -443,6 +443,9 @@ def format_size(size: int) -> str:
SITE_URL = "https://abdess.github.io/retrobios/"
# The installer demo (scripts/make_demo.py): the recording and what it shows.
DEMO_DIR = Path(__file__).resolve().parent.parent / "docs_assets" / "demo"
DEMO_GIF = "docs_assets/demo/install.gif"
REPO_PATH = "../.."
REPO = "Abdess/retrobios"
# GitHub's "Add file > Upload files" page for bios/: a browser upload by
@@ -473,6 +476,19 @@ 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_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.")
def _existing_contributor_block(readme_path: str = "README.md") -> list[str]:
"""The contributors already published, read back from the file.
@@ -619,6 +635,8 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
install_command("powershell", "--check"),
"```",
"",
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.",
+23 -1
View File
@@ -82,6 +82,8 @@ from generate_readme import (
NO_PACK,
asset_links,
contributing_text,
demo_description,
demo_facts,
download_links,
extract_notes,
extract_targets,
@@ -516,6 +518,7 @@ def generate_home(
"**Run one command.** Some setups have a ZIP.",
"**Start a game.**",
]),
*install_demo(),
"## Pick your device",
"",
*setup_picker(registry, display),
@@ -4155,6 +4158,20 @@ def first_screen_words() -> list[str]:
f"and **{installer.TUI_FOLDER_TITLE}**), then offers **{installer.TUI_INSTALL_ROW}**."]
# 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")
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()
text = html.escape(demo_description(facts), quote=True)
return [f'<InstallDemo description="{text}" width="{facts["width"]}" '
f'height="{facts["height"]}" />', ""]
def check_pass_line() -> str:
"""The pass line of "How to check": the words `--check` prints only when
no file is missing or wrong (install.py EVERYTHING_IN_PLACE), read from
@@ -7910,7 +7927,7 @@ def generate_emulators_index(profiles: dict) -> str:
def _snippet_noun(names: Iterable[str], firmware: int, fetched: int) -> str:
"""What an emulator's files are called in its snippet. A Switch
emulator reads keys, and "eden keys" is what readers search for; Ship
emulator reads keys, and "eden keys" is what readers search for. Ship
of Harkinian reads game ROMs, which are no BIOS."""
names = list(names)
keys = sum("key" in name.casefold() for name in names)
@@ -11957,6 +11974,7 @@ def generate_which_pack() -> str:
"",
*first_screen_words(),
"",
*install_demo(),
*trust_box(""),
"**Check your folder later.** Add `--check`. It changes nothing.",
"",
@@ -12316,6 +12334,10 @@ def _generate(args: argparse.Namespace) -> None:
source = Path("docs_assets") / folder
if source.is_dir():
shutil.copytree(source, public / "assets" / dest, dirs_exist_ok=True)
demo_dest = public / "assets" / "demo"
demo_dest.mkdir(parents=True, exist_ok=True)
for name in DEMO_ASSETS:
shutil.copy2(Path("docs_assets") / "demo" / name, demo_dest / name)
assets_src = Path(".github") / "assets"
for name, dest_name in [("logo.png", "logo.png"), ("favicon.png", "favicon.png")]:
src = assets_src / name
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""Record the installer demo 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:
- 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.
- 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.
- 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/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
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
"""
from __future__ import annotations
import argparse
import functools
import hashlib
import http.server
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import threading
from collections.abc import Iterator
from contextlib import contextmanager
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"
CARD_SIZE = "29g"
SETUP = "ArkOS"
# 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.
CARD_FOLDERS = ("bios", "bgmusic", "launchimages", "themes/es-theme-nes-box")
GAMES = {
"psx": "Sky Courier (USA).iso",
"segacd": "Night Shift (USA).iso",
"megadrive": "Old Harbour (World).md",
"mastersystem": "Tide Runner (Europe).sms",
"gamegear": "Pocket Quest (World).gg",
"pcengine": "Star Lancer (Japan).pce",
}
GAME_BYTES = 256 * 1024
# A PlayStation disc carries Sony's licence text in sector 4, the text the
# 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}
GIF_FPS = 10
VIDEO_FPS = 25
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()
blocks, counter = [], 0
while len(blocks) * 32 < GAME_BYTES:
blocks.append(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
counter += 1
return b"".join(blocks)[:GAME_BYTES]
def build_card(card: Path) -> None:
if card.exists():
shutil.rmtree(card)
for folder in CARD_FOLDERS:
(card / folder).mkdir(parents=True)
(card / "launchimages" / "loading.jpg").write_bytes(b"\xff\xd8\xff\xe0" + bytes(60) + b"\xff\xd9")
(card / "themes" / "es-theme-nes-box" / "theme.xml").write_text(
"<theme>\n</theme>\n", encoding="utf-8")
for folder, name in GAMES.items():
data = bytearray(game_bytes(name))
if folder == "psx":
data[4 * 2048:4 * 2048 + len(PSX_LICENCE)] = PSX_LICENCE
(card / folder).mkdir()
(card / folder / name).write_bytes(bytes(data))
class QuietHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None: # noqa: A002
pass
@contextmanager
def served_repository() -> Iterator[str]:
"""The repository on a loopback port, the base URL install.py accepts
without HTTPS."""
handler = functools.partial(QuietHandler, directory=str(ROOT))
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_address[1]}"
finally:
server.shutdown()
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."""
rc = work / "bashrc"
rc.write_text(
"PS1='$ '\n"
"unset HISTFILE PROMPT_COMMAND\n"
"curl() {\n"
' if [ "$*" = "-fsSL https://abdess.github.io/retrobios/i" ]; then\n'
' printf \'exec %s "$@"\\n\' "$RETROBIOS_DEMO_BOOTSTRAP"\n'
" else\n"
' command curl "$@"\n'
" fi\n"
"}\n"
"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"
"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'
'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'
"fi\n"
'exec unshare --user --mount --map-root-user /bin/sh -c "$inner" sh "$@"\n',
encoding="utf-8",
)
session.chmod(0o755)
return session
def session_env(base_url: str, work: Path, card: Path) -> dict[str, str]:
home = work / "home"
home.mkdir(parents=True, exist_ok=True)
return {
"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_BASE_URL": base_url,
# 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."""
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))
def measure(session: Path, env: dict[str, str]) -> 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"]
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}")
events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")]
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 {
"width": size["width"],
"height": size["height"],
"setup": SETUP,
"games": games["games"],
"systems": len(games["found"]),
"files": check["missing"],
"size": format_size(check["bytes_needed"]),
}
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",
"-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",
"-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"))
def check_sizes(out: Path) -> 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]
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]
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)
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)
if over:
sys.exit("Over the size targets:\n " + "\n ".join(over))
finally:
shutil.rmtree(WORK, ignore_errors=True)
if __name__ == "__main__":
from common import run_cli
run_cli(main)
+2 -1
View File
@@ -29,6 +29,7 @@ COMPONENTS = {
"TabItem": "@astrojs/starlight/components",
"FilePicker": "components/FilePicker.astro",
"MeasurementsExplorer": "components/MeasurementsExplorer.astro",
"InstallDemo": "components/InstallDemo.astro",
}
# Pages of file cards: level 3 headings by the hundred, so the table of
@@ -330,7 +331,7 @@ OG_DIR = Path(__file__).resolve().parent.parent / "docs_assets" / "og"
# An emulator page's card is that of the maker of the first system it runs,
# by page path: "emulators/mgba.mdx" -> "nintendo". One card per emulator
# would be some 900 images in the repository; the maker's names the
# would be some 900 images in the repository, and the maker's names the
# consoles. Filled by generate_site.record_emulator_page.
EMULATOR_CARDS: dict[str, str] = {}