chore: merge proven cards and one vocabulary

This commit is contained in:
Abdessamad Derraz committed 2026-10-28 15:01:11 +01:00
commit 27b945ba4c
19 files changed
+1609 -255

No files matched your search

+1 -1
View File
@@ -41,7 +41,7 @@ Every option, and what the installer detects and writes, is on the [installer pa
## Download BIOS packs
A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file. At a terminal it offers to install everything or to choose systems, cores and regions, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.
A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **Choose systems**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.
A pack over 2 GB comes in several parts (`Pack.part1of2.zip`, `Pack.part2of2.zip`), and every part is needed. Each part is an ordinary ZIP: extract them all into the same folder.
+1 -1
View File
@@ -16,7 +16,7 @@
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
# the site's build rewrites this line there (scripts/sitebootstrap.py).
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
$defaultInstallSha256 = "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7"
$defaultInstallSha256 = "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+611 -163
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
# curl or wget stop on a cut transfer before the hash is read: the site's
# build rewrites this line there (scripts/sitebootstrap.py).
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
DEFAULT_INSTALL_SHA256="67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7"
DEFAULT_INSTALL_SHA256="665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 939291,
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
"size": 961225,
"sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 992468,
"sha256": "06761ebb41ef22fa2c4b64c5e27fab5ed5508dbcd63c5a634527d03a7cdaa7d1"
"size": 1014402,
"sha256": "cdd06d24d30ad6b564d8a1595b906d50ce05d23093121cc7e5d91b5acd8e08ab"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 939291,
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
"size": 961225,
"sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 939291,
"sha256": "67cbe64d1de7b2f38edd25cab1b34384c55cac77e13f22f4c302fa2b04a809d7",
"size": 961225,
"sha256": "665534440e8b79c9643e6dc176c4a03da1ec497f151eb33fa0b9d4fb44809511",
"executable": false
},
{
+52
View File
@@ -188,6 +188,57 @@ def card_marker_rows() -> list[list[str]]:
return rows
# The folder the line menu example names: a home that is no one's.
MENU_FOLDER = "/home/me/.config/retroarch/system"
def line_menu() -> str:
"""The line menu of a RetroArch run, g then Enter pressed, as install.py
prints it for the RetroArch list (install/retroarch.json): the very
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"))
files = [*manifest["files"], *(manifest.get("case_variants") or [])]
folder = Path(MENU_FOLDER)
out = io.StringIO()
answers = iter(["g", ""])
def answer(prompt: str) -> str:
given = next(answers)
print(f"{prompt}{given}".rstrip())
return given
invocation = _install.Invocation()
invocation.os_type = "linux"
environment = {key: value for key, value in os.environ.items()
if key not in (_install.OFFLINE_VARIABLE, "RETROBIOS_BOOTSTRAP")}
with mock.patch.dict(os.environ, environment, clear=True), \
mock.patch.object(_install, "_INVOCATION", invocation), \
mock.patch.object(_install, "_ask", answer), \
mock.patch.object(_install, "folder_warnings", return_value=[]), \
contextlib.redirect_stdout(out):
cut = [False]
def command(choice: object) -> str:
invocation.chosen["retroarch"] = (
(_install.chosen_flags(choice) if choice is not None else [])
+ ([("--no-game-data", None)] if cut[0] else []))
return invocation.command("retroarch", folder)
_install._prompt_custom_selection(
"retroarch", files, manifest.get("omitted_files") or [], None, "RetroArch",
False, manifest.get("data_directories") or [], folder,
_install.records_selection(files), None, manifest, False, command, cut)
return "```\n" + out.getvalue().strip("\n") + "\n```"
def render(page: str) -> str:
"""The page with each marked table written from install.py."""
writers = {
@@ -198,6 +249,7 @@ def render(page: str) -> str:
"route-keys": lambda _body: route_keys(),
"card-markers": lambda _body: _table(["On the card", "System", "Files go to"],
card_marker_rows()),
"line-menu": lambda _body: line_menu(),
}
seen = []
+13 -1
View File
@@ -39,6 +39,18 @@ from siteguides import install_command
from verify import verify_platform
def _installer_words() -> object:
"""install.py read as a module, for the words its screen shows: the
README named a line menu the one-liner never opens."""
import importlib.util # noqa: PLC0415
spec = importlib.util.spec_from_file_location(
"install", Path(__file__).resolve().parent.parent / "install.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# The repository's profiles wherever the script is run from: a bare
# "emulators" read the working directory, and a run elsewhere counted 0 cores.
_EMULATORS_DIR = str(Path(__file__).resolve().parent.parent / "emulators")
@@ -582,7 +594,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json"
"",
"## Download BIOS packs",
"",
"A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file. At a terminal it offers to install everything or to choose systems, cores and regions, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.",
"A pack holds everything its platform runs: its own BIOS list plus every file its emulator cores load. Pick your platform, download the ZIP, extract to the BIOS path. Each Download link is a page that keeps its address from one release to the next. The installer above does the same file by file: its screen installs everything, or fewer under **" + _installer_words().TUI_SYSTEMS_ROW + "**, and `--system`, `--core`, `--region` and `--target` make the same choice without questions. For a bare minimum, build your own pack below.",
"",
"A pack over 2 GB comes in several parts (`Pack.part1of2.zip`,"
" `Pack.part2of2.zip`), and every part is needed. Each part is an"
+21 -16
View File
@@ -3934,11 +3934,22 @@ MISSING_IN_PLACE = "Reported missing anyway? `--check` names a wrong file."
# page shows in full, so the setup page links it.
SAFE_LINK = "[Is it safe?](../index.md#is-it-safe)"
# The pass line of "How to check". `--check` prints "All files up to date."
# only when no file is missing or wrong (install.py, the args.check branch
# after check_local). "0 wrong hash" alone is also what an empty folder
# prints: 0/14508 present (0 verified, 0 wrong hash).
CHECK_PASS_LINE = "All good when it says `All files up to date.`"
def first_screen_words() -> list[str]:
"""What a beginner sees when the command runs, the screen's own words
read from install.py: the one-liner opens the full screen, and the page
showed a line menu (Enter, i, c, q) nobody saw, whose keys had changed
since."""
installer = _installer()
return [f"The command opens a screen. **{installer.TUI_INSTALL_ROW}** gets every "
f"file, **{installer.TUI_SYSTEMS_ROW}** fewer."]
def check_pass_line() -> str:
"""The pass line of "How to check": the words `--check` prints only when
no file is missing or wrong (install.py EVERYTHING_IN_PLACE), read from
the installer. Typed here, it kept "All files up to date." after the
installer said "Everything is in place." (tui_audit_15 P3-10)."""
return f"All good when it says `{_installer().EVERYTHING_IN_PLACE}`"
# Under "From your computer": a device with no network installs from the ZIP
# (`--from-pack`). The id is the one wiki/installer.md gives its section.
@@ -4099,7 +4110,8 @@ def only_some_consoles(name: str, prefix: str = "") -> list[str]:
"""The way to a smaller download, in the words Get your files uses."""
return [
f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}"
"#only-some-consoles), or press **c** in the command.",
f"#only-some-consoles), or choose **{_installer().TUI_SYSTEMS_ROW}** in the "
"command.",
"",
]
@@ -4260,7 +4272,7 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
"Add `--check` to the command. It changes nothing.",
"",
*terminal_tabs(guide.run, check_args, steps=False),
CHECK_PASS_LINE,
check_pass_line(),
"",
_check_sentence(display, cov["mode"], cov["config"], no_list),
"",
@@ -11397,7 +11409,7 @@ def generate_which_pack() -> str:
"## Only some consoles?",
"",
"Pick them here, and get the files and the command. Or run the command "
"below and press **c** when it asks.",
f"below and choose **{_installer().TUI_SYSTEMS_ROW}**.",
"",
"<FilePicker />",
"",
@@ -11410,14 +11422,7 @@ def generate_which_pack() -> str:
"On Batocera, Recalbox and other game systems, run it on the device. "
"Your setup's page shows how.",
"",
"The command asks what to install. Press **Enter** for everything.",
"",
"```text title=\"What it asks\" frame=\"terminal\"",
" Enter) install everything",
" i) only what the installed cores read",
" c) choose systems, cores and regions",
" q) quit",
"```",
*first_screen_words(),
"",
*trust_box(""),
"**Check your folder later.** Add `--check`. It changes nothing.",
+19
View File
@@ -109,6 +109,25 @@ 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,
+3 -3
View File
@@ -3737,7 +3737,7 @@ class TheInstallerSaysWhatItFound(unittest.TestCase):
(dest / "lynxboot.img").write_bytes(b"corrupt")
proc = self._run("--dest", str(dest), "--system", "atari-lynx", "--check")
self.assertEqual(proc.returncode, 1)
self.assertIn("wrong hash: lynxboot.img", proc.stdout)
self.assertIn("wrong copy: lynxboot.img", proc.stdout)
self.assertIn("without --check", proc.stdout)
def test_a_read_only_folder_is_said_once(self):
@@ -4202,7 +4202,7 @@ class RunsAgainstTheRealLists(unittest.TestCase, _LoopbackRun):
"--check", "--no-input")
self.assertEqual(proc.returncode, 1, proc.stderr)
listing = proc.stdout.split("to download:\n", 1)[1].splitlines()
self.assertEqual(listing[0].strip(), "wrong hash: scph5501.bin")
self.assertEqual(listing[0].strip(), "wrong copy: scph5501.bin")
def test_a_dry_run_counts_the_data_trees_and_ends_on_its_last_line(self):
proc = _install_run(self.env, "--platform", "retroarch", "--dest",
@@ -4441,7 +4441,7 @@ class StandaloneEmulatorsWithoutAPlatformAreServed(unittest.TestCase, _LoopbackR
self.assertTrue(all(name.startswith("scph") for name in names), names)
proc = _install_run(self.env, "--no-input", "--check")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("All files up to date.", proc.stdout)
self.assertIn("Everything is in place.", proc.stdout)
def test_no_emulator_folder_keeps_the_hint(self):
self.duckstation.rmdir()
+8 -4
View File
@@ -391,7 +391,7 @@ class ReviewCases(unittest.TestCase):
out = buf.getvalue()
self.assertNotIn("Done.", out)
self.assertIn("12 files not downloaded", out)
self.assertIn("3 files already up to date", out)
self.assertIn("3 files already in place", out)
def test_quiet_refuses_modes_it_would_silence(self):
for mode in ("--check", "--dry-run", "--list-platforms", "--list-systems"):
@@ -547,7 +547,7 @@ class AFolderThisUserCannotEnter(unittest.TestCase):
os.chmod(locked, 0o755)
self.assertNotIn("Traceback", proc.stderr)
self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr)
self.assertIn("wrong hash: pcsx/extra.bin", proc.stdout)
self.assertIn("wrong copy: pcsx/extra.bin", proc.stdout)
self.assertIn("could not be read (Permission denied)", proc.stdout)
self.assertIn("missing: scph5501.bin", proc.stdout)
@@ -937,7 +937,7 @@ class TheEndOfARunPaintsItsStatusWords(unittest.TestCase):
("failure", "1 file failed"), ("failure", "failed:"))),
((2, 1, 0, 0, False), {},
(("success", "Done"), ("success", "2 files installed"),
("success", "1 file already up to date"))),
("success", "1 file already in place"))),
((1, 0, 0, 0, False), {"offline": 2},
(("warning", "Finished with files missing"), ("warning", "2 not fetched")))):
with self.subTest(counts=counts):
@@ -1159,10 +1159,14 @@ class NotFoundWithAClosedFolder(unittest.TestCase):
asked = []
install._DETECTION_REFUSED[:] = [(home / ".config" / "EmuDeck", "Permission denied")]
self.addCleanup(install._DETECTION_REFUSED.clear)
# The drives of the machine running the tests are not read, and the
# question is not printed into the test output.
with mock.patch.dict(os.environ, {"HOME": str(home)}), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "_choose_platform", return_value="retroarch"), \
mock.patch.object(install, "_ask",
lambda prompt: asked.append(prompt) or str(home / "b")):
lambda prompt: asked.append(prompt) or str(home / "b")), \
contextlib.redirect_stdout(io.StringIO()):
install._prompt_manual_platform("linux")
self.assertEqual(len(asked), 1)
self.assertNotIn("no default on this system", asked[0])
+11 -9
View File
@@ -132,7 +132,7 @@ class CheckNamesWhyAFileIsRefused(Scratch):
self.complete("scph5501.bin")
self.place("scph5501.bin", JP)
out = self.check()
self.assertIn("wrong hash: scph5501.bin", out)
self.assertIn("wrong copy: scph5501.bin", out)
self.assertIn("another region: the contents of scph5500.bin (japan)", out)
def test_another_revision_names_the_core_that_reads_it(self):
@@ -663,7 +663,7 @@ class OfflineMakesNoRequest(Scratch):
self.assertEqual((self.dest / "scph5501.bin").read_bytes(), US)
self.assertFalse((self.dest / "gba_bios.bin").exists())
# Missing, never failed (code_audit_15 P3-1): no FAILED line per file.
self.assertIn("3 not fetched (--offline)", proc.stdout + proc.stderr)
self.assertIn("3 still missing", proc.stdout + proc.stderr)
self.assertNotIn("FAILED", proc.stdout + proc.stderr)
self.assertIn("without --offline", proc.stdout + proc.stderr)
self.assertNotIn("trying again", proc.stderr)
@@ -801,9 +801,11 @@ class OfflineLeavesFilesMissing(Scratch):
sources="4 files copied from old, 14696 still missing.")
self.assertEqual(code, 1)
self.assertNotIn("Done", text)
self.assertIn("Finished with files missing. 4 files installed, 14696 not fetched "
"(--offline).", text)
self.assertIn("14696 still missing", text)
# One count, the sources line as the head, as the screen's result
# (tui_audit_15 P3-8).
self.assertIn("Finished with files missing. 4 files copied from old, 14696 still "
"missing.", text)
self.assertEqual(text.count("14696"), 1)
def test_nothing_installed_is_code_2(self):
code, text = self.outcome(offline=14700)
@@ -843,7 +845,7 @@ class OfflineLeavesFilesMissing(Scratch):
proc = self.run_installer("--offline", "--manifest", str(self.list), "--no-input")
# Its files are missing, as the BIOS files --offline leaves out are,
# with or without questions.
self.assertIn("bluemsx: 0 already up to date, 1 need the internet (--offline)",
self.assertIn("bluemsx: 0 already in place, 1 need the internet (--offline)",
proc.stdout)
self.assertNotIn("could not read", proc.stderr)
self.assertNotIn("no longer in the source", proc.stdout)
@@ -867,8 +869,8 @@ class OneOfflineVocabulary(Scratch):
"--from", str(self.old()), "--no-input")
out = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, 1, out)
self.assertIn("Finished with files missing. 1 file installed, 3 not fetched "
"(--offline).", out)
self.assertIn(f"Finished with files missing. 1 file copied from {self.root / 'old'}, "
"3 still missing.", out)
self.assertIn(f"They are not in {self.root / 'old'}", out)
for said in ("Finished with errors", "files failed", "retry",
"copy them from your old card"):
@@ -961,7 +963,7 @@ class AuditFifteenOffline(Scratch):
(1, 3, []))
self.assertNotIn("FAILED", proc.stderr)
self.assertNotIn("downloading", proc.stderr)
self.assertIn("1 file installed, 3 not fetched (--offline)", proc.stderr)
self.assertIn(f"1 file copied from {old}, 3 still missing", proc.stderr)
download = next(event for event in events if event["event"] == "download")
self.assertEqual(download["files"], 1)
# The screen reads that summary as missing files, not failures.
+2 -2
View File
@@ -1625,9 +1625,9 @@ class SomethingElseAtADestination(unittest.TestCase):
proc = repo.run("--check")
self.assertEqual(proc.returncode, 1, proc.stderr)
self.assertNotIn("Traceback", proc.stderr)
self.assertIn(" wrong hash: scph5500.bin\n a folder sits where this "
self.assertIn(" wrong copy: scph5500.bin\n a folder sits where this "
"file goes: move it away, then run again", proc.stdout)
self.assertIn(" wrong hash: scph5501.bin\n could not be read "
self.assertIn(" wrong copy: scph5501.bin\n could not be read "
"(Permission denied)", proc.stdout)
proc = repo.run("--yes")
self.assertEqual(proc.returncode, 1, proc.stderr)
+745 -12
View File
@@ -5102,7 +5102,8 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
term.wait("Enter) install everything")
term.send("\r")
term.wait("installed")
# Installed by the first run, in place for the second.
term.wait("Done.")
term.close()
self.assertNotIn(b"\x1b", term.raw)
@@ -6756,6 +6757,21 @@ class AuditSevenScreens(unittest.TestCase):
self.assertIn("1 wrong copy: backed up, replaced.", view)
def games_part(card: Path) -> Path:
"""An ArkOS EASYROMS (dArkOS game_systems.txt: themes, launchimages
with loading.jpg) or an AmberELEC GAMES (autostart.sh: gamedata,
homebrew with gamelist.xml), console folders beside them."""
marks = (("gamedata/retroarch", "homebrew") if card.name.startswith("GAMES")
else ("themes/es-theme-nes-box", "launchimages"))
for folder in ("bios", "nes", "gba", "psx", *marks):
(card / folder).mkdir(parents=True)
if card.name.startswith("GAMES"):
(card / "homebrew" / "gamelist.xml").write_text("<gameList/>\n")
else:
(card / "launchimages" / "loading.jpg").write_bytes(b"\xff\xd8")
return card
class _Decided(Exception):
"""Raised where line mode has chosen its platforms and folders."""
@@ -7550,7 +7566,9 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
install._install(args, real)
self.assertIn("Done. 2 files installed", real.getvalue())
# Every file from one folder: one count, as the screen's result
# (tui_audit_15 P3-8).
self.assertIn(f"Done. 2 files copied from {backup}.", real.getvalue())
self.assertIn("Installed: 1 BIOS file and 1 game data file.", real.getvalue())
# 2026-10-27, CI run 40541886959: the run said 8069 files, the menu
@@ -7578,8 +7596,10 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
self.assertIn("RetroArch: 4 files, 123 B.\n 1 BIOS file (3 B) and 3 game data files "
"(120 B).", text)
self.assertIn("g) Game data: kept (3 files, 120 B): leave it out", text)
self.assertIn(" 4 files (123 B)\n 1 BIOS file (3 B) and 3 game data files (120 B).",
text)
# Said once, by the menu: the run head repeated it right under it
# (tui_audit_15 P3-9).
after = text[text.index("q) quit"):text.index("Checking existing files")]
self.assertNotIn("4 files (123 B)", after)
self.assertIn("0 in place, 4 missing, 0 wrong copies", text)
self.assertIn("4 files (123 B)", text[text.index("Checking existing files"):])
@@ -8290,7 +8310,13 @@ class LineModeAndScreenDecideAlike(_LineModeFindings, _OutsideTextFindings,
text, _asked = self.line_run(_args(**args), ["c", "", ""])
self.assertNotRegex(text, r"Narrowed (\d+) to \1 files")
text, _asked = self.line_run(_args(**args), ["c", "1", ""])
self.assertIn("Narrowed 4 to 3 files", text)
# The menu counted the choice again before Enter: said once.
self.assertIn("Counted again for your choices", text)
self.assertNotIn("Narrowed", text)
args = _args(manifest=self.counted_list(), dest=self.here, declared_only=False,
dry_run=True, no_input=True, system=["doom"])
text, _asked = self.line_run(args, [])
self.assertRegex(text, r"Narrowed 4 to \d files")
def test_the_dry_run_names_systems_by_their_titles(self):
"""tui_audit_12 P3-13: --dry-run listed microsoft-msx where the list
@@ -10862,7 +10888,10 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase):
path = "/home/moi/abcdefghijklmnopqrstuvw/.config/retroarch/system"
width = len(path) + 1
lines = install._wrap(f"BIOS folder: {path}", width, whole="text")
self.assertEqual(lines, ["BIOS folder:", path])
# Broken after a separator, the rest at the one indent of every
# folder line (tui_audit_15 P3-12): never inside a name.
self.assertEqual(lines, ["BIOS folder: /home/moi/abcdefghijklmnopqrstuvw/.config/",
" retroarch/system"])
# A command keeps the option with its value, the path whole where
# the line holds them (tui_audit_14 P3-8).
command = install._wrap(f" python3 install.py --dest {path}", width + 10,
@@ -12248,12 +12277,10 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
self.assertIn("About 0 files, 0 B, for 1 of 3 systems, 3 need the internet", text)
def easyroms(self, name: str = "EASYROMS") -> Path:
"""An ArkOS games part as a computer mounts it: bios/ and console
folders, no file naming the system."""
card = self.root / "media" / name
for folder in ("bios", "nes", "gba", "psx"):
(card / folder).mkdir(parents=True)
return card
"""A handheld's games part as a computer mounts it, with the folders
its system writes there (install._GAMES_PART_MARKERS): ArkOS's
EASYROMS, AmberELEC's GAMES."""
return games_part(self.root / "media" / name)
def test_an_arkos_card_is_found_and_comes_first(self):
"""tui_audit_14 P2-3: an EASYROMS partition mounted, "Nothing was
@@ -12337,6 +12364,7 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
class Empty:
name = "RetroArch_BIOS_Pack.zip"
members: dict = {}
@staticmethod
def find(entry: dict) -> None:
@@ -12557,5 +12585,710 @@ class AuditTwentyOneAndFourteen(unittest.TestCase):
self.assertIn(f"BIOS folder: {card / 'bios'}", text)
class AuditTwentyTwoAndFifteen(unittest.TestCase):
"""code_audit_22 and tui_audit_15: a drive is a handheld's card only by
the files its system writes, one settle from the frame shown, one
vocabulary and one count per run, and the focus after an action."""
setUp = LineModeAndScreenDecideAlike.setUp
line_run = LineModeAndScreenDecideAlike.line_run
listing = LineModeAndScreenDecideAlike.listing
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
receive = staticmethod(AuditTwentyAndThirteen.receive)
game_manifest = AuditTwentyAndThirteen.game_manifest
game_events = AuditTwentyAndThirteen.game_events
game_plan = AuditTwentyAndThirteen.game_plan
game_listing = AuditTwentyOneAndFourteen.game_listing
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
def drive(self, name: str, folders: tuple) -> Path:
root = self.root / "media" / name
for folder in folders:
(root / folder).mkdir(parents=True)
return root
def first_screen(self, mounts: list) -> install.TuiState:
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
mock.patch.object(install, "_mount_points", return_value=frozenset()), \
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.object(install.Path, "home", return_value=self.root / "home"):
state, _effects = install.tui_start(_args(), "linux")
return state
def folder_screen(self, mounts: list) -> tuple[install.TuiState, str, list]:
"""RetroArch chosen on the first screen: (state, Which folder? at
80x40, its rows)."""
state = self.first_screen(mounts)
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
mock.patch.object(install, "_mount_points", return_value=frozenset()):
install.tui_update(state, Key("enter"))
self.assertEqual(state.screen, "folder")
return state, self.screen(state, 80, 40), install.tui_rows(state)
def test_a_data_drive_or_a_stick_is_no_card(self):
"""code_audit_22 P2-1: a data drive holding bios/, snes/ and Music/
was "a card in the reader", and three presses of Enter put the list
on it. A stick with bios/ alone is no card either: both are listed,
never under Enter, and a plain line says why."""
data = self.drive("DATA", ("bios", "snes", "Music"))
stick = self.drive("STICK", ("bios",))
mounts = [data, stick]
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
mock.patch.object(install, "_mount_points", return_value=frozenset()):
self.assertEqual(install.unnamed_cards(), [])
state = self.first_screen(mounts)
self.assertEqual(state.unnamed, [])
self.assertIn("Nothing was found here", self.screen(state))
state, text, rows = self.folder_screen(mounts)
self.assertNotIn(rows[0].value, (data / "bios", stick / "bios"))
self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a "
"handheld's card: listed, not chosen.", text)
self.assertIn(data / "bios", [row.value for row in rows])
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
declared_only=False), ["1", ""], mounts=mounts)
self.assertNotIn("in the reader", text)
self.assertIn("DATA, STICK have a bios folder, and nothing on them shows a "
"handheld's card: listed, not chosen.", text)
self.assertIn("Enter for the default above", asked[-2])
self.assertNotIn(f"BIOS folder: {data / 'bios'}", text)
def test_the_layouts_handhelds_write_are_cards(self):
"""ArkOS's EASYROMS, AmberELEC's GAMES and EmuELEC's EEROMS, each
with the folders its own image writes there."""
arkos = games_part(self.root / "media" / "EASYROMS")
amber = games_part(self.root / "media" / "GAMES")
emuelec = self.drive("EEROMS", ("bios", "nes", "bezels"))
(emuelec / "bezels" / "default.cfg").write_text("")
mounts = [arkos, amber, emuelec]
with mock.patch.object(install, "_mounted_cards", return_value=mounts), \
mock.patch.object(install, "_mount_points", return_value=frozenset()):
self.assertEqual(install.unnamed_cards(), mounts)
self.assertEqual([install._games_part_system(mount) for mount in mounts],
["ArkOS", "AmberELEC", "EmuELEC"])
# One of these the system calls fixed is a disk, not a card.
with mock.patch.object(install, "_drive_not_removable", return_value=True):
self.assertEqual(install.unnamed_cards(), [])
def test_a_card_beside_a_data_drive_comes_first(self):
"""code_audit_22 P2-1: the ArkOS card and the data drive mounted,
the data drive sorted first and took Enter. The card comes first in
both modes, the drive listed after it with its note."""
data = self.drive("DATA", ("bios", "snes", "Music"))
card = games_part(self.root / "media" / "EASYROMS")
mounts = [data, card]
state = self.first_screen(mounts)
self.assertEqual(state.unnamed, [card])
self.assertIn("A card is in the reader (EASYROMS): choose its system", self.screen(state))
state, text, rows = self.folder_screen(mounts)
self.assertEqual(rows[0].value, card / "bios")
self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text)
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
declared_only=False), ["1", ""], mounts=mounts)
self.assertIn("A card is in the reader (EASYROMS): choose its system.", text)
self.assertIn(f"The card in the reader keeps them in its bios folder: {card / 'bios'}.",
text)
self.assertIn(f"1) EASYROMS: bios (card or drive) ({card / 'bios'})", text)
self.assertIn("DATA has a bios folder, and nothing on it shows a handheld's card", text)
self.assertIn(f"BIOS folder: {card / 'bios'}", text)
def test_two_cards_are_spoken_of_in_the_plural(self):
"""code_audit_22 P3-2: "Cards are in the reader (DATA, EASYROMS):
choose its system", then "The card in the reader" of one of two."""
arkos = games_part(self.root / "media" / "EASYROMS")
amber = games_part(self.root / "media" / "GAMES")
mounts = [arkos, amber]
state = self.first_screen(mounts)
self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems",
self.screen(state))
state, text, rows = self.folder_screen(mounts)
self.assertIn("EASYROMS keeps them in its bios folder, first below.", text)
text, asked = self.line_run(_args(manifest=self.game_listing(), dry_run=True,
declared_only=False), ["1", ""], mounts=mounts)
self.assertIn("Cards are in the reader (EASYROMS, GAMES): choose their systems.", text)
self.assertIn(f"EASYROMS keeps them in its bios folder: {arkos / 'bios'}.", text)
self.assertNotIn("The card in the reader", text)
def test_a_fixed_partition_is_told_from_a_card(self):
"""code_audit_22 P2-1: every mount under /media and /mnt, and on
Windows every drive letter, was a card. A whole partition of a disk
sysfs calls fixed is not, a USB or MMC one is, a folder bound in
place says nothing, and Windows answers by GetDriveTypeW."""
sys_root = self.root / "sys"
devices = sys_root / "devices"
for disk, part, removable in (("pci0/nvme0n1", "nvme0n1p2", "0"),
("pci0/usb2/2-1/sdb", "sdb1", "0"),
("mmc0/mmcblk0", "mmcblk0p1", "0"),
("pci0/sdc", "sdc1", "1")):
(devices / disk / part).mkdir(parents=True)
(devices / disk / part / "partition").write_text("1\n")
(devices / disk / "removable").write_text(f"{removable}\n")
block = sys_root / "dev" / "block"
block.mkdir(parents=True)
for number, target in (("259:2", "pci0/nvme0n1/nvme0n1p2"),
("8:17", "pci0/usb2/2-1/sdb/sdb1"),
("179:1", "mmc0/mmcblk0/mmcblk0p1"),
("8:33", "pci0/sdc/sdc1")):
(block / number).symlink_to(devices / target)
mountinfo = "\n".join([
"40 1 259:2 / /media/me/DATA rw - ext4 /dev/nvme0n1p2 rw",
"41 1 8:17 / /media/me/EASYROMS rw - vfat /dev/sdb1 rw",
"42 1 179:1 / /media/me/GAMES rw - exfat /dev/mmcblk0p1 rw",
"43 1 8:33 / /media/me/STICK rw - vfat /dev/sdc1 rw",
"44 1 259:2 /home/me/cards/X /media/me/BOUND rw - ext4 /dev/nvme0n1p2 rw",
"45 1 0:74 / /mnt/d rw - 9p D:\\134 rw"])
told = {name: install._fixed_partition(Path(point), mountinfo, sys_root)
for name, point in (("DATA", "/media/me/DATA"), ("USB", "/media/me/EASYROMS"),
("MMC", "/media/me/GAMES"), ("STICK", "/media/me/STICK"),
("BOUND", "/media/me/BOUND"), ("WSL", "/mnt/d"),
("unmounted", "/media/me/NONE"))}
self.assertEqual(told, {"DATA": True, "USB": False, "MMC": False, "STICK": False,
"BOUND": False, "WSL": False, "unmounted": False})
for kind, fixed in ((2, False), (3, True), (4, True)):
kernel = types.SimpleNamespace(GetDriveTypeW=lambda root, kind=kind: kind)
fake = types.SimpleNamespace(windll=types.SimpleNamespace(kernel32=kernel))
with mock.patch.object(install.os, "name", "nt"), \
mock.patch.dict(sys.modules, {"ctypes": fake}):
self.assertEqual(install._drive_not_removable(PureWindowsPath("D:\\")), fixed)
# install.py whose tui_view takes SLOW seconds on the first frame of each
# screen, as a slower device takes to lay one out (code_audit_22 P3-1).
SLOW_VIEW = """
import os, sys, time
sys.argv[0] = {install!r}
sys.path.insert(0, {repo!r})
import install
slow = float(os.environ["SLOW"])
real, seen = install.tui_view, [None]
def view(state, cols, rows):
key = (state.screen, len(state.stack))
if key != seen[0]:
seen[0] = key
time.sleep(slow)
return real(state, cols, rows)
install.tui_view = view
sys.exit(install.main())
"""
@unittest.skipUnless(pyte and pty and hasattr(pty, "fork"),
"screen tests need pyte (pip install pyte==0.8.2) and a pty")
class TheSettleRunsFromTheFrameShown(unittest.TestCase):
"""code_audit_22 P3-1: the settle was stamped before the layout, so on
a slow one the second click of a double click on Options' Back reached
the plan's Quit."""
repo = EndToEnd.repo
bar_entry = TapsOnAPtySeeFirst.bar_entry
def test_a_double_click_on_back_stays_on_the_plan_after_a_slow_layout(self):
repo = self.repo()
wrapper = repo.root / "slowview.py"
wrapper.write_text(SLOW_VIEW.format(install=str(REPO_ROOT / "install.py"),
repo=str(REPO_ROOT)))
env = {**self.env, "SLOW": "0.5"}
term = Pty([], env, 80, 24, program=[sys.executable, str(wrapper)])
self.addCleanup(lambda: term.status is None and os.kill(term.pid, 9))
term.wait("Where do you play?")
term.send("\r")
term.wait("> Install")
term.settle()
term.send("o")
term.wait("RetroBIOS: Options")
term.settle()
time.sleep(1.0)
col, line = self.bar_entry(term, "Back")
report = sgr(col + 1, line + 1) + sgr(col + 1, line + 1, release=True)
os.write(term.fd, report.encode())
time.sleep(0.2)
os.write(term.fd, report.encode())
time.sleep(1.0)
screen = term.settle(0.6)
self.assertNotIn("Quit?", screen)
self.assertIn("RetroBIOS: RetroArch", screen.splitlines()[0])
self.assertEqual(repo.installed(), [])
term.send("q")
if "Quit?" in term.settle():
term.send("\r")
term.close()
class OnePowerShellRule(unittest.TestCase):
"""code_audit_22 P3-5: the words of a command asked RETROBIOS_BOOTSTRAP
or the detected system, its line ends RETROBIOS_BOOTSTRAP or os.name:
with RETROBIOS_OS=windows on Linux one command had PowerShell's quotes
and sh's line ends, and pasted into either shell it broke."""
def test_the_words_and_the_line_ends_read_one_rule(self):
cases = ((None, "windows", True), ("sh", "windows", False), ("ps1", "linux", True),
(None, "linux", False), ("sh", "linux", False))
for bootstrap, os_type, powershell in cases:
with self.subTest(bootstrap=bootstrap, os_type=os_type):
hint = install.Invocation()
hint.os_type, hint.options = os_type, [("--system", "it's")]
env = {key: value for key, value in os.environ.items()
if key != "RETROBIOS_BOOTSTRAP"}
if bootstrap:
env["RETROBIOS_BOOTSTRAP"] = bootstrap
with mock.patch.dict(os.environ, env, clear=True):
self.assertEqual(hint.powershell(), powershell)
text = hint.command("retroarch", Path("/home/me/My Games/bios"))
# Line mode's wrap reads the run's Invocation, the
# screen's the one that made its command.
with mock.patch.object(install, "_INVOCATION", hint):
wrapped = install._command_wrap(text, 40)
drawn = [str(line) for line in install._text_lines(
[install._command_line(text, hint)], 40)]
for lines in (wrapped, drawn):
lines = [line for line in lines if line.strip()]
ends = {line.rstrip()[-1] for line in lines[:-1]}
self.assertEqual(ends, {"`" if powershell else "\\"})
words = " ".join(lines)
self.assertIn("'it''s'" if powershell else shlex.quote("it's"), words)
class EachDataTreeReadOnce(unittest.TestCase):
"""code_audit_22 P3-3: the count line, the check, the events and the
install each read and hashed every data tree in place, twice per run
on an SD card through a reader."""
def setUp(self):
self.root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, self.root, True)
self.dest = self.root / "bios"
bios = b"bios bytes"
(self.dest / "gba_bios.bin").parent.mkdir(parents=True)
(self.dest / "gba_bios.bin").write_bytes(bios)
trees = []
for ref, files, held in (("dolphin-sys", {"Sys/a.bin": b"a", "Sys/b.bin": b"bb"}, 2),
("ppsspp", {"assets/c.bin": b"ccc", "assets/d.bin": b"d"}, 1)):
for rel, data in list(files.items())[:held]:
(self.dest / ref / rel).parent.mkdir(parents=True, exist_ok=True)
(self.dest / ref / rel).write_bytes(data)
trees.append({"ref": ref, "dest": ref, "type": "zip",
"url": f"https://example.invalid/{ref}.zip",
"systems": ["nintendo-gba"],
"files": {rel: {"sha1": hashlib.sha1(data).hexdigest(),
"size": len(data)} for rel, data in files.items()}})
self.list = self.root / "retroarch.json"
self.list.write_text(json.dumps({
"manifest_version": 2, "platform": "retroarch", "data_directories": trees,
"files": [{"dest": "gba_bios.bin", "sha1": hashlib.sha1(bios).hexdigest(),
"size": len(bios), "repo_path": "bios/gba_bios.bin", "cores": None,
"systems": ["nintendo-gba"]}]}))
def test_each_tree_is_hashed_once_per_run(self):
real = install.data_directory_state
for mode in ("check", "events", "dry_run", "yes"):
with self.subTest(mode=mode):
calls = []
def counted(entry, bios_path, calls=calls):
calls.append(entry["ref"])
return real(entry, bios_path)
given = {"events": True, "check": True} if mode == "events" else {mode: True}
stream = io.StringIO() if mode == "events" else None
with mock.patch.object(install, "data_directory_state", counted), \
mock.patch.object(install.EVENTS, "stream", stream), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()), \
contextlib.suppress(SystemExit):
try:
install._install(_args(platform="retroarch", dest=self.dest,
manifest=self.list, no_input=True,
declared_only=False, **given), io.StringIO())
finally:
install.LOCAL.reset()
self.assertEqual(sorted(calls), ["dolphin-sys", "ppsspp"])
class OneVocabularyOneCount(unittest.TestCase):
"""code_audit_22 P3-4, tui_audit_15 P3-6 to P3-10: line mode said "in
place" then "up to date", "wrong copies" then "wrong hash", counted
twice, left the data files out of its last line, and the screen focused
Install for a pack holding none of the files."""
setUp = LineModeAndScreenDecideAlike.setUp
line_run = LineModeAndScreenDecideAlike.line_run
listing = LineModeAndScreenDecideAlike.listing
entry = staticmethod(LineModeAndScreenDecideAlike.entry)
screen = staticmethod(AuditTwentyOneAndFourteen.screen)
def two_files(self) -> tuple[Path, Path, Path]:
"""A list of two files, the first in place, the second in an old
folder: (list, BIOS folder, old folder)."""
files = [self.entry("gba_bios.bin", b"gba", ["nintendo-gba"]),
self.entry("scph5501.bin", b"psx bytes", ["sony-playstation"])]
listing = self.listing("retroarch", files)
dest = self.root / "bios"
dest.mkdir()
(dest / "gba_bios.bin").write_bytes(b"gba")
old = self.root / "OLD"
old.mkdir()
(old / "scph5501.bin").write_bytes(b"psx bytes")
return listing, dest, old
def run_line(self, listing: Path, dest: Path, answers: list, **given) -> str:
from_ = given.pop("from_", None)
args = _args(manifest=listing, dest=dest, declared_only=False, **given)
if from_:
given["from_"] = from_
if "from_" in given:
setattr(args, "from", given.pop("from_"))
# Offline as the variable makes it: no request leaves the test. The
# end of the run is written to the stdout the run was given.
given_answers = iter(answers)
def answer(prompt: str) -> str:
try:
return next(given_answers)
except StopIteration:
raise _Decided(prompt) from None
install.LOCAL.reset()
self.addCleanup(install.LOCAL.reset)
invocation = install.Invocation()
invocation.os_type = "linux"
invocation.record(args)
end = io.StringIO()
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}), \
mock.patch.object(install, "_INVOCATION", invocation), \
mock.patch.object(install, "_mounted_cards", return_value=[]), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install.sys.stdin, "isatty", return_value=True), \
mock.patch.object(install, "_is_terminal", return_value=True), \
mock.patch.object(install, "_ask", side_effect=answer), \
contextlib.redirect_stdout(io.StringIO()) as out, \
contextlib.redirect_stderr(io.StringIO()) as err, \
contextlib.suppress(_Decided, SystemExit):
install._install(args, end)
return out.getvalue() + err.getvalue() + end.getvalue()
def test_the_line_mode_words_are_the_screens(self):
listing, dest, old = self.two_files()
(old / "scph5501.bin").rename(dest / "scph5501.bin")
for given in ({"no_input": True}, {"check": True}, {"dry_run": True}):
with self.subTest(**given):
text = self.run_line(listing, dest, [""], offline=True, **given)
self.assertIn("2 in place, 0 missing, 0 wrong copies", text)
self.assertIn("Everything is in place", text)
self.assertNotIn("up to date", text)
self.assertNotIn("0 files installed", text)
state = checked_state()
state.stack = ["result"]
state.summary = {"installed": 0, "up_to_date": 2, "pending": 0, "kept": [],
"failed": []}
state.code = 0
_title, lines = install.tui_outcome(state)
self.assertEqual(lines[0], "Everything is in place.")
def test_check_names_a_wrong_copy_as_such(self):
listing, dest, _old = self.two_files()
(dest / "scph5501.bin").write_bytes(b"other")
text = self.run_line(listing, dest, [], check=True, offline=True)
self.assertIn("1 in place, 0 missing, 1 wrong copy", text)
self.assertIn("wrong copy: scph5501.bin", text)
self.assertNotIn("wrong hash", text)
self.assertNotIn("A file with a", text)
def test_line_mode_with_from_counts_once(self):
"""tui_audit_15 P3-8 and P3-9: no found count before Enter, the
plan said again after it, Copying twice, the result's count twice,
and no command on a Finished with files missing."""
listing, dest, old = self.two_files()
(dest / "gba_bios.bin").unlink()
text = self.run_line(listing, dest, [""], from_=[old], offline=True)
menu, after = text.split("\nChecking existing files...", 1)
self.assertIn("RetroArch: 1 found in OLD, 1 needs the internet.", menu)
self.assertNotIn("2 files (", after)
self.assertEqual(after.count("Copying"), 1, after)
self.assertIn(f"Finished with files missing. 1 file copied from {old}, "
"1 still missing.", after)
self.assertNotIn("installed,", after)
self.assertIn("Same choice without questions:", after)
# Every file found: one count, as the screen's "22 files copied
# from packx."
(dest / "scph5501.bin").unlink()
(old / "gba_bios.bin").write_bytes(b"gba")
text = self.run_line(listing, dest, [""], from_=[old], offline=True)
menu, after = text.split("\nChecking existing files...", 1)
self.assertIn("RetroArch: 2 found in OLD, nothing to download.", menu)
self.assertIn(f"Done. 2 files copied from {old}.", after)
self.assertNotIn("files installed", after)
def test_the_last_offline_line_counts_the_data_files(self):
"""tui_audit_15 P3-6: "0 in place, 10994 missing", then "Nothing
installed: 8358 files need the internet"."""
listing, dest, _old = self.two_files()
listed = json.loads(listing.read_text())
listed["data_directories"] = [{
"ref": "bluemsx", "dest": "", "type": "zip",
"url": "https://example.invalid/blueMSX.zip", "systems": ["nintendo-gba"],
"files": {f"Machines/{n}.txt": {"sha1": hashlib.sha1(b"%d" % n).hexdigest(),
"size": 1} for n in range(2)}}]
listing.write_text(json.dumps(listed))
text = self.run_line(listing, dest, [""], offline=True)
self.assertIn("1 in place, 3 missing, 0 wrong copies", text)
self.assertIn("Nothing installed: 3 files need the internet", text)
def test_a_pack_holding_none_offers_no_install(self):
"""tui_audit_15 P3-7: a data tree whose folder is the BIOS folder
itself (blueMSX) made any pack hold a data file: Install focused,
and the result counted 8252 failed beside 10888 missing."""
pack = self.root / "RetroArch_BIOS_Pack.zip"
with zipfile.ZipFile(pack, "w") as archive:
archive.writestr("manifest.json", json.dumps({"files": []}))
archive.writestr("other-system.bin", b"x")
state = checked_state()
state.check = {**state.check, "data_missing": 2, "data_bytes": 2}
state.manifest = {**MANIFEST, "data_directories": [
{"ref": "bluemsx", "dest": "", "files": {
"Machines/a.txt": {"sha1": "0" * 40, "size": 1},
"Machines/b.txt": {"sha1": "0" * 40, "size": 1}}}]}
install.LOCAL.reset()
self.addCleanup(install.LOCAL.reset)
install.LOCAL.pack = install.PackSource([pack])
rows = install.tui_rows(state)
self.assertNotIn("install", [row.action for row in rows])
self.assertEqual(rows[state.focus.get("plan", 0)].action, "fromcard")
text = self.screen(state)
self.assertIn("RetroArch_BIOS_Pack.zip holds none of these 6 files", text)
self.assertIn("1 in place, 5 missing, 1 wrong copy", text)
class FocusAfterAnAction(unittest.TestCase):
"""tui_audit_15 P3-1, P3-3, P3-4 and P3-5: one tap on the focused
Install row armed nothing visible, the result's Put RetroBIOS on this
card was cut at 40x12, the Game data row lost the focus once changed,
and a choice already in place opened on Options."""
@staticmethod
def row_of(lines: list, action: str) -> int:
return next(index for index, line in enumerate(lines)
if getattr(line, "mark", None) and line.mark[0] == "row"
and line.mark[2][0] == action)
def test_one_tap_on_the_focused_install_row_arms_it_visibly(self):
for cols, rows in ((80, 24), (40, 12)):
with self.subTest(size=(cols, rows)):
state = checked_state()
state.mouse = True
lines = drawn(state, cols, rows)
row = self.row_of(lines, "install")
self.assertTrue(str(lines[row]).lstrip().startswith("> Install"))
self.assertEqual(tap(state, 4, row), [])
armed = drawn(state, cols, rows)
self.assertIn(install.TUI_ARMED_SHORT, str(armed[-1]))
self.assertIn(install.TUI_ARMED_SHORT, str(armed[row]))
self.assertEqual(armed[row].style, "focus")
painted = install.tui_paint(str(armed[-1]), "hint", True)
self.assertIn(f"{install.COLOUR_ROLES['accent']}m", painted)
# The second tap, on the row or on the bar, installs.
self.assertEqual(tap(state, 4, row), [("run", "install")])
def test_a_row_armed_then_the_bar_installs_and_a_key_disarms(self):
state = checked_state()
state.mouse = True
lines = drawn(state, 80, 24)
tap(state, 4, self.row_of(lines, "install"))
drawn(state, 80, 24)
self.assertEqual(tap(state, 2, len(lines) - 1), [("run", "install")])
state = checked_state()
state.mouse = True
lines = drawn(state, 80, 24)
tap(state, 4, self.row_of(lines, "install"))
install.tui_update(state, Key("down"))
install.tui_update(state, Key("up"))
self.assertNotIn(install.TUI_ARMED_SHORT, " ".join(map(str, drawn(state, 80, 24))))
def test_put_retrobios_on_this_card_fits_40x12(self):
"""The result offering the app, drawn and tapped as the loop does
(tui_view, tui_hits, tui_mouse), at 40x12 and 80x24: no line cut
inside a word, the armed bar whole, one Exit on the bar and the
help naming B as the bar does."""
root, bios = _app_card(self, "muOS")
for cols, rows in ((40, 12), (80, 24)):
with self.subTest(size=(cols, rows)):
state = _installed_state(bios)
state.mouse = True
with mock.patch.object(install, "_tui_app_ready", return_value=True):
lines = drawn(state, cols, rows)
text = [str(line).rstrip() for line in lines]
row = self.row_of(lines, "addapp")
offer = text[row].strip()
self.assertIn(offer, ("Put RetroBIOS on this card",
install.APP_OFFER))
self.assertEqual(text[-1].split(), ["A/Enter", "Exit"]
if cols >= install.TUI_LONG_BAR_WIDTH else ["A", "Exit"])
# One tap focuses the row: a second puts the app on the
# card, which the row and the bar say, B kept.
tap(state, 4, row)
armed = [str(line).rstrip() for line in drawn(state, cols, rows)]
self.assertTrue(armed[row].lstrip().startswith("> Put"))
self.assertIn("Exit", armed[-1])
self.assertIn("B/Esc" if cols >= install.TUI_LONG_BAR_WIDTH else "B",
armed[-1].split())
self.assertIn(f"{install.TUI_ARMED_SHORT} B", armed[-1])
self.assertTrue(armed[row].strip().endswith(install.TUI_ARMED_SHORT),
armed[row])
for line in (*armed, *text):
self.assertLessEqual(install.cell_width(line), cols)
self.assertFalse(line.endswith(("aga", "fro", "(update f")), line)
state.tapped = None
install.tui_update(state, Key("f1"))
help_text = " ".join(" ".join(str(line).split())
for line in drawn(state, cols, rows))
self.assertIn("Exit", help_text)
self.assertNotIn("Backspace Quit", help_text)
install.tui_update(state, Key("f1"))
def test_the_game_data_row_keeps_the_focus_when_changed(self):
audit = AuditTwentyAndThirteen()
state = audit.game_plan()
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "gamedata")
for left_out, expected in ((True, "Game data: left out"), (False, "Game data: kept")):
self.assertEqual(install.tui_update(state, Key("enter")), [("run", "check")])
audit.receive(state, audit.game_events(audit.game_manifest(), left_out=left_out))
rows = install.tui_rows(state)
self.assertEqual(rows[state.focus["plan"]].label, expected)
def test_a_choice_already_in_place_opens_on_choose_systems(self):
state = checked_state()
state.focus["plan"] = next(i for i, row in enumerate(install.tui_rows(state))
if row.action == "systems")
install.tui_update(state, Key("enter"))
self.assertEqual(state.screen, "systems")
state.ticked_before = set(state.ticked)
state.ticked = {"nintendo-famicom-disk"}
with mock.patch.object(install, "_choice_in_place", return_value="in place"):
install.tui_update(state, Key("esc"))
self.assertEqual(state.screen, "plan")
rows = install.tui_rows(state)
self.assertEqual(rows[state.focus["plan"]].label, "Choose systems")
class TextThatFits(unittest.TestCase):
"""tui_audit_15 P3-2, P3-12, P3-13 and P3-11: a pack's name cut in a
command, folder lines at two indents, the 40x12 plan dropping the
version note, and the --from plan without its kinds."""
def test_a_pack_path_keeps_its_name_whole(self):
pack = "/home/user/Downloads/RetroArch_BIOS_Pack.zip"
text = ("curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --platform "
f"retroarch --dest /media/moi/ONION/BIOS --from-pack {pack} --offline")
for width in (38, 50, 78):
with self.subTest(width=width), \
mock.patch.dict(os.environ, {"RETROBIOS_BOOTSTRAP": "sh"}):
lines = install._command_wrap(text, width)
joined = " ".join(lines)
self.assertIn("RetroArch_BIOS_Pack.zip", joined)
self.assertNotRegex(joined, r"\.\.\.[^/ ]*Pack\.zip")
for line in lines:
self.assertLessEqual(install.cell_width(line), width, line)
def test_a_folder_on_its_own_line_keeps_one_indent(self):
base = "/home/me/very/long/folder/names/for/an/android/storage/RetroArch"
items = [f"BIOS folder: {base}/system",
"1 file read from RetroArch's save directory goes to a folder per core:",
f" {base}/saves"]
for width in (38, 70, 78):
for whole in (False, True):
with self.subTest(width=width, whole=whole):
lines = [str(line) for line in install._text_lines(items, width, whole)]
folders = [line for line in lines if "/" in line
and not line.startswith("BIOS folder:")]
self.assertTrue(folders)
for line in folders:
self.assertEqual(len(line) - len(line.lstrip(" ")), 2, lines)
def test_the_40x12_plan_keeps_the_version_note(self):
state = checked_state()
state.dest = Path("/home/moi/retroarch_system/tmp/tui_audit15/rb2/bios")
install.tui_receive(state, ("event", state.run_id, {
"event": "version", "platform": "retroarch", "list_version": "1.22.2",
"installed_version": "1.21.0",
"message": "This list was built for RetroArch 1.22.2, this machine runs 1.21.0. "
"Some files may differ from what this version reads."}))
state.run = "check"
narrow = " ".join(" ".join(str(line).split())
for line in install.tui_view(state, 40, 12))
self.assertIn("List for RetroArch 1.22.2, this is 1.21.0.", narrow)
self.assertIn("BIOS folder:", narrow)
wide = " ".join(" ".join(str(line).split()) for line in install.tui_view(state, 80, 24))
self.assertIn("This list was built for RetroArch 1.22.2, this machine runs 1.21.0.", wide)
class TheFromPlanSaysItsKinds(unittest.TestCase):
"""tui_audit_15 P3-11: every file found in the folder, the plan named
the Game data row and said neither the kinds nor what game data is."""
def test_the_kinds_and_the_definition_stay(self):
audit = AuditTwentyAndThirteen()
state = audit.game_plan()
folder = "/media/moi/packx"
state.from_folders = [Path(folder)]
state.found = [found(event["dest"], folder) for event in state.files
if event["state"] != "present"]
with mock.patch.dict(os.environ, {install.OFFLINE_VARIABLE: "1"}):
text = " ".join(" ".join(str(line).split())
for line in install.tui_view(state, 80, 24))
self.assertIn("found in packx, nothing to download", text)
self.assertIn("2 BIOS files", text)
self.assertIn("3 game data files", text)
self.assertIn("Game data: files a game or a game engine reads", text)
self.assertNotIn("Not in packx", text)
class TheSystemsQuestionFits(unittest.TestCase):
"""code_audit_22 P3-6, tui_audit_15 P3-14: the size column was placed
for the longest size, so 60 of 66 systems took two lines and 26 still
passed 80 columns."""
def question(self, cols: int) -> list[str]:
manifest = json.loads((REPO_ROOT / "install" / "retroarch.json").read_text())
files = manifest["files"]
systems = install.systems_in_order(install.listed_systems(files), files, manifest,
None)
out = io.StringIO()
with mock.patch.object(install.shutil, "get_terminal_size",
return_value=os.terminal_size((cols, 24))), \
mock.patch.object(install, "_ask", return_value=""), \
contextlib.redirect_stdout(out):
install._choose_narrowing(files, [], manifest, None)
text = out.getvalue()
# Up to the question that follows it, the cores' or none.
ends = [at for at in (text.find("\nCores:"), text.find("\nThe platform's own"))
if at >= 0]
return (text[text.index("Systems:"):min(ends, default=len(text))].splitlines(),
systems, files)
def test_the_question_fits_80_and_40_columns(self):
for cols in (80, 40):
with self.subTest(cols=cols):
lines, systems, files = self.question(cols)
numbered = [line for line in lines if re.match(r" +\d+\) ", line)]
self.assertEqual(len(numbered), len(systems))
for line in lines:
self.assertLessEqual(install.cell_width(line), cols, line)
shared = sum(1 for system in systems if "shared" in install._system_sized(
[f for f in files if install._chosen(f, {system})]))
# One line a system, a second for its shared files: the
# 80-column question took 127 lines for 66 systems.
limit = len(systems) + shared + 10 if cols == 80 else 2 * len(systems) + 12
self.assertLessEqual(len(lines), limit)
def test_a_row_that_fits_keeps_one_line(self):
lines, _systems, _files = self.question(80)
row = next(index for index, line in enumerate(lines) if "Amstrad CPC" in line)
self.assertIn("4 files, 112.0 KB", lines[row])
self.assertTrue(re.match(r" +\d+\) ", lines[row + 1]))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -167,7 +167,7 @@ class GetYourFilesOrder(unittest.TestCase):
text = _page("which-pack.md")
order = [text.index(mark) for mark in (
"## Pick your setup", "| Setup | Runs on | BIOS folder | ZIP |", "<FilePicker",
"## Not in the list? One command finds it", "Press **Enter** for everything",
"## Not in the list? One command finds it", "The command opens a screen",
"<summary>Is it safe?</summary>",
)]
self.assertEqual(order, sorted(order))
+3 -1
View File
@@ -158,7 +158,9 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase):
with self.subTest(platform=key):
text = _guide_part(_page(f"platforms/{key}.md"))
self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text)
self.assertIn("or press **c** in the command.", text)
# The screen's own row: the line menu's c is not what the
# one-liner opens.
self.assertIn("or choose **Choose systems** in the command.", text)
def test_the_anchor_exists(self):
self.assertIn("## Only some consoles?", _page("which-pack.md"))
+7 -2
View File
@@ -193,8 +193,13 @@ class IphoneAndIpad(unittest.TestCase):
class WhatItAsks(unittest.TestCase):
def test_the_prompt_block_is_framed_as_a_terminal(self):
self.assertIn('```text title="What it asks" frame="terminal"', _page("which-pack.md"))
def test_the_page_says_the_screen_not_a_line_menu(self):
# The one-liner opens the full screen: a "What it asks" block of the
# line menu showed keys a beginner never sees (owner's note).
text = _page("which-pack.md")
self.assertNotIn('title="What it asks"', text)
for line in generate_site.first_screen_words():
self.assertIn(line, text)
if __name__ == "__main__":
+57 -4
View File
@@ -104,16 +104,17 @@ class GetPagesGiveTheSetupsOwnRoutes(unittest.TestCase):
class CheckNeedsTheFiles(unittest.TestCase):
def test_the_pass_line_is_what_the_installer_prints_only_when_complete(self):
installer = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
printed = re.search(r"`([^`]+)`", generate_site.CHECK_PASS_LINE)[1]
self.assertIn(f'print("\\n {printed}")', installer)
self.assertNotIn("wrong hash", generate_site.CHECK_PASS_LINE)
printed = re.search(r"`([^`]+)`", generate_site.check_pass_line())[1]
self.assertEqual(printed, generate_site._installer().EVERYTHING_IN_PLACE)
self.assertIn('print(f"\\n {EVERYTHING_IN_PLACE}")', installer)
self.assertNotIn("wrong hash", generate_site.check_pass_line())
def test_no_setup_page_passes_an_empty_folder(self):
for name in SETUPS:
text = _page(f"platforms/{name}.md")
with self.subTest(setup=name):
self.assertNotIn("All good when you see `0 wrong hash`", text)
self.assertIn(generate_site.CHECK_PASS_LINE, text)
self.assertIn(generate_site.check_pass_line(), text)
class ARouteWithoutANetwork(unittest.TestCase):
@@ -236,5 +237,57 @@ class HomeAndWhichPack(unittest.TestCase):
self.assertIn(word, meta[1].split())
class TheSiteSaysWhatTheInstallerShows(unittest.TestCase):
"""which-pack showed a line menu (Enter, i, c, q) the one-liner never
opens, typed by hand, its keys out of date. The site, the README and
the wiki now take the screen's words and the line menu from install.py."""
MENU_LINES = ("Enter) install everything", "i) only what the installed cores read",
"c) choose systems, cores and regions")
def test_which_pack_describes_the_screen_in_the_installers_words(self):
installer = generate_site._installer()
text = _page("which-pack.md")
for line in generate_site.first_screen_words():
self.assertIn(line, text)
self.assertIn(f"**{installer.TUI_INSTALL_ROW}**", text)
self.assertIn(f"**{installer.TUI_SYSTEMS_ROW}**", text)
for line in self.MENU_LINES:
self.assertNotIn(line, text)
self.assertNotIn("press **c**", text)
def test_the_plan_rows_are_the_words_the_pages_use(self):
installer = generate_site._installer()
state = installer.TuiState(os_type="linux", home=Path("/home/me"))
state.stack = ["plan"]
state.check = {"files": 2, "present": 0, "missing": 2, "wrong_hash": 0, "need": 2,
"bytes_needed": 10}
state.systems = [{"system": name, "missing": 1, "wrong_hash": 0, "present": 0,
"files": 1, "bytes_needed": 5} for name in ("a", "b")]
state.manifest = {"files": [{"dest": "a.bin", "systems": ["a"]},
{"dest": "b.bin", "systems": ["b"]}]}
state.ticked = {"a", "b"}
state.checked_flags = installer.tui_flags(state)
labels = [row.label for row in installer.tui_rows(state)]
self.assertEqual(labels[0], installer.TUI_INSTALL_ROW)
self.assertIn(installer.TUI_SYSTEMS_ROW, labels)
def test_the_readme_and_the_wiki_take_the_same_words(self):
installer = generate_site._installer()
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
self.assertIn(f"fewer under **{installer.TUI_SYSTEMS_ROW}**", readme)
self.assertNotIn("offers to install everything or to choose systems", readme)
source = (REPO_ROOT / "scripts" / "generate_readme.py").read_text(encoding="utf-8")
self.assertIn("_installer_words().TUI_SYSTEMS_ROW", source)
import generate_installer_doc # noqa: PLC0415
page = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
self.assertIn(generate_installer_doc.line_menu(), page)
menu = page.split("<!-- generated: line-menu -->", 1)[1]
outside = page.replace(menu.split("<!-- /generated: line-menu -->", 1)[0], "")
for line in self.MENU_LINES:
self.assertNotIn(line, outside)
if __name__ == "__main__":
unittest.main()
+45 -26
View File
@@ -273,7 +273,9 @@ and the row a screen opens on (**Install** on Confirm) is never chosen by one
tap. The entries of the bottom line press their key, **Exit** on the result
too. One that writes files with no screen after it to look first, **Install**
on the plan and **Put it on the card**, takes two taps: the first adds
`click or tap again` to it in the accent colour, the second presses it.
`click or tap again` to it in the accent colour (`tap again` where the line
has no room), the second presses it. A first tap on the row it acts on arms
it the same way, on the row and on the bottom line.
**Install** on Confirm, which is that look, Quit, Continue and Close take one,
so installing or quitting never takes more than two. A row of
Preferred regions stays where it is when it is ticked, its number giving the
@@ -281,8 +283,8 @@ order, which the list shows sorted the next time it opens. The line that says
which rows are in view shows the next page, and the wheel scrolls a list or a
text without moving the cursor unless it would leave the screen. Nothing
happens until the button or the finger comes up, and only where it went down,
on the screen drawn when it went down. A new screen takes no press for its
first 400 ms: a double click is two presses at most 400 ms (GNOME) to 500 ms
on the screen drawn when it went down. A new screen takes no press for the
first 400 ms after it is on the screen, however long it took to lay out: a double click is two presses at most 400 ms (GNOME) to 500 ms
(Windows, macOS) apart and the new screen is drawn on the first release, so
the second tap of a double tap that changes the screen does nothing on the
new one, and a tap made later acts. The right and middle buttons
@@ -322,8 +324,8 @@ file list is downloaded once and handed to each run.
3. Prints the file count and total size, then a safety notice counting the
entries the collection cannot serve, how many of those the platform marks
required, and why. At a terminal, the
[full-screen interface](#the-full-screen-interface) then offers to install
everything or to choose systems, cores and regions, and `--plain` asks the
[full-screen interface](#the-full-screen-interface) then offers **Install**
for everything and **Choose systems** for fewer, and `--plain` asks the
same as questions (see below).
4. Hashes what is already in the destination and reports how many entries are
present, verified, or present with the wrong contents.
@@ -391,7 +393,7 @@ A RetroArch dry run into an empty folder printed this on 2026-10-27:
```
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.6 GB)
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).
Not installable: 37 files (1 required, not found: 37).
@@ -528,7 +530,7 @@ The same command with `--check` reads the folder and writes nothing:
Checking existing files...
14481 in place, 0 missing, 0 wrong copies
All files up to date.
Everything is in place.
```
The files the collection does not hold are listed after it again, as long as
@@ -544,7 +546,7 @@ core opens it:
```
3 files to download:
wrong hash: scph5501.bin
wrong copy: scph5501.bin
another region: the contents of scph5500.bin (japan)
opened at /home/me/.config/retroarch/system/scph5501.bin by beetle_psx, pcsx_rearmed
missing: gba_bios.bin
@@ -700,25 +702,38 @@ the plan screen of the [full-screen interface](#the-full-screen-interface)
offers **Install**, **Choose systems** and **Options**. With `--plain`, or where
the full screen cannot open (a terminal smaller than 40x12, `TERM=dumb`), the
same choice comes as questions after detection. A RetroArch run with `--plain`
printed this on 2026-10-28, `g` then pressed:
prints this for the RetroArch list, `g` then pressed (written from install.py
by `scripts/generate_installer_doc.py`):
<!-- generated: line-menu -->
```
RetroArch: 10994 files, 6.6 GB.
RetroArch: 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.
BIOS folder: /home/me/.config/retroarch/system
106 files read from RetroArch's save directory go to a folder per core under /home/me/.config/retroarch/saves
Enter) install everything
c) choose systems, cores and regions
f) copy from a folder or an old card
g) Game data: kept (5089 files, 2.5 GB): leave it out
q) quit
>
> g
Counted again for your choices. Nothing is written before Enter.
RetroArch: 5905 files, 4.1 GB.
RetroArch: 5905 files, 4.2 GB.
3269 BIOS files (4.1 GB) and 2636 data files (38.5 MB). 5089 game data files left out.
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.
BIOS folder: /home/me/.config/retroarch/system
Same choice without questions: python3 install.py --platform retroarch --dest /home/me/.config/retroarch/system --no-game-data
Enter) install everything
c) choose systems, cores and regions
f) copy from a folder or an old card
g) Game data: left out (5089 files, 2.5 GB): keep it
q) quit
>
```
<!-- /generated: line-menu -->
A list that marks its game data counts three kinds of file apart, on the plan,
in this menu and on the result (`Installed: 3269 BIOS files, 5089 game data
@@ -733,9 +748,8 @@ count follows the systems chosen, as `g` counts them, and a choice that holds
no game data has no row. Given `--no-game-data`, neither the menu nor the plan
offers the game data back.
`i) only what the installed cores read: 87 files, 43.8 MB` appears for
RetroArch, Lakka and RetroPie when cores are found installed, here a RetroArch
with Beetle PSX HW, Snes9x, mGBA, Genesis Plus GX and FCEUmm.
An `i` entry, with the count and size of only what the installed cores read,
appears for RetroArch, Lakka and RetroPie when cores are found installed.
It keeps what those cores read and drops the systems none of them emulates,
the rule `--installed-cores` applies.
@@ -908,8 +922,8 @@ what the options name, everything by default.
<!-- /generated: options -->
`--check` reads the same file list, hashes the destination and exits, with
code 1 when a file is missing or does not match and 0 when every one does. Files
with a wrong hash are listed first and in full, then the missing ones whose
code 1 when a file is missing or does not match and 0 when every one does. Wrong
copies are listed first and in full, then the missing ones whose
reason it found, then the first ten other missing ones (`--verbose` lists them
all). See [Why a file is refused](#why-a-file-is-refused). `--dry-run` counts the data trees in its total. Without
it, a file already present with the expected hash is left untouched, and one
@@ -1181,13 +1195,18 @@ muOS, ArkOS and the others) opens the setup list instead, filtered to the
setups that read it, RetroArch first: Enter on it asks RetroArch's folder. At
that question a drive is numbered by the folder its system reads (`ONION:
BIOS`), a drive nothing names by its `bios` folder (`EASYROMS: bios`), never
by its root, which no setup reads. A drive no file names that holds a `bios`
folder and console folders (`nes`, `gba`, `psx`) is a handheld's card whose
own files sit on a part a computer does not open, ArkOS's `EASYROMS` and
AmberELEC's `GAMES`: with nothing found here the first screen and line mode
say `A card is in the reader (EASYROMS): choose its system`, and its `bios`
folder comes first at the folder question, under Enter, before this
computer's usual folder. Another system's card or SHARE drive, and a
by its root, which no setup reads. A handheld's card whose own files sit on a
part a computer does not open is told by the folders its system writes beside
`bios` on the games part: `themes` and `launchimages` on ArkOS's `EASYROMS`,
`gamedata` and `homebrew` on AmberELEC's `GAMES`, `bezels/default.cfg` on
EmuELEC's `EEROMS`. A disk Windows or Linux calls fixed is never one. With
nothing found here the first screen and line mode say `A card is in the reader
(EASYROMS): choose its system` (two cards: `choose their systems`), and its
`bios` folder comes first at the folder question, under Enter, before this
computer's usual folder. A drive with a `bios` folder and none of those
folders, a data drive or a stick, is numbered with a note (`DATA has a bios
folder, and nothing on it shows a handheld's card: listed, not chosen.`) and
never taken under Enter. Another system's card or SHARE drive, and a
drive two systems fit, are left out, and a line names them: each is reached
through its own setup. A folder typed is read from the current
one when it is not a full path, and named in full after. `q` quits there too
@@ -1608,7 +1627,7 @@ fields, the installer refuses to narrow rather than install everything.
| Code | Meaning |
|------|---------|
| `0` | everything asked for is installed or already up to date |
| `0` | everything asked for is installed or already in place |
| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 (nothing was run), an unusable base URL, an unknown platform or target, `--installed-cores` with no core found, a manifest that fails validation, a fetch that never succeeded, no platform to install for in a non-interactive run, some files failed or were left out by `--offline` while others installed, `--check` found files to fetch, a `--from-pack` pack that cannot be read, lacks a part or is for another platform, a `--from` folder that does not exist or cannot be read, a `--dest` that is not the card of the system named (another system's card, a folder inside one, a drive with none of its files where its card always has them, or a folder that does not exist), `--restore` could not put a file back, or `--add-app` could not put every file of the app on the card |
| `2` | nothing was installed: every file the run tried failed (a BIOS folder that cannot be written, for one), or `--offline` left every file out, the drive has too little free space for the download (checked before any folder is created), or the question about a folder that does not exist, named with `--dest` or typed at the folder question, was answered no. Also an option the installer refused before any download: an unknown option, `--jobs` outside 1 to 32, `--target` with `--every-board` or `--from-pack`, `--target` or `--list-targets` with `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set), which read a core list from the network, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also `--add-app` for a folder on no card that takes the app, said in an `Error:` line and an `app` event before anything is written. With `--restore`, a folder that is not a dated folder inside `retrobios-backup`. Also a quit chosen before the install, as `gh` ends a cancelled prompt: `q` at a question, `q` or **Quit** on the full screen, which then keeps `RetroBIOS: quit. Nothing was installed.`, and the full screen closed with no button pressed (`RETROBIOS_IDLE_EXIT`). No code but `0` closes a Windows Terminal tab started on the installer itself, so such a tab stays open on a quit |
| `130` | stopped with Ctrl+C, or with Ctrl+C or Ctrl+D at a question, the terminal keeping `RetroBIOS: stopped. Nothing was installed.` before an install. Run the same command again to carry on |