mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
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:
1 parent
27b945ba4c
commit
c1a02ef24f
5 files changed
+399
-98
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user