From c1a02ef24f6bee5e85254fb00f152406a71ee192 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:22:04 +0100 Subject: [PATCH] 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. --- scripts/generate_installer_doc.py | 277 ++++++++++++++++++++++++++++-- scripts/pipeline.py | 6 + tests/test_doc_pages.py | 118 ++++++++----- wiki/first-install.md | 2 +- wiki/installer.md | 94 ++++++---- 5 files changed, 399 insertions(+), 98 deletions(-) diff --git a/scripts/generate_installer_doc.py b/scripts/generate_installer_doc.py index 803804ea3..195a31693 100644 --- a/scripts/generate_installer_doc.py +++ b/scripts/generate_installer_doc.py @@ -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/.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/.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") diff --git a/scripts/pipeline.py b/scripts/pipeline.py index 5cd4d3c97..7ee5d5100 100644 --- a/scripts/pipeline.py +++ b/scripts/pipeline.py @@ -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: diff --git a/tests/test_doc_pages.py b/tests/test_doc_pages.py index 34303b8cc..0bc3dc025 100644 --- a/tests/test_doc_pages.py +++ b/tests/test_doc_pages.py @@ -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)\n(.*?)", + 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"", 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 diff --git a/wiki/first-install.md b/wiki/first-install.md index 6d3c6eb99..f4d884288 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -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 diff --git a/wiki/installer.md b/wiki/installer.md index 3304fe810..8e2b8782f 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -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: + + +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 ``` + 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 } + + 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. + ### Before the run @@ -431,77 +438,87 @@ command never writes one. ### The first lines -``` -RetroBIOS 8c0e9c762d2f + +``` Looking for setups here... Found Batocera at /userdata/bios ``` + -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: + + ``` 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 ``` + `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: + + ``` -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 ``` + 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 + + ``` -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. ``` + 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. + + + +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%). ``` + `--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: + + ``` Checking existing files... - 14481 in place, 0 missing, 0 wrong copies + 14723 in place, 0 missing, 0 wrong copies Everything is in place. ``` + 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