fix: print installer page samples from install.py

The dry run, the Batocera walkthrough and the narrowing line of
wiki/installer.md were typed by hand and went stale with each list.
generate_installer_doc.py now runs install.py against the committed
lists in a sandbox, dated by each list, and the pipeline runs it at
step 7.
This commit is contained in:
Abdessamad Derraz committed 2026-10-28 16:22:04 +01:00
1 parent 27b945ba4c
commit c1a02ef24f
5 files changed
+399 -98

No files matched your search

+264 -13
View File
@@ -13,16 +13,32 @@ does, when an event comes) are kept as written, matched by their row. A row
install.py gains has no prose yet: the generator names it and writes nothing,
and the row is added by hand inside the markers, its first cell holding the
option, kind, code or value alone.
The run samples (a dry run, a first install, a later check) are printed by
install.py itself, run in its own process against the committed
install/<platform>.json served on the loopback, from a home and a card made
under tmp/ and shown as /home/me and /userdata. Only the transport and the
machine are stood in for, see _child. Each is dated by the `generated` field
of its list, so a rendering is the same from one day to the next.
"""
from __future__ import annotations
import argparse
import ast
import contextlib
import http.server
import importlib.util
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path, PurePosixPath
from typing import Callable
ROOT = Path(__file__).resolve().parent.parent
PAGE = ROOT / "wiki" / "installer.md"
@@ -198,10 +214,7 @@ def line_menu() -> str:
function line mode runs, its questions answered here. The page carried
a copy typed by hand, whose keys and counts drifted from the installer
(code_audit_22, owner's note on which-pack)."""
import contextlib # noqa: PLC0415
import io # noqa: PLC0415
import json # noqa: PLC0415
import os # noqa: PLC0415
from unittest import mock # noqa: PLC0415
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
@@ -239,8 +252,240 @@ def line_menu() -> str:
return "```\n" + out.getvalue().strip("\n") + "\n```"
# The home and the card the run samples show, in place of the sandbox's.
SHOWN_HOME = "/home/me"
SHOWN_CARD = ""
RETROARCH_CFG = ('system_directory = "~/.config/retroarch/system"\n'
'savefile_directory = "~/.config/retroarch/saves"\n')
class _ListHandler(http.server.SimpleHTTPRequestHandler):
"""Serves the committed file lists, install/<platform>.json, and nothing
else: a sample that asks for a file is a sample that downloads."""
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, directory=str(ROOT), **kwargs)
def do_GET(self) -> None: # noqa: N802
if not re.fullmatch(r"/install/[a-z0-9_-]+\.json", self.path):
self.send_error(404)
return
super().do_GET()
def log_message(self, *_args: object) -> None:
return
def _child(spec: dict) -> int:
"""install.main() as the sample runs it, in its own process. Only the
transport and the machine are stood in for: no connection leaves the
loopback, detection finds the card folder the spec names, a file of a
landed run reports its bytes written without fetching them, and a held
run finds every file in place. The counts, sizes and words are the
installer's own."""
import concurrent.futures # noqa: PLC0415
import socket # noqa: PLC0415
from unittest import mock # noqa: PLC0415
real_connect = socket.create_connection
def loopback_only(address: tuple, *args: object, **kwargs: object) -> socket.socket:
if address[0] not in ("127.0.0.1", "localhost"):
raise OSError(f"the sample makes no request to {address[0]}")
return real_connect(address, *args, **kwargs)
def landed(f: dict, _bios: Path, _verbose: bool = False, progress: object = None,
_copy: object = None) -> tuple[str, bool, str]:
if progress is not None:
progress.add(f["size"])
return f["dest"], True, ""
def held(files: list[dict], _bios: Path, **_kwargs: object) -> tuple:
return [], list(files), []
patches = [
mock.patch.object(socket, "create_connection", loopback_only),
# One worker and the submission order: the progress lines of a log.
mock.patch.object(concurrent.futures, "as_completed", list),
mock.patch.object(sys, "argv", ["install.py", *spec["argv"]]),
]
if spec.get("card"):
patches += [
mock.patch.object(_install, "detect_platforms",
lambda _os: [(spec["platform"], Path(spec["card"]))]),
mock.patch.object(_install, "_mounted_cards", lambda: []),
]
if spec.get("landed"):
patches.append(mock.patch.object(_install, "_download_one", landed))
if spec.get("held"):
patches.append(mock.patch.object(_install, "check_local", held))
with contextlib.ExitStack() as stack:
for patch in patches:
stack.enter_context(patch)
try:
_install.main()
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else 1
return 0
# What the process of a run sample executes, the spec as its one argument.
CHILD = ("import json, sys; sys.path.insert(0, 'scripts'); import generate_installer_doc; "
"sys.exit(generate_installer_doc._child(json.loads(sys.argv[1])))")
class Sandbox:
"""A home with a RetroArch config and a card folder, under tmp/, and the
committed file lists served on the loopback. Every run sample of the page
is printed here."""
def __enter__(self) -> Sandbox:
(ROOT / "tmp").mkdir(exist_ok=True)
self.root = Path(tempfile.mkdtemp(prefix="installer-doc-", dir=ROOT / "tmp"))
self.home = self.root / "home"
self.card = self.root / "card"
config = self.home / ".config" / "retroarch" / "retroarch.cfg"
config.parent.mkdir(parents=True)
config.write_text(RETROARCH_CFG, encoding="utf-8")
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _ListHandler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
return self
def __exit__(self, *_exc: object) -> None:
self.server.shutdown()
self.server.server_close()
shutil.rmtree(self.root)
def run(self, platform: str, *argv: str, landed: bool = False,
held: bool = False) -> str:
"""What install.py prints for the run, the sandbox read as a user's
home and card."""
card = self.card / "userdata" / "bios" if platform != "retroarch" else None
if card is not None:
card.mkdir(parents=True, exist_ok=True)
spec = {"platform": platform, "argv": ["--plain", "--platform", platform, *argv],
"card": str(card) if card else None, "landed": landed, "held": held}
env = {"PATH": os.environ.get("PATH", ""), "HOME": str(self.home),
"RETROBIOS_OS": "linux", "LANG": "C.UTF-8",
"RETROBIOS_BASE_URL": f"http://127.0.0.1:{self.server.server_address[1]}"}
done = subprocess.run(
[sys.executable, "-s", "-c", CHILD, json.dumps(spec)],
cwd=ROOT, env=env, stdin=subprocess.DEVNULL, capture_output=True, text=True,
check=False)
if done.returncode != 0:
raise SampleFailed(f"install.py {' '.join(spec['argv'])} exited "
f"{done.returncode}: {done.stderr.strip()[-2000:]}")
return done.stdout.replace(str(self.home), SHOWN_HOME).replace(str(self.card), SHOWN_CARD)
# The runs printed in this process, by what they read: the page is rendered
# more than once by --check and by the tests, from the same installer and lists.
_RUNS: dict[tuple, str] = {}
def _stamp(platform: str) -> tuple[int, int]:
return tuple(hash(path.read_bytes()) for path in
(INSTALLER, ROOT / "install" / f"{platform}.json"))
class _LazySandbox:
"""A Sandbox opened at the first run a rendering asks for, and only then."""
def __init__(self, stack: contextlib.ExitStack) -> None:
self.stack = stack
self.box: Sandbox | None = None
def run(self, platform: str, *argv: str, **kinds: bool) -> str:
key = (platform, argv, kinds.get("landed", False), kinds.get("held", False),
_stamp(platform))
if key in _RUNS:
return _RUNS[key]
if self.box is None:
self.box = self.stack.enter_context(Sandbox())
_RUNS[key] = self.box.run(platform, *argv, **kinds)
return _RUNS[key]
class SampleFailed(Exception):
"""A run sample install.py could not print."""
def _lines_from(text: str, first: str, last: str | None = None, count: int | None = None
) -> list[str]:
"""The lines of a run from the one starting with first, through the one
starting with last or for count lines."""
lines = text.splitlines()
start = next((i for i, line in enumerate(lines) if line.startswith(first)), None)
if start is None:
raise SampleFailed(f"the run printed no line starting {first!r}")
if count is not None:
return lines[start:start + count]
end = next((i for i in range(start, len(lines)) if lines[i].startswith(last)), None)
if end is None:
raise SampleFailed(f"the run printed no line starting {last!r} after {first!r}")
return lines[start:end + 1]
def _fenced(lines: list[str]) -> str:
return "```\n" + "\n".join(lines) + "\n```"
def list_date(platform: str) -> str:
"""The day the committed list was generated, which dates its samples."""
manifest = json.loads((ROOT / "install" / f"{platform}.json").read_text(encoding="utf-8"))
return manifest["generated"][:10]
# The platform of the run walked through under Reading a run.
WALKTHROUGH = "batocera"
# A system of the RetroArch list one core pulls most of: the sample of the
# line naming it. A list where it no longer does fails the rendering, and
# another system is named here.
BULK_SYSTEM = "sony-playstation"
def _bulk_line(box: Sandbox | _LazySandbox) -> str:
run = box.run("retroarch", "--dry-run", "--system", BULK_SYSTEM)
found = [line for line in run.splitlines() if re.match(r" \S+ pulls ", line)]
if len(found) != 1:
raise SampleFailed(f"a RetroArch run with --system {BULK_SYSTEM} names no core "
"pulling most of its download: name another BULK_SYSTEM")
return found[0]
def sample_writers(box: Sandbox) -> dict[str, Callable[[str], str]]:
"""The run samples, each from the run that prints it."""
install_run = lambda: box.run(WALKTHROUGH, "--yes", "--jobs", "1", landed=True) # noqa: E731
check_run = lambda: box.run(WALKTHROUGH, "--check", held=True) # noqa: E731
dry_run = lambda: box.run("retroarch", "--dry-run") # noqa: E731
return {
"dry-run": lambda _body: (
f"A RetroArch dry run into an empty folder, with the list of "
f"{list_date('retroarch')}, printed this:\n\n"
+ _fenced(_lines_from(dry_run(), "Fetching file index", " 0 in place"))),
"run-date": lambda _body: (
f"Each line a first install prints, and what it means. The run below is a\n"
f"{_install.display_name(WALKTHROUGH)} install into an empty folder, from the "
f"list of {list_date(WALKTHROUGH)}.\nThe figures change with each release."),
"run-first-lines": lambda _body: _fenced(
_lines_from(install_run(), "Looking for setups", " Found")),
"run-index": lambda _body: _fenced(
_lines_from(install_run(), "Fetching file index", " 0 in place")),
"run-download": lambda _body: _fenced(
_lines_from(install_run(), "Downloading ", count=4)),
"run-end": lambda _body: _fenced(
_lines_from(install_run(), "Done.", "To check this later")),
"bulk-hint": lambda _body: (
f"RetroArch with `--system {BULK_SYSTEM}`, from the list of "
f"{list_date('retroarch')}:\n\n"
+ _fenced([_bulk_line(box)])),
"run-check": lambda _body: _fenced(
_lines_from(check_run(), "Checking existing files", " Everything is in place")),
}
def render(page: str) -> str:
"""The page with each marked table written from install.py."""
"""The page with each marked table and run sample written from install.py."""
writers = {
"options": lambda body: _table(["Option", "Effect"], option_rows(body)),
"exit-codes": lambda body: _table(["Code", "Meaning"], exit_rows(body)),
@@ -252,16 +497,19 @@ def render(page: str) -> str:
"line-menu": lambda _body: line_menu(),
}
seen = []
with contextlib.ExitStack() as stack:
box = _LazySandbox(stack)
writers.update(sample_writers(box))
def write(match: re.Match[str]) -> str:
name = match.group("name")
if name not in writers:
raise MissingProse(f"no table is written for the marker {name!r}")
seen.append(name)
table = writers[name](match.group("body"))
return f"{match.group('open')}\n{table}\n{match.group('close')}"
def write(match: re.Match[str]) -> str:
name = match.group("name")
if name not in writers:
raise MissingProse(f"no table is written for the marker {name!r}")
seen.append(name)
table = writers[name](match.group("body"))
return f"{match.group('open')}\n{table}\n{match.group('close')}"
text = BLOCK.sub(write, page)
text = BLOCK.sub(write, page)
absent = sorted(set(writers) - set(seen))
if absent:
raise MissingProse(f"markers missing from the page: {', '.join(absent)}")
@@ -282,10 +530,13 @@ def main(argv: list[str] | None = None) -> int:
print(f"{args.page}: {exc}. Add the row inside its markers, then run "
"python scripts/generate_installer_doc.py", file=sys.stderr)
return 1
except SampleFailed as exc:
print(f"{args.page}: {exc}", file=sys.stderr)
return 1
if text == current:
return 0
if args.check:
print(f"{args.page} differs from install.py: run "
print(f"{args.page} differs from install.py and install/*.json: run "
"python scripts/generate_installer_doc.py", file=sys.stderr)
return 1
args.page.write_text(text, encoding="utf-8")
+6
View File
@@ -24,6 +24,7 @@ Steps, numbered as the run prints them:
5 consistency check verify == pack: counts, paths, every emulator pack
6 --verify-packs pack integrity, extraction and hashes
7 generate_readme.py README.md and CONTRIBUTING.md
generate_installer_doc.py run samples and tables of wiki/installer.md
8 generate_site.py site pages, into the Starlight project in website/
8b npm run build (website/) rendered as the site deploy renders it, then validate_site.py
@@ -1106,6 +1107,11 @@ def main() -> None:
],
STEP_LABELS["generate_readme"],
)
# The run samples of wiki/installer.md, printed by install.py from
# the manifests step 4b just wrote.
doc_ok, _ = run([sys.executable, "scripts/generate_installer_doc.py"],
"7 generate installer page")
ok = ok and doc_ok
results["generate_readme"] = ok
all_ok = all_ok and ok
else:
+71 -47
View File
@@ -78,28 +78,71 @@ class TestSkeletonReadsTheResultShape(unittest.TestCase):
self.assertIn(key.value, result)
def _generated(text: str, name: str) -> str:
"""The body scripts/generate_installer_doc.py writes between the markers."""
return re.search(rf"(?s)<!-- generated: {name} -->\n(.*?)<!-- /generated: {name} -->",
text).group(1)
class InstallerOutputExample(unittest.TestCase):
"""The page showed a run with no `Not installable:` line while the
RetroArch manifest left eleven entries out, and a count that the menu
example below it no longer shared."""
example below it no longer shared. Its run samples were then typed by
hand and compared here, so a new list left the page stale and this test
red until someone retyped them: scripts/generate_installer_doc.py now
prints each one with install.py from the committed list."""
def test_run_and_menu_show_one_dated_run_of_the_manifest(self):
def test_the_samples_are_the_ones_the_installer_prints(self):
import generate_installer_doc
page = _page("installer.md")
self.assertEqual(generate_installer_doc.render(page), page,
"run python scripts/generate_installer_doc.py")
for name in ("dry-run", "run-date", "run-index", "run-download", "run-end", "run-check"):
with self.subTest(sample=name):
self.assertIn(f"<!-- generated: {name} -->", page)
def test_a_retyped_count_fails_the_check(self):
import generate_installer_doc
page = _page("installer.md")
for name in ("dry-run", "run-end", "bulk-hint"):
with self.subTest(sample=name):
body = _generated(page, name)
edited = re.sub(r"\d+", lambda m: str(int(m.group(0)) + 1), body, count=1)
stale = page.replace(body, edited)
self.assertNotEqual(stale, page)
self.assertEqual(generate_installer_doc.render(stale), page)
def test_a_sample_is_dated_by_its_list_never_by_the_day_it_ran(self):
import generate_installer_doc
page = _page("installer.md")
for name, platform in (("dry-run", "retroarch"), ("run-date", "batocera")):
with self.subTest(sample=name):
listed = (ROOT / "install" / f"{platform}.json").read_text(encoding="utf-8")
date = json.loads(listed)["generated"][:10]
self.assertIn(f"the list of {date}", _generated(page, name))
self.assertNotIn(str(ROOT), page)
home = f"{generate_installer_doc.SHOWN_HOME}/.config/retroarch"
self.assertIn(home, _generated(page, "dry-run"))
def test_run_and_menu_show_one_run_of_the_manifest(self):
import install
text = _page("installer.md")
intro, run = re.search(r"(?s)\n([^\n]*)\n\n```\n(Fetching file index.*?)```", text).groups()
self.assertRegex(intro, r"\d{4}-\d{2}-\d{2}", "an output example says when it was taken")
run = _generated(text, "dry-run")
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
omitted = re.search(r"Not installable: \d+ files? \(\d+ required, (.*)\)\.", run)
if manifest.get("omitted_files"):
self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it")
self.assertEqual(omitted is not None, bool(manifest.get("omitted_files")))
if omitted:
for part in omitted.group(1).split(", "):
self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values())
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups()
self.assertIn(f"0 in place, {count} missing", run)
self.assertSplitAddsUp(run, count, size)
menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)", text)
menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)[.,]([^`]*)",
_generated(text, "line-menu"))
self.assertEqual(menu.groups()[:2], (count, size))
self.assertSplitAddsUp(menu.group(3), count, size)
games = re.search(r"g\) Game data: kept \((\d+) files, ([\d.]+ [KMG]B)\)",
@@ -109,25 +152,6 @@ class InstallerOutputExample(unittest.TestCase):
r"files \(([\d.]+ [KMG]B)\)", menu.group(3))
self.assertEqual(games.groups(), split.groups()[2:])
def test_the_samples_say_what_the_installer_prints_for_the_list(self):
"""tui_audit_15 P3-15: both samples said 6.6 GB where the same list
prints 6.7 GB, the size updated by hand from its rounded parts. The
head is the installer's own tally of the list, its second spellings
and data files in it, as a run on Linux counts them."""
import install
text = _page("installer.md")
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
counts = install.tally([*manifest["files"], *(manifest.get("case_variants") or [])])
trees = manifest.get("data_directories") or []
data = sum(len(tree["files"]) for tree in trees)
data_bytes = sum(meta["size"] for tree in trees for meta in tree["files"].values())
total = f"{counts.files + data} files"
size = install.format_size(counts.size + data_bytes)
parts = install.parts_words(counts, data, data_bytes, 0)
self.assertIn(f"\n {total} ({size})\n {parts}.\n", text)
self.assertIn(f"\nRetroArch: {total}, {size}.\n {parts}.\n", text)
def assertSplitAddsUp(self, text: str, count: str, size: str) -> None:
"""The run counted 8069 files where its menu counted 3259 BIOS files
and 5089 game data files: the parts add up to the total they divide,
@@ -749,14 +773,11 @@ class FirstInstallWalkthrough(unittest.TestCase):
self.assertIn("reaches 1 GB", section)
self.assertNotRegex(section.replace("reaches 1 GB", ""), r"\d+(\.\d+)? ?[KMG]B\b")
def test_the_run_is_dated_and_consistent_with_the_manifest(self):
def test_the_run_is_one_run_of_the_manifest(self):
"""The counts are printed by the generator, so they are not retyped
here: what is checked is that the five samples are one run."""
import install
self.assertRegex(_section(self.run, "Reading a run { #reading-a-run }"),
r"published on\s+\d{4}-\d{2}-\d{2}")
step3 = _section(self.run, "The file index and the download")
step4 = _section(self.run, "The end of the run")
step5 = _section(self.run, "A later check")
# First install names the three endings the full screen shows as
# its title, and no count: a figure from one setup read as the size
# of every install.
@@ -765,25 +786,28 @@ class FirstInstallWalkthrough(unittest.TestCase):
with self.subTest(ending=ending):
self.assertIn(f"**{ending}**", _section(self.text, "5. Read the result"))
self.assertIn(f'title = "{ending}"', source)
self.assertNotRegex(self.text, r"Done\. \d+ files")
self.assertNotRegex(self.text, r"\d+ (in place|missing|files installed)")
self.assertIn(f" {install.EVERYTHING_IN_PLACE}", self.text)
step3 = _generated(self.run, "run-index")
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups()
self.assertIn(f"0 in place, {count} missing", step3)
self.assertIn(f"Downloading {count} files ({size})", step3)
self.assertIn(f"Downloading {count} files ({size})", _generated(self.run, "run-download"))
step4 = _generated(self.run, "run-end")
self.assertIn(f"Done. {count} files installed", step4)
self.assertIn(f"{count} in place, 0 missing", step5)
self.assertIn(f"{count} in place, 0 missing", _generated(self.run, "run-check"))
manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8"))
omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3)
if manifest.get("omitted_files"):
self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it")
if omitted:
total, required = map(int, omitted.groups())
first, needed = install._omission_lines(
total, [{"required": True, "dest": str(i)} for i in range(required)]
)[:2]
self.assertIn(first, step4)
self.assertIn(needed.split(":")[0], step4)
self.assertIn(f"all {required} belong", step4)
self.assertIn(f"other {total - required} are optional", step4)
omitted = manifest.get("omitted_files") or []
self.assertEqual("Not installable:" in step3, bool(omitted))
# The prose under the end of the run names both lines a required
# omission can take, whichever this list prints.
section = _section(self.run, "The end of the run")
for words in ("Not available here", "Needed to start an emulator"):
self.assertIn(f"`{words}`", section)
needed = [entry for entry in omitted if entry.get("required")]
if any(entry.get("category") == install.GAME_DATA for entry in needed):
self.assertIn("Not available here:", step4)
if any(entry.get("category") != install.GAME_DATA for entry in needed):
self.assertIn("Needed to start an emulator", step4)
def test_nothing_found_reads_like_the_installer(self):
# The one-liner hands the terminal to the installer, which opens the
+1 -1
View File
@@ -126,7 +126,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --check
All good:
```
109 in place, 0 missing, 0 wrong copies
Everything is in place.
```
## Undo
+57 -37
View File
@@ -388,18 +388,22 @@ names `~/Emulation/saves/retroarch/saves`. RetroPie, ROCKNIX, AmberELEC and
EmuELEC keep saves beside each game, and RomM keeps them in the browser: their
lists record these files as omitted.
A RetroArch dry run into an empty folder printed this on 2026-10-27:
<!-- generated: dry-run -->
A RetroArch dry run into an empty folder, with the list of 2026-10-27, printed this:
```
Fetching file index for retroarch...
106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves
10994 files (6.7 GB)
3269 BIOS files (4.1 GB), 5089 game data files (2.5 GB) and 2636 data files (38.5 MB).
Game data: files a game or a game engine reads, of no use to the other cores. Data files: the files an emulator reads from a data folder of its own, such as Dolphin's Sys and PPSSPP's assets.
Not installable: 37 files (1 required, not found: 37).
Checking existing files...
0 in place, 10994 missing, 0 wrong copies
```
<!-- /generated: dry-run -->
The second line divides the first, and every later count of the run (the
menu, the plan, `--dry-run`, the result) is taken from the same list.
@@ -413,9 +417,12 @@ the installer can fetch them.
## Reading a run { #reading-a-run }
<!-- generated: run-date -->
Each line a first install prints, and what it means. The run below is a
Batocera install into an empty folder, from the list as published on
2026-10-21. The figures change with each release.
Batocera install into an empty folder, from the list of 2026-10-27.
The figures change with each release.
<!-- /generated: run-date -->
### Before the run
@@ -431,77 +438,87 @@ command never writes one.
### The first lines
```
RetroBIOS 8c0e9c762d2f
<!-- generated: run-first-lines -->
```
Looking for setups here...
Found Batocera at /userdata/bios
```
<!-- /generated: run-first-lines -->
The code after RetroBIOS identifies the installer: it is the start of the
SHA-256 the command checked before running it. The path is the folder it will
write to.
A line above them, `RetroBIOS` and a code, identifies the installer: the
code is the start of the SHA-256 the command checked before running it. The
path is the folder it will write to.
### The file index and the download
Two phases follow. The first reads what is already in the folder. It takes a
few minutes on a memory card, because every file is hashed:
<!-- generated: run-index -->
```
Fetching file index for batocera...
14481 files (9.1 GB)
Not installable: 399 files (79 required, not found: 399).
23 files read from Batocera's save directory go to a folder per system under /userdata/saves
14723 files (9.2 GB)
3179 BIOS files (4.4 GB) and 11544 game data files (4.8 GB).
Game data: files a game or a game engine reads, of no use to the other cores.
Not installable: 425 files (79 required, not found: 425).
Checking existing files...
0 in place, 14481 missing, 0 wrong copies
0 in place, 14723 missing, 0 wrong copies
```
<!-- /generated: run-index -->
`Not installable` counts the files of the list that the collection does not
hold. They are named at the end of the run.
On a first install nothing is in place. On a later run the line counts what
is already there, and only the rest is downloaded. A download of 1 GB or more
is confirmed first (`Download 9.1 GB to /userdata/bios? [Y/n]`).
is confirmed first, the size and the folder in the question.
The second phase downloads what is missing, one line per file:
<!-- generated: run-download -->
```
Downloading 14481 files (9.1 GB)...
Downloading 14723 files (9.2 GB)...
Press Ctrl+C to stop. Running the same command again carries on from here.
[1/14481] 3do_arcade_saot.bin ok
[2/14481] 3dobios.zip ok
[1/14723] panafz1.bin ok
[2/14723] panafz10.bin ok
```
<!-- /generated: run-download -->
Ctrl+C is safe at any point. Files already installed stay, and running the
command again picks up where it stopped.
### The end of the run
<!-- generated: run-end -->
```
Done. 14481 files installed.
Done. 14723 files installed.
Installed: 3179 BIOS files and 11544 game data files.
BIOS folder: /userdata/bios
399 files were not installed: the collection does not hold them, or its copy fails their check.
Needed to start an emulator (79 files):
dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB, SPRITES.GOB, TEXTURES.GOB
dark-forces-ii (openjkdf2): openjkdf2/episode/JK1.gob, openjkdf2/episode/JK1CTF.gob, openjkdf2/episode/JK1MP.gob, openjkdf2/resource/Res1hi.gob, openjkdf2/resource/Res2.gob
diablo (devilutionx): devilutionx/diabdat.mpq
doom3 (dhewm3): base/pak000.pk4, base/pak002.pk4, base/pak003.pk4, base/pak004.pk4
droidworks (openjkdf2): dwCD.GOB, dwHD.GOB, dwMin.GOB, dwStream.GOB
fallout (fallout1-ce): fallout1-ce/critter.dat, fallout1-ce/master.dat
fallout2 (fallout2-ce): fallout2-ce/critter.dat, fallout2-ce/master.dat
fury (fury): fury.grp
... and 9 more systems (--check --verbose lists them all)
Each emulator's page says what these files are: https://abdess.github.io/retrobios/emulators/
425 files were not installed: the collection does not hold them, or its copy fails their check.
Not available here: DARK.GOB, DARKWAR.RTL, DARKWAR.WAD and 76 more, game data their games read. Copy them from your own copy of the game into /userdata/bios/, /userdata/bios/Source/, /userdata/bios/base/ and 10 more folders.
Game data: files a game or a game engine reads, of no use to the other cores.
To check this later, run the same command with --check.
```
<!-- /generated: run-end -->
When most of a download of 100 MB or more comes from one system, or from
one core of a single system, the end of the run says so and what narrowing
would save, once that saving is over half:
would save, once that saving is over half.
<!-- generated: bulk-hint -->
RetroArch with `--system sony-playstation`, from the list of 2026-10-27:
```
mame pulls 336.5 MB of this 349.0 MB download for 3do. --core opera keeps 12.5 MB of it and saves 336.5 MB (96%).
duckstation pulls 316.5 MB of this 329.0 MB download for sony-playstation. --core pcsx1 keeps 11.0 MB of it and saves 318.0 MB (96%).
```
<!-- /generated: bulk-hint -->
`--dry-run` prints the same line under its plan.
@@ -515,23 +532,26 @@ The word `Done` and the BIOS folder are the confirmation. Any other ending
was left undone, and the run says which case it was.
The lines between `BIOS folder:` and the `--check` hint name the files of the
list that the collection does not hold. `Needed to start an emulator` gives,
for each system, the emulators that read it and the files they cannot start
without. On the run above, all 79 belong to games (Dark Forces, Diablo, Doom 3,
Tomb Raider and the others): the engine reads them from a copy of the game
itself. The other 320 are optional, and the emulators reading them start
without them.
list that the collection does not hold. `Not available here` names the game
data an engine reads from a copy of the game itself (Dark Forces, Diablo, Doom
3), and the folders to copy them into. `Needed to start an emulator` gives,
for each system, the emulators that read it and the other files they cannot
start without. The rest are optional, counted in the first line only: the
emulators reading them start without them.
### A later check
The same command with `--check` reads the folder and writes nothing:
<!-- generated: run-check -->
```
Checking existing files...
14481 in place, 0 missing, 0 wrong copies
14723 in place, 0 missing, 0 wrong copies
Everything is in place.
```
<!-- /generated: run-check -->
The files the collection does not hold are listed after it again, as long as
they are absent from the folder. A number before `wrong copies` means a file is