mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 21:13:49 -06:00
chore: merge origin/main
This commit is contained in:
commit
399b5c33c1
19 files changed
+689
-8
No files matched your search
@@ -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"",
|
||||
"",
|
||||
"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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user