From df5de91b3790b2e8dd3ea4f2f3b9dc05047db5c5 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:41:00 +0100 Subject: [PATCH 01/30] fix: clean download.sh staging on a failed part --- scripts/download.sh | 14 ++++++++++++-- tests/test_download.py | 13 +++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/scripts/download.sh b/scripts/download.sh index ce2ba8190..92f40bd3f 100755 --- a/scripts/download.sh +++ b/scripts/download.sh @@ -29,6 +29,15 @@ case "$API_BASE" in esac API="${API_BASE%/}/repos/${REPO}/releases/latest" +# Global, not local to download_pack: the EXIT trap runs once that function's +# scope is gone, and under set -u a local would be unbound there. +STAGING="" +cleanup_staging() { + if [ -n "$STAGING" ]; then + rm -rf "$STAGING" + fi +} + usage() { echo "Usage: $0 " echo " $0 --list" @@ -148,7 +157,8 @@ EOF local staging="${dest%/}/.retrobios-download" rm -rf "$staging" mkdir -p "$staging" - trap 'rm -rf "$staging"' EXIT + STAGING="$staging" + trap cleanup_staging EXIT local index=0 local url name @@ -184,7 +194,7 @@ EOF unzip -o -q "$file" -d "$dest" done - rm -rf "$staging" + cleanup_staging trap - EXIT echo "Done! BIOS files extracted to ${dest}/" } diff --git a/tests/test_download.py b/tests/test_download.py index a3b286272..fc0c51d6c 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -371,6 +371,10 @@ class ReleaseServer: ] self.index.write_text(json.dumps(release)) + def drop_last_asset(self) -> None: + """A part the release lists but no longer serves: its URL gives 404.""" + (self.root / "assets" / self.names[-1]).unlink() + def corrupt_last_volume(self) -> None: target = self.root / "assets" / self.names[-1] target.write_bytes(target.read_bytes()[:-16] + b"0" * 16) @@ -561,6 +565,15 @@ class TestDownloadShell(DownloaderCase): proc = self.run_cli("nintendo", str(self.dest), expect_success=False) self.assertIn(self.platform_label, proc.stdout + proc.stderr) + def test_a_failed_part_leaves_no_staging_behind(self): + """The EXIT trap read a variable local to the function that set it: + under set -u it failed on `staging: unbound variable` and left the + parts already downloaded in the BIOS folder.""" + self.server.drop_last_asset() + proc = self.run_cli(self.platform, str(self.dest), expect_success=False) + self.assertNotIn("unbound variable", proc.stderr) + self.assertEqual(list(self.dest.iterdir()), []) + class TestWholePackPython(WholePackCase, TestDownloadPython): def test_info_reports_every_volume(self): From 34293f7bbb5e88644bb162d43d6c7d80ebcd7ea2 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:41:48 +0100 Subject: [PATCH 02/30] fix: give each download.sh run its own staging --- scripts/download.sh | 9 +++++---- tests/test_download.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/scripts/download.sh b/scripts/download.sh index 92f40bd3f..9800583e8 100755 --- a/scripts/download.sh +++ b/scripts/download.sh @@ -152,11 +152,12 @@ EOF fi # Staged inside the destination: a pack is gigabytes, and /tmp is a RAM - # disk on the appliances these packs target. + # disk on the appliances these packs target. The folder is this run's own: + # a fixed name let a second download into the same folder empty it while + # the first was still writing its parts. mkdir -p "$dest" - local staging="${dest%/}/.retrobios-download" - rm -rf "$staging" - mkdir -p "$staging" + local staging + staging=$(mktemp -d "${dest%/}/.retrobios-download-XXXXXX") STAGING="$staging" trap cleanup_staging EXIT diff --git a/tests/test_download.py b/tests/test_download.py index fc0c51d6c..0b57a1daa 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -23,6 +23,7 @@ import subprocess import sys import tempfile import threading +import time import unittest import zipfile from pathlib import Path @@ -574,6 +575,34 @@ class TestDownloadShell(DownloaderCase): self.assertNotIn("unbound variable", proc.stderr) self.assertEqual(list(self.dest.iterdir()), []) + def test_two_runs_into_one_folder_both_finish(self): + """Both runs staged in `/.retrobios-download` and the second + emptied it while the first was still writing its parts.""" + shim = Path(_tempdir(self)) + real = shutil.which("curl") + (shim / "curl").write_text( + "#!/bin/sh\n" + 'case "$*" in *-o*) sleep 1 ;; esac\n' + f'exec "{real}" "$@"\n' + ) + (shim / "curl").chmod(0o755) + env = self.env() + env["PATH"] = f"{shim}{os.pathsep}{env['PATH']}" + command = ["bash", str(SHELL), self.platform, str(self.dest)] + first = subprocess.Popen( + command, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.5) + second = subprocess.run( + command, env=env, capture_output=True, text=True, timeout=120 + ) + out, err = first.communicate(timeout=120) + self.assertEqual(first.returncode, 0, out + err) + self.assertEqual(second.returncode, 0, second.stdout + second.stderr) + self.assert_extracted() + self.assert_no_leftovers() + class TestWholePackPython(WholePackCase, TestDownloadPython): def test_info_reports_every_volume(self): From 2a243985c0cdc6a433f7151ca1ca4c66a7b3e342 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:43:03 +0100 Subject: [PATCH 03/30] fix: rename extracted BIOS files into place --- scripts/download.sh | 20 +++++++++++++++++++- tests/test_download.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/download.sh b/scripts/download.sh index 9800583e8..bc2c79afd 100755 --- a/scripts/download.sh +++ b/scripts/download.sh @@ -191,9 +191,27 @@ EOF done echo "Extracting to ${dest}/..." + # unzip -o unlinks a file and writes its replacement under the final + # name, so a run stopped mid-file left a partial BIOS. Members land in + # the staging folder, on the same filesystem, and each is renamed into + # place: a file in the BIOS folder is the old one or the new one. + local extracted="${staging}/extracted" for file in "${staging}"/*.zip; do - unzip -o -q "$file" -d "$dest" + unzip -o -q "$file" -d "$extracted" done + local target + target=$(cd "$dest" && pwd) + ( + cd "$extracted" + find . -type f -exec sh -c ' + target="$1" + shift + for member; do + mkdir -p "$target/$(dirname "$member")" + mv -f "$member" "$target/$member" + done + ' sh "$target" {} + + ) cleanup_staging trap - EXIT diff --git a/tests/test_download.py b/tests/test_download.py index 0b57a1daa..154f780ca 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -575,6 +575,35 @@ class TestDownloadShell(DownloaderCase): self.assertNotIn("unbound variable", proc.stderr) self.assertEqual(list(self.dest.iterdir()), []) + def test_an_interrupted_extraction_keeps_the_installed_file(self): + """unzip -o unlinks a file and writes the new one under its final + name: a run killed mid-file left a partial BIOS where a valid one + was. Extraction now writes beside the folder and renames.""" + shim = Path(_tempdir(self)) + (shim / "unzip").write_text( + "#!/bin/sh\n" + 'for target; do :; done\n' + 'mkdir -p "$target/bios"\n' + 'printf partial > "$target/bios/scph5501.bin"\n' + "exit 137\n" + ) + (shim / "unzip").chmod(0o755) + installed = self.dest / "bios" / "scph5501.bin" + installed.parent.mkdir(parents=True) + installed.write_bytes(b"valid") + env = self.env() + env["PATH"] = f"{shim}{os.pathsep}{env['PATH']}" + proc = subprocess.run( + ["bash", str(SHELL), self.platform, str(self.dest)], + env=env, capture_output=True, text=True, timeout=120, + ) + self.assertNotEqual(proc.returncode, 0) + self.assertEqual(installed.read_bytes(), b"valid") + self.assertEqual( + sorted(p.name for p in self.dest.iterdir()), ["bios"], + "staging left behind", + ) + def test_two_runs_into_one_folder_both_finish(self): """Both runs staged in `/.retrobios-download` and the second emptied it while the first was still writing its parts.""" From 0c384e0446d9be783e35ddbde39a6a7b9edbc9b3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:45:53 +0100 Subject: [PATCH 04/30] fix: say when retroarch.cfg cannot be read --- install.ps1 | 2 +- install.py | 7 +++++-- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_experience.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 12 deletions(-) diff --git a/install.ps1 b/install.ps1 index d31fa3a10..48f2ca54b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547" + $defaultInstallSha256 = "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 0bd9c4d18..21dcb64d4 100644 --- a/install.py +++ b/install.py @@ -1246,11 +1246,14 @@ def _retroarch_cfg_dir( A value left empty or at `default` is the directory RetroArch falls back on, `default` under app_dir. None when the config is unreadable or does - not set the key. + not set the key. A config this user cannot read is said once, like a + closed folder: a retroarch.cfg a `sudo retroarch` left to root hid the + setup without a word. """ try: lines = cfg_path.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: + except OSError as exc: + _note_refused(cfg_path, exc) return None for line in lines: line = line.strip() diff --git a/install.sh b/install.sh index d98b63192..7e3eb0e3f 100755 --- a/install.sh +++ b/install.sh @@ -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="6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547" +DEFAULT_INSTALL_SHA256="627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index bbf635adc..45856d1f4 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1079846, - "sha256": "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547", + "size": 1080043, + "sha256": "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1133023, - "sha256": "ff64d48ae2d5bd2dcf341917c55da88f8ca7174a2ccbc4e7786b69444da11ff8" + "size": 1133220, + "sha256": "818f6453e8c441729b395a765f47735d5c3710a03db288ab5609b4485710d2cd" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1079846, - "sha256": "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547", + "size": 1080043, + "sha256": "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1079846, - "sha256": "6e625f7a8c9c3ba095c8240dc3e8482dddd514e438302a576a2f80807e79a547", + "size": 1080043, + "sha256": "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad", "executable": false }, { diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index 4a7306aa0..ba86703cb 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -823,6 +823,38 @@ class APartlyClosedHome(unittest.TestCase): self.assertEqual([entry["bios_dir"] for entry in detect["platforms"]], [str(system)]) +class AnUnreadableRetroArchConfigIsSaid(unittest.TestCase): + """A retroarch.cfg a `sudo retroarch` left at 0600 for root was read as + no config at all: RetroArch was not found and nothing said why.""" + + @unittest.skipIf(os.name == "nt" or os.geteuid() == 0, + "a file without read permission is POSIX") + def test_the_closed_config_is_named(self): + with FakeRepository() as repo: + home = repo.root / "home" + cfg = home / ".config" / "retroarch" / "retroarch.cfg" + cfg.parent.mkdir(parents=True) + cfg.write_text('system_directory = "/media/card/bios"\n') + env = {**repo.env(), "HOME": str(home), "RETROBIOS_OS": "linux"} + os.chmod(cfg, 0o000) + try: + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--check", + "--no-input", "--events"], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + finally: + os.chmod(cfg, 0o644) + self.assertNotIn("Traceback", proc.stderr) + refusal = (f"Could not look for setups: this user cannot open {cfg} " + "(Permission denied)") + self.assertEqual(" ".join(proc.stderr.split()).count(refusal), 1, proc.stderr) + events = [json.loads(line) for line in proc.stdout.splitlines()] + unsearched = [event for event in events if event["event"] == "unsearched"] + self.assertEqual([(event["folder"], event["reason"]) for event in unsearched], + [(str(cfg), "Permission denied")]) + + class OutputToAFileAsksNothing(unittest.TestCase): """`install.py > log.txt` at a terminal: the menu went into the file and the run waited on an answer to a prompt nobody could see.""" From f78da216561cf07a0b0a88ba088742fdceafe76c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:46:40 +0100 Subject: [PATCH 05/30] fix: say why the games index did not download --- install.ps1 | 2 +- install.py | 7 ++++--- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_games.py | 13 +++++++++++++ 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 48f2ca54b..37de60804 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad" + $defaultInstallSha256 = "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 21dcb64d4..a46e1e478 100644 --- a/install.py +++ b/install.py @@ -9233,6 +9233,7 @@ class GamesData: raw = _read_capped(beside / "index.json", MAX_GAMES_INDEX_BYTES) cached = self.cached("index.json") reason = "offline" + failure = "" if raw is None and network: try: raw = _fetch_json(GAMES_URL.format(path="index.json"), MAX_GAMES_INDEX_BYTES, @@ -9242,8 +9243,7 @@ class GamesData: except (urllib.error.URLError, OSError, ValueError, http.client.HTTPException) as exc: reason = "network" - if os.environ.get("RETROBIOS_DEBUG"): - print(f" Games index: {exc}", file=sys.stderr) + failure = str(exc) else: if cached is not None: _cache_write(cached, raw) @@ -9252,7 +9252,8 @@ class GamesData: if raw is None: raise GamesUnavailable(reason, ( f"the games index is not on this machine and {offline_words()}" - if reason == "offline" else "the games index could not be downloaded")) + if reason == "offline" + else f"the games index could not be downloaded ({failure})")) try: data = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: diff --git a/install.sh b/install.sh index 7e3eb0e3f..8555cd2c6 100755 --- a/install.sh +++ b/install.sh @@ -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="627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad" +DEFAULT_INSTALL_SHA256="7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 45856d1f4..4aaafb661 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1080043, - "sha256": "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad", + "size": 1080006, + "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1133220, - "sha256": "818f6453e8c441729b395a765f47735d5c3710a03db288ab5609b4485710d2cd" + "size": 1133183, + "sha256": "cc752f0ea8becaba5f3d96c6c77ca9c39b8b870fd9d2019985e1fd78ed912592" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1080043, - "sha256": "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad", + "size": 1080006, + "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1080043, - "sha256": "627e9c3bb72daacae022a93f5ca2ba010cd8fb025ef92d69f020734575ee9bad", + "size": 1080006, + "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", "executable": false }, { diff --git a/tests/test_install_games.py b/tests/test_install_games.py index f5c98ce54..117970364 100644 --- a/tests/test_install_games.py +++ b/tests/test_install_games.py @@ -22,6 +22,7 @@ import sys import tempfile import threading import unittest +import urllib.error import zipfile import zlib from pathlib import Path @@ -777,6 +778,18 @@ class Unavailable(GamesTestCase): install.plan_games("retroarch", self.card.bios, manifest(), os_type="linux") self.assertEqual(caught.exception.reason, "offline") + def test_a_failed_download_says_why(self): + """The cause was printed only under RETROBIOS_DEBUG: a proxy's 403 + or a TLS refusal read as a bare "could not be downloaded".""" + shutil.rmtree(self.card.lists / "games") + refusal = urllib.error.HTTPError( + "https://example.invalid/index.json", 403, "Forbidden", {}, None) + with mock.patch.object(install, "_fetch_json", side_effect=refusal), \ + self.assertRaises(install.GamesUnavailable) as caught: + install.GamesData().load_index(network=True, attempts=1) + self.assertEqual(caught.exception.reason, "network") + self.assertIn("403", caught.exception.words) + def test_a_document_that_is_not_the_one_listed_is_refused(self): (self.card.lists / "games" / "setups" / "retroarch.json").write_text("{}") with self.assertRaises(install.GamesUnavailable) as caught: From 4d46587693a4a137103ea6735394a215626129a0 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:48:50 +0100 Subject: [PATCH 06/30] fix: name a Python built without ssl or zlib --- install.ps1 | 2 +- install.py | 31 ++++++++++++++++++++++++------- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 33 +++++++++++++++++++++++++++++++++ wiki/installer.md | 2 +- 6 files changed, 68 insertions(+), 18 deletions(-) diff --git a/install.ps1 b/install.ps1 index 37de60804..cebb3b2b3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546" + $defaultInstallSha256 = "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a46e1e478..4fa6d0d7a 100644 --- a/install.py +++ b/install.py @@ -43,18 +43,26 @@ import textwrap import threading import time import traceback -import unicodedata import urllib.error import urllib.parse import urllib.request import weakref import xml.etree.ElementTree as ET import zipfile -import zlib from collections.abc import Callable, Collection, Iterable, Iterator, Mapping from pathlib import Path, PurePosixPath from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO +# Built as C extensions that a Python compiled without their libraries +# lacks: the run stopped on a ModuleNotFoundError traceback. +try: + import unicodedata + import zlib +except ImportError as _missing: + print(f"Error: this Python was built without its {_missing.name} module, which " + "the installer needs. Run it with another Python 3.8 or newer.", file=sys.stderr) + sys.exit(1) + # File lists are read from main, the newest ones, whichever commit the # installer came from. RETROBIOS_REF pins them to a tag or a commit when # reproducibility matters. @@ -645,8 +653,9 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # platforms: [{"platform", "name", "bios_dir"}], empty when none is found. "detect": ("os", "os_name", "platforms"), # stage: manifest (reason: network, not_found, invalid, clock for a - # refused certificate under a clock set in the past, or no_ca for no - # trusted certificates on the machine) or detect (reason: conflict, two + # refused certificate under a clock set in the past, no_ca for no + # trusted certificates on the machine, or no_ssl for a Python built + # without ssl) or detect (reason: conflict, two # systems found on one machine, or not_found, the platform named with # --platform, or by a file list or a pack in a run without questions, # found nowhere and no folder to assume) or from (a folder named with --from, @@ -3811,7 +3820,7 @@ def network_words( ) -> tuple[str, str]: """The reason a request failed, and the same in words a beginner reads. - The reason is clock, no_ca, network, or offline for a request the run + The reason is clock, no_ca, no_ssl, network, or offline for a request the run was told not to make. Offline, the run printed "[Errno -2] Name or service not known": the raw text now follows the words only under --verbose. @@ -3823,7 +3832,14 @@ def network_words( raw = str(reason or exc) code, words = "network", "" cert_error = getattr(sys.modules.get("ssl"), "SSLCertVerificationError", None) - if cert_error is not None and isinstance(inner, cert_error): + if (isinstance(exc, urllib.error.URLError) and raw.startswith("unknown url type") + and not hasattr(urllib.request, "HTTPSHandler")): + # urllib defines no HTTPS handler in a Python built without ssl: + # three attempts and a word on the connection blamed the network. + code = "no_ssl" + words = ("This Python was built without its ssl module and cannot use HTTPS. " + "Run the installer with another Python 3.8 or newer") + elif cert_error is not None and isinstance(inner, cert_error): today = today or datetime.date.today() if not _ca_bundle_found(): code = "no_ca" @@ -22588,7 +22604,8 @@ def _tui_fetch(plat: str, inbox: queue.Queue, log: io.StringIO) -> None: f"(--from-pack PACK), or {offline_undo()}."], 1)) return title = {"clock": "The clock is wrong", - "no_ca": "No trusted certificates"}.get(code, "No connection") + "no_ca": "No trusted certificates", + "no_ssl": "This Python cannot use HTTPS"}.get(code, "No connection") # The screen's first row is Try again: "then run again" was a second way. lines = [words[0].upper() + words[1:].replace(", then run again", ", then choose Try again") + "."] diff --git a/install.sh b/install.sh index 8555cd2c6..4c5668448 100755 --- a/install.sh +++ b/install.sh @@ -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="7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546" +DEFAULT_INSTALL_SHA256="40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 4aaafb661..40796b8ed 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1080006, - "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", + "size": 1080968, + "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1133183, - "sha256": "cc752f0ea8becaba5f3d96c6c77ca9c39b8b870fd9d2019985e1fd78ed912592" + "size": 1134145, + "sha256": "9a837360311366ecb4a65c4c4eff5388319ff0ade4f6acad3709cd4e63249a59" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1080006, - "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", + "size": 1080968, + "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1080006, - "sha256": "7cbbbe3baefc4a03f5e8a85ee915df4ae23217d21aa61a2dcfccb4cbc846f546", + "size": 1080968, + "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index f6d72e39a..52353b6e7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -5513,6 +5513,39 @@ class TheInstallerPageIsWrittenFromTheCode(unittest.TestCase): self.assertIn("route-keys", said) +class AStrippedPythonIsNamed(unittest.TestCase): + """A Python built without ssl blamed the network three times, and one + without zlib or unicodedata stopped on a ModuleNotFoundError traceback.""" + + def _run(self, hidden: str, *argv: str) -> subprocess.CompletedProcess: + code = (f"import runpy, sys; sys.modules[{hidden!r}] = None; " + "sys.argv = sys.argv[1:]; " + "runpy.run_path(sys.argv[0], run_name='__main__')") + env = {key: value for key, value in os.environ.items() + if key not in ("RETROBIOS_BASE_URL", install.OFFLINE_VARIABLE)} + return subprocess.run( + [sys.executable, "-c", code, str(REPO_ROOT / "install.py"), *argv], + env=env, capture_output=True, text=True, timeout=60, + stdin=subprocess.DEVNULL) + + def test_a_missing_c_module_is_named(self): + for hidden in ("zlib", "unicodedata"): + with self.subTest(module=hidden): + proc = self._run(hidden, "--help") + self.assertEqual(proc.returncode, 1) + self.assertNotIn("Traceback", proc.stderr) + self.assertIn(f"built without its {hidden} module", proc.stderr) + + def test_no_ssl_is_said_once_not_retried(self): + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as dest: + proc = self._run("ssl", "--platform", "retroarch", "--dest", dest, + "--check", "--no-input") + self.assertEqual(proc.returncode, 1) + self.assertIn("built without its ssl module and cannot use HTTPS", proc.stderr) + self.assertNotIn("trying again", proc.stderr) + self.assertNotIn("HTTPS_PROXY", proc.stderr) + + class NetworkFailuresInPlainWords(unittest.TestCase): """Offline, the run printed "[Errno -2] Name or service not known". A beginner needs "no internet connection", and the raw text only under diff --git a/wiki/installer.md b/wiki/installer.md index b5c6b183e..44815a33e 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1761,7 +1761,7 @@ other schemas under `api/v1/schemas/`. | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | -| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of | From bcedd22a9e2b55ae4e5ab235c437a7007dc40adb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:53:49 +0100 Subject: [PATCH 07/30] fix: pick a Python that can do HTTPS --- install.ps1 | 2 +- install.sh | 16 ++++++++-- install/launchers/index.json | 16 +++++----- ports/RetroBIOS/retrobios/screens.txt | 2 +- ports/shared/tui.txt | 12 ++++++- tests/test_handheld_launchers.py | 38 ++++++++++++++++++++++ tests/test_install.py | 45 +++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index cebb3b2b3..f44c7a75c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -84,7 +84,7 @@ param($candidate) if (-not $candidate) { return $false } $arguments = $candidate[1] - & $candidate[0].Source @arguments -c "import sys; raise SystemExit(sys.version_info < (3, 8))" | Out-Null + & $candidate[0].Source @arguments -c "import sys, ssl, unicodedata, zlib; raise SystemExit(sys.version_info < (3, 8))" 2>$null | Out-Null return ($LASTEXITCODE -eq 0) } $needPython = "Python 3.8 or newer is required. Install it with " + diff --git a/install.sh b/install.sh index 4c5668448..9594bc91d 100755 --- a/install.sh +++ b/install.sh @@ -121,14 +121,21 @@ if [ -z "$INSTALLER" ] || [ ! -f "$INSTALLER" ]; then INSTALLER="$TEMP_INSTALLER" fi +# A Python can be built without ssl, unicodedata or zlib, and the installer +# needs all three: a python3 lacking ssl was chosen over a full python and +# every download then failed as if the network were down. find_python() { PYTHON="" + PYTHON_LACKING="" for command_name in python3 python; do - if command -v "$command_name" >/dev/null 2>&1 \ - && "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' 2>/dev/null; then + command -v "$command_name" >/dev/null 2>&1 || continue + "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' \ + 2>/dev/null || continue + if "$command_name" -c 'import ssl, unicodedata, zlib' 2>/dev/null; then PYTHON=$command_name return 0 fi + PYTHON_LACKING=${PYTHON_LACKING:-$command_name} done return 1 } @@ -172,6 +179,11 @@ if ! find_python; then esac fi fi +if [ -z "$PYTHON" ] && [ -n "$PYTHON_LACKING" ]; then + echo "Error: $PYTHON_LACKING is built without a module the installer needs" >&2 + echo " (ssl, unicodedata or zlib). Run it with a full Python 3.8 or newer." >&2 + exit 1 +fi if [ -z "$PYTHON" ]; then echo "Error: Python 3.8 or newer is required." >&2 if [ -n "$install_command" ]; then diff --git a/install/launchers/index.json b/install/launchers/index.json index 40796b8ed..dc01820d9 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -62,8 +62,8 @@ { "dest": "roms/ports/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35984, - "sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1", + "size": 36434, + "sha256": "fa05432a9a4325f3fd3220eec0548ad932dffc1888b93a08af2047cc78aa4d0c", "executable": false } ] @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1134145, - "sha256": "9a837360311366ecb4a65c4c4eff5388319ff0ade4f6acad3709cd4e63249a59" + "size": 1134595, + "sha256": "72ef1c1054aaff03491127694455e27d25edcc5565ace9e490bb5623c2a62840" }, "members": [ { @@ -141,8 +141,8 @@ { "name": "RetroBIOS/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35984, - "sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1", + "size": 36434, + "sha256": "fa05432a9a4325f3fd3220eec0548ad932dffc1888b93a08af2047cc78aa4d0c", "executable": false } ] @@ -208,8 +208,8 @@ { "dest": ".config/modules/retrobios/tui.txt", "repo_path": "ports/shared/tui.txt", - "size": 35984, - "sha256": "7291b11eef5b5c2df8d2befab10614a2d515bffdf96dc208529b5dee9e6df0f1", + "size": 36434, + "sha256": "fa05432a9a4325f3fd3220eec0548ad932dffc1888b93a08af2047cc78aa4d0c", "executable": false } ] diff --git a/ports/RetroBIOS/retrobios/screens.txt b/ports/RetroBIOS/retrobios/screens.txt index b486cb87c..5e5d16bd2 100644 --- a/ports/RetroBIOS/retrobios/screens.txt +++ b/ports/RetroBIOS/retrobios/screens.txt @@ -43,7 +43,7 @@ rb_fetch_runtime() { fi PortMasterDialogInit "no-check" PortMasterDialog "messages_begin" - PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime." + PortMasterDialog "message" "This system's Python is older than 3.8 or lacks a module install.py needs. Downloading PortMaster's $RB_RUNTIME runtime." answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs") PortMasterDialog "messages_end" PortMasterDialogExit diff --git a/ports/shared/tui.txt b/ports/shared/tui.txt index 2ad8c88c9..c6bf99867 100644 --- a/ports/shared/tui.txt +++ b/ports/shared/tui.txt @@ -193,10 +193,17 @@ rb_open_log() { # --- Python --------------------------------------------------------------- -rb_python_ok() { +# The modules install.py needs that a Python can be built without: a python3 +# lacking ssl was chosen over PortMaster's runtime, and every download failed +# as if the network were down. +rb_python_new() { "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 } +rb_python_ok() { + rb_python_new "$@" && "$@" -c 'import ssl, unicodedata, zlib' >/dev/null 2>&1 +} + # The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 # runtime on aarch64, mounted as PortMaster's own Python ports do. A launcher # that can download the runtime defines rb_fetch_runtime. Sets @@ -252,6 +259,9 @@ rb_release_python() { rb_python_text() { local found found=$(python3 --version 2>&1 | awk '{print $2}') + if command -v python3 >/dev/null 2>&1 && rb_python_new python3; then + found="$found, built without a module install.py needs (ssl, unicodedata or zlib)" + fi case "$RB_PYTHON_PROBLEM" in old) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch). $RB_PC_ROUTE" ;; runtime_offline) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download. $RB_PC_ROUTE" ;; diff --git a/tests/test_handheld_launchers.py b/tests/test_handheld_launchers.py index 835376c61..99607be54 100644 --- a/tests/test_handheld_launchers.py +++ b/tests/test_handheld_launchers.py @@ -1080,6 +1080,44 @@ class ThePortMasterPort(unittest.TestCase): self.assertIn("--dest", screen) self.assertEqual(device.calls(), []) + def _python_without_ssl(self, device) -> None: + """A python3 3.8 or newer whose ssl import fails, as on a CFW image + built without OpenSSL.""" + real = shutil.which("python3.8") or sys.executable + shadow = device.root / "no-ssl" + shadow.mkdir() + (shadow / "ssl.py").write_text('raise ImportError("built without ssl")\n') + device.tool("python3", f'#!/bin/sh\nPYTHONPATH="{shadow}" exec {real} "$@"\n') + + def test_a_python_without_ssl_is_told_on_the_console(self): + """Only the version was probed: a python3 without ssl was chosen and + every download failed as if the network were down.""" + device = self.device() + self._python_without_ssl(device) + device.tool("uname", "#!/bin/sh\necho armv7l\n") + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 1) + screen = " ".join(device.screen.split()) + self.assertIn("built without a module install.py needs", screen) + self.assertEqual(device.calls(), []) + + def test_a_python_without_ssl_gives_way_to_the_runtime(self): + device = self.device() + self._python_without_ssl(device) + real = shutil.which("python3.8") or sys.executable + device.tool("uname", "#!/bin/sh\necho aarch64\n") + device.tool("mount", '#!/bin/sh\nprintf "mount %s\\n" "$*" >> "$FAKE_STATE/tools.log"\n' + 'mkdir -p "$2/bin"\n' + f'printf \'#!/bin/sh\\nunset PYTHONHOME\\nexec {real} "$@"\\n\' > "$2/bin/python3"\n' + 'chmod +x "$2/bin/python3"\n') + device.tool("umount", FAKE_RECORDER) + (self.control / "libs").mkdir() + (self.control / "libs" / "python_3.11.squashfs").write_bytes(b"") + proc = device.run(["bash", self.script]) + self.assertEqual(proc.returncode, 0, proc.stderr.decode()) + [call] = device.tui_calls() + self.assertTrue(any(t.startswith("mount") for t in device.tools())) + def test_the_runtime_is_mounted_then_unmounted(self): """ArkOS's Python 3.7: PortMaster's python_3.11 runtime, mounted for the run and unmounted after it.""" diff --git a/tests/test_install.py b/tests/test_install.py index 52353b6e7..8556425fa 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2575,6 +2575,51 @@ class NoPythonNamesThisSystemsCommand(unittest.TestCase): self.assertNotIn("apt", proc.stderr) +class ABootstrapPythonCanDoHttps(unittest.TestCase): + """find_python checked the version only: a python3 built without ssl + was chosen over a full python, and every download then failed.""" + + def _bin(self) -> tuple[Path, Path]: + root = Path(_tempdir(self)) + fake_bin = root / "bin" + fake_bin.mkdir() + for tool in ("sed", "tr", "dirname", "cat", "awk", "uname"): + found = shutil.which(tool) + if found: + (fake_bin / tool).symlink_to(found) + shadow = root / "no-ssl" + shadow.mkdir() + (shadow / "ssl.py").write_text('raise ImportError("built without ssl")\n') + (fake_bin / "python3").write_text( + f'#!/bin/sh\nPYTHONPATH="{shadow}" exec {shlex.quote(sys.executable)} "$@"\n') + (fake_bin / "python3").chmod(0o755) + (root / "install.py").write_text("import ssl; print('installer ran')\n") + shutil.copy(REPO_ROOT / "install.sh", root / "install.sh") + return root, fake_bin + + def _run(self, root: Path, fake_bin: Path) -> subprocess.CompletedProcess: + return subprocess.run( + ["/bin/sh", str(root / "install.sh")], cwd=root, + env={"PATH": str(fake_bin), "HOME": str(root)}, + capture_output=True, text=True, timeout=30, check=False, + stdin=subprocess.DEVNULL, start_new_session=True, + ) + + def test_a_full_python_is_preferred(self): + root, fake_bin = self._bin() + (fake_bin / "python").symlink_to(sys.executable) + proc = self._run(root, fake_bin) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("installer ran", proc.stdout) + + def test_the_missing_module_is_named(self): + root, fake_bin = self._bin() + proc = self._run(root, fake_bin) + self.assertEqual(proc.returncode, 1) + self.assertNotIn("installer ran", proc.stdout) + self.assertIn("python3 is built without a module the installer needs", proc.stderr) + + @unittest.skipUnless(shutil.which("pwsh") and shutil.which("script"), "needs PowerShell and a terminal") class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase): """A Windows machine without Python was told to install it, open a new From 896dbc866061befa5b1f1bc6b09637073955d842 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:54:53 +0100 Subject: [PATCH 08/30] fix: say a platform has no hardware targets --- install.ps1 | 2 +- install.py | 13 +++++++------ install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_cores.py | 14 +++++++++++++- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/install.ps1 b/install.ps1 index f44c7a75c..d41b5047b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6" + $defaultInstallSha256 = "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 4fa6d0d7a..ee9ba411e 100644 --- a/install.py +++ b/install.py @@ -14388,12 +14388,13 @@ def _several_found(platforms: list[tuple[str, Path]], setups: list) -> None: def _unknown_target(target: str, plat: str, targets: dict) -> None: - print(f"Error: unknown target '{target}' for {plat}.", file=sys.stderr) - if targets: - print(" " + did_you_mean([target], sorted(targets), "--list-targets"), - file=sys.stderr) - else: - print(f" {plat} publishes no target list", file=sys.stderr) + if not targets: + # No value can work: "unknown target" read as a typo of the board. + print(f"Error: {display_name(plat)} has no hardware targets: run again " + "without --target.", file=sys.stderr) + return + print(f"Error: unknown target '{target}' for {display_name(plat)}.", file=sys.stderr) + print(" " + did_you_mean([target], sorted(targets), "--list-targets"), file=sys.stderr) def _is_terminal(stream: TextIO) -> bool: diff --git a/install.sh b/install.sh index 9594bc91d..6de052330 100755 --- a/install.sh +++ b/install.sh @@ -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="40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6" +DEFAULT_INSTALL_SHA256="97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index dc01820d9..9001e7fae 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1080968, - "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", + "size": 1081112, + "sha256": "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1134595, - "sha256": "72ef1c1054aaff03491127694455e27d25edcc5565ace9e490bb5623c2a62840" + "size": 1134739, + "sha256": "c0bdf23698273f62236ff5928d035086625532995322b453107cc1b81c365972" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1080968, - "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", + "size": 1081112, + "sha256": "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1080968, - "sha256": "40647ef424033dd7bb5707f3a7c217ad0c746cad4f37f1029670045d34dac0b6", + "size": 1081112, + "sha256": "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909", "executable": false }, { diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index beb738b11..26a71a6cb 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -416,11 +416,23 @@ class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): with self.assertRaises(SystemExit) as stop: install.main() self.assertEqual(stop.exception.code, 1) - self.assertIn("unknown target 'steamos' for retroarch", err.getvalue()) + self.assertIn("unknown target 'steamos' for RetroArch", err.getvalue()) self.assertIn("Nothing was installed", err.getvalue()) fetch.assert_not_called() +class APlatformWithoutTargetsSaysSo(unittest.TestCase): + """`--target switch` on MiSTer FPGA read "unknown target", a typo to the + user, when no value can work there.""" + + def test_the_way_forward_is_named(self): + with contextlib.redirect_stderr(io.StringIO()) as err: + install._unknown_target("switch", "misterfpga", {}) + said = err.getvalue() + self.assertIn("MiSTer FPGA has no hardware targets: run again without --target.", said) + self.assertNotIn("unknown target", said) + + class _CoreRepository(FakeRepository): """The fake repository with the two maps and a portable RetroArch.""" From 7da6e117a3f10b91b8b72dbd2747fe6eb323b60a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:56:00 +0100 Subject: [PATCH 09/30] fix: read a RetroDECK bios dest as its folder --- install.ps1 | 2 +- install.py | 6 ++++++ install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install_experience.py | 21 +++++++++++++++++++++ 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index d41b5047b..fbb39ad50 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909" + $defaultInstallSha256 = "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index ee9ba411e..48dfd738d 100644 --- a/install.py +++ b/install.py @@ -14074,6 +14074,12 @@ def _list_card_choice(plat: str, dest: Path) -> CardChoice: return CardChoice(plat, dest, f"{screen_text(str(dest))} holds the files of " f"{names}: installing into it as named. Name the card's BIOS " "folder with --dest to write there.") + if plat == "retrodeck" and dest.name.lower() == "bios" and not _is_dir(dest / "bios"): + # RetroDECK's list writes bios/, roms/ and the rest below its own + # folder: its BIOS folder, which --help asks for, got bios/bios. + return CardChoice(plat, dest.parent, f"{shown_path(dest)} is RetroDECK's BIOS " + f"folder: the files go below {shown_path(dest.parent)}, the " + "RetroDECK folder holding it") return CardChoice(plat, dest) diff --git a/install.sh b/install.sh index 6de052330..b3bd625c5 100755 --- a/install.sh +++ b/install.sh @@ -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="97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909" +DEFAULT_INSTALL_SHA256="9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 9001e7fae..1672607f9 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1081112, - "sha256": "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909", + "size": 1081583, + "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1134739, - "sha256": "c0bdf23698273f62236ff5928d035086625532995322b453107cc1b81c365972" + "size": 1135210, + "sha256": "59914e1d36e1169d6a9b24314a8e64833a434fa8c6df05916257aa5007855b0f" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1081112, - "sha256": "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909", + "size": 1081583, + "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1081112, - "sha256": "97857d0ba16eaa8be16a4c8832bf04b6ee6da1bcdbc0d747d1934cc3eb5ff909", + "size": 1081583, + "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", "executable": false }, { diff --git a/tests/test_install_experience.py b/tests/test_install_experience.py index ba86703cb..7eab70c72 100644 --- a/tests/test_install_experience.py +++ b/tests/test_install_experience.py @@ -823,6 +823,27 @@ class APartlyClosedHome(unittest.TestCase): self.assertEqual([entry["bios_dir"] for entry in detect["platforms"]], [str(system)]) +class RetroDeckBiosFolderNamedAsDest(unittest.TestCase): + """`--platform retrodeck --dest ~/retrodeck/bios`, as --help asks for a + BIOS folder, put every file under bios/bios, which RetroDECK never reads.""" + + def test_the_files_go_below_the_retrodeck_folder(self): + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + (root / "bios").mkdir() + choice = install.card_choice("retrodeck", "retrodeck", root / "bios") + self.assertEqual(choice.folder, root) + self.assertIn("RetroDECK's BIOS folder", choice.note) + self.assertFalse(choice.refused) + + def test_the_retrodeck_folder_itself_is_kept(self): + root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) + self.addCleanup(shutil.rmtree, root, True) + (root / "bios").mkdir() + choice = install.card_choice("retrodeck", "retrodeck", root) + self.assertEqual((choice.folder, choice.note), (root, "")) + + class AnUnreadableRetroArchConfigIsSaid(unittest.TestCase): """A retroarch.cfg a `sudo retroarch` left at 0600 for root was read as no config at all: RetroArch was not found and nothing said why.""" From 3f0dfbce31554254231a4fa518727166aa5cb835 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:59:02 +0100 Subject: [PATCH 10/30] feat: emit an error event for an unknown name --- install.ps1 | 2 +- install.py | 55 +++++++++++++++++++++--------------- install.sh | 2 +- install/launchers/index.json | 16 +++++------ tests/test_install.py | 40 +++++++++++++++++++++++++- tests/test_install_cores.py | 3 +- wiki/installer.md | 2 +- 7 files changed, 85 insertions(+), 35 deletions(-) diff --git a/install.ps1 b/install.ps1 index fbb39ad50..19267c967 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213" + $defaultInstallSha256 = "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 48dfd738d..6e003ed10 100644 --- a/install.py +++ b/install.py @@ -51,7 +51,7 @@ import xml.etree.ElementTree as ET import zipfile from collections.abc import Callable, Collection, Iterable, Iterator, Mapping from pathlib import Path, PurePosixPath -from typing import Any, BinaryIO, ContextManager, NamedTuple, TextIO +from typing import Any, BinaryIO, ContextManager, NamedTuple, NoReturn, TextIO # Built as C extensions that a Python compiled without their libraries # lacks: the run stopped on a ModuleNotFoundError traceback. @@ -662,7 +662,9 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # reason: not_found, not_a_folder, unreadable, or system for the root # of the system and /proc, /sys, /dev) or dest (reason: unreadable, a # folder above it this user cannot open, or not_writable, the BIOS - # folder cannot be created, a summary following). + # folder cannot be created, a summary following) or option (reason: + # unknown, a platform, target, system, core or emulator the run cannot + # use, refused before any download). "error": ("stage", "reason", "platform", "message"), # A folder named with --from, once read: the files it holds (counted # up to MAX_SCANNED_FILES, truncated past them), and the folders below @@ -3058,6 +3060,23 @@ def did_you_mean( return f"{listing} lists all {len(known)}." +def refuse_name(platform: str, *lines: str) -> NoReturn: + """End the run on a name it cannot use (a platform, target, system, core + or emulator), refused before any download. + + The reason went to stderr only: a reader of --events got start, then + end with code 1, and could not tell a typo from a failed download. + """ + message = " ".join(line.strip() for line in lines) + if message.startswith("Error: "): + message = message[len("Error: "):] + EVENTS.emit("error", stage="option", reason="unknown", platform=platform, + message=message) + for line in lines: + print(line, file=sys.stderr) + sys.exit(1) + + def normalize_platform(name: str, say: bool = True) -> str: """Return the canonical platform id for user-supplied input. @@ -3077,10 +3096,8 @@ def normalize_platform(name: str, say: bool = True) -> str: print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " f"--platform {rule.manifest}.") return rule.manifest - print(f"Unknown platform '{name.strip()}'. " - + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms"), - file=sys.stderr) - sys.exit(1) + refuse_name(name.strip(), f"Unknown platform '{name.strip()}'. " + + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms")) def _read_limited_json( @@ -9061,8 +9078,7 @@ def _narrow_to_emulators( try: chosen = resolve_choices(names, known, "emulator") except ValueError as exc: - print(f"Error: {plat}: {exc}", file=sys.stderr) - sys.exit(1) + refuse_name(plat, f"Error: {plat}: {exc}") before = len(files) files, omitted = narrow_to_cores(manifest, chosen, files, omitted) # A whole-platform install keeps the platform's files of a system no @@ -9118,8 +9134,7 @@ def _requested_narrowing( cores = resolve_choices(args.core or [], choices["cores"], "core") regions = resolve_regions(args.region or []) except ValueError as exc: - print(f"Error: {plat}: {exc}", file=sys.stderr) - sys.exit(1) + refuse_name(plat, f"Error: {plat}: {exc}") return systems, cores, regions @@ -14393,14 +14408,13 @@ def _several_found(platforms: list[tuple[str, Path]], setups: list) -> None: sys.exit(1) -def _unknown_target(target: str, plat: str, targets: dict) -> None: +def _unknown_target(target: str, plat: str, targets: dict, *after: str) -> NoReturn: if not targets: # No value can work: "unknown target" read as a typo of the board. - print(f"Error: {display_name(plat)} has no hardware targets: run again " - "without --target.", file=sys.stderr) - return - print(f"Error: unknown target '{target}' for {display_name(plat)}.", file=sys.stderr) - print(" " + did_you_mean([target], sorted(targets), "--list-targets"), file=sys.stderr) + refuse_name(plat, f"Error: {display_name(plat)} has no hardware targets: run again " + "without --target.", *after) + refuse_name(plat, f"Error: unknown target '{target}' for {display_name(plat)}.", + " " + did_you_mean([target], sorted(targets), "--list-targets"), *after) def _is_terminal(stream: TextIO) -> bool: @@ -14743,11 +14757,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: sys.exit(1) target_lists[plat_name] = targets if args.target not in targets: - _unknown_target(args.target, plat_name, targets) - if len(platforms) > 1: - print(" Nothing was installed. Name one platform with " - "--platform to install it alone.", file=sys.stderr) - sys.exit(1) + _unknown_target(args.target, plat_name, targets, *( + [" Nothing was installed. Name one platform with " + "--platform to install it alone."] if len(platforms) > 1 else [])) if args.romm_firmware is not None: # Applied or refused, never ignored. @@ -14869,7 +14881,6 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # of what a filter was asked for: a 4 GB download instead of # the subset the target needs. Refuse rather than ignore. _unknown_target(target, plat_name, targets) - sys.exit(1) elif target_info.get("cores") is None: print( f" Target '{target}' publishes no core list: " diff --git a/install.sh b/install.sh index b3bd625c5..27da4735a 100755 --- a/install.sh +++ b/install.sh @@ -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="9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213" +DEFAULT_INSTALL_SHA256="ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 1672607f9..c68543061 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1081583, - "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", + "size": 1082235, + "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1135210, - "sha256": "59914e1d36e1169d6a9b24314a8e64833a434fa8c6df05916257aa5007855b0f" + "size": 1135862, + "sha256": "a1d165f2bcbc60eec9cc8654e92bf4f1d1790d93e2c33dcd467594a5c85ad9ea" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1081583, - "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", + "size": 1082235, + "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1081583, - "sha256": "9c9a29d182e9c4074197a4177e13c026e06c98defe0fc79ba820f1c696216213", + "size": 1082235, + "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 8556425fa..98c6d74c7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3952,6 +3952,44 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess ) +class AnUnknownNameIsAnEvent(unittest.TestCase): + """--events carried start then end with code 1 for an unknown platform, + target or system: the reason was on stderr only, which readers ignore.""" + + def _events(self, *argv: str) -> tuple[int, list[dict]]: + env = {key: value for key, value in os.environ.items() + if key != "RETROBIOS_BASE_URL"} + env[install.OFFLINE_VARIABLE] = "1" + with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as dest: + proc = subprocess.run( + [sys.executable, str(REPO_ROOT / "install.py"), "--dest", dest, + "--check", "--events", "--no-input", *argv], + env=env, capture_output=True, text=True, timeout=120, + stdin=subprocess.DEVNULL) + return proc.returncode, [json.loads(line) for line in proc.stdout.splitlines()] + + def test_each_refusal_says_why(self): + manifest = str(REPO_ROOT / "install" / "retroarch.json") + for argv, words in ( + (["--platform", "nope"], "Unknown platform 'nope'"), + (["--platform", "retroarch", "--manifest", manifest, "--system", "nope"], + "unknown system: nope"), + (["--platform", "retroarch", "--manifest", manifest, "--core", "nope"], + "unknown core: nope"), + (["--platform", "retroarch", "--manifest", manifest, "--emulator", "nope"], + "unknown emulator: nope")): + with self.subTest(argv=argv): + code, events = self._events(*argv) + self.assertNotEqual(code, 0) + errors = [event for event in events if event["event"] == "error"] + self.assertEqual(len(errors), 1, events) + self.assertEqual((errors[0]["stage"], errors[0]["reason"]), + ("option", "unknown")) + self.assertIn(words, errors[0]["message"]) + self.assertFalse(errors[0]["message"].startswith("Error")) + self.assertEqual(events[-1]["event"], "end") + + class ATypoGetsTheClosestName(unittest.TestCase): """--platform batocra, --system playstation or --core swanstaton were answered with every name the platform has, up to 400 on one line, and @@ -4013,7 +4051,7 @@ class ATypoGetsTheClosestName(unittest.TestCase): def test_a_target_typo(self): err = io.StringIO() - with contextlib.redirect_stderr(err): + with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): install._unknown_target("swich", "retroarch", {"switch": {}, "desktop": {}}) self.assertIn("Did you mean: switch?", err.getvalue()) self.assertIn("--list-targets", err.getvalue()) diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index 26a71a6cb..c0c057470 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -426,7 +426,8 @@ class APlatformWithoutTargetsSaysSo(unittest.TestCase): user, when no value can work there.""" def test_the_way_forward_is_named(self): - with contextlib.redirect_stderr(io.StringIO()) as err: + with contextlib.redirect_stderr(io.StringIO()) as err, \ + self.assertRaises(SystemExit): install._unknown_target("switch", "misterfpga", {}) said = err.getvalue() self.assertIn("MiSTer FPGA has no hardware targets: run again without --target.", said) diff --git a/wiki/installer.md b/wiki/installer.md index 44815a33e..67da7754c 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1761,7 +1761,7 @@ other schemas under `api/v1/schemas/`. | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | -| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of | From f1ce63fe5cf5dd700223877b3ab56a51ef743847 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:05:08 +0100 Subject: [PATCH 11/30] docs: name the SHARE drive folder for a card --- platforms/_guides.yml | 6 ++++-- tests/test_pack_readme.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/platforms/_guides.yml b/platforms/_guides.yml index d07af1e2c..2a886327d 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -335,7 +335,8 @@ guides: Option C: Manual (SD card) -------------------------- 1. Put the SD card in your PC - 2. Navigate to /userdata/bios/ on the SHARE partition + 2. Open the bios folder at the root of the SHARE drive + (Batocera itself names it /userdata/bios/) 3. Extract all files from this archive directly into that folder NOTE: Dreamcast flash memory is named dc_nvmem.bin @@ -368,7 +369,8 @@ guides: Option C: Manual (SD card) -------------------------- 1. Put the SD card in your PC - 2. Navigate to /recalbox/share/bios/ + 2. Open the bios folder at the root of the SHARE drive + (Recalbox itself names it /recalbox/share/bios/) 3. Extract all files from this archive directly into that folder logo_owner: Recalbox logo_licence: file from homarr-labs/dashboard-icons, Apache-2.0 diff --git a/tests/test_pack_readme.py b/tests/test_pack_readme.py index 9218843ca..786ccd882 100644 --- a/tests/test_pack_readme.py +++ b/tests/test_pack_readme.py @@ -44,6 +44,22 @@ class GuidesPointAtTheReadPath(unittest.TestCase): ) +class ACardOpenedOnAComputerShowsItsShareDrive(unittest.TestCase): + """The SD card steps said /userdata/bios/ and /recalbox/share/bios/, + paths of the running system. On a computer the SHARE drive opens at its + root, bios/ beside roms/ and system/, and no such folder exists.""" + + def test_the_card_steps_name_the_drive_folder(self): + for name in ("batocera", "recalbox"): + text = _build_readme(name, name, "", 1, 1) + card = text[text.index("Manual (SD card)"):] + card = card[:card.index("\n\n", card.index("1."))] + with self.subTest(platform=name): + self.assertIn("bios folder at the root of the SHARE drive", card) + self.assertNotIn("/userdata/bios/ on", card) + self.assertNotIn("Navigate to /recalbox", card) + + class GuidesNameShippedInstallers(unittest.TestCase): def test_every_installer_named_exists(self): """RetroBat's guide sent users to an install.bat no release carries.""" From 94b5195590785f0fea0193c371848318c2e9fc0d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:05:44 +0100 Subject: [PATCH 12/30] fix: exit 2 on an unknown name, as for an option --- install.ps1 | 2 +- install.py | 6 ++++-- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 4 ++-- wiki/installer.md | 6 +++--- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/install.ps1 b/install.ps1 index 19267c967..8321df15e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676" + $defaultInstallSha256 = "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 6e003ed10..11db59f4a 100644 --- a/install.py +++ b/install.py @@ -3065,7 +3065,9 @@ def refuse_name(platform: str, *lines: str) -> NoReturn: or emulator), refused before any download. The reason went to stderr only: a reader of --events got start, then - end with code 1, and could not tell a typo from a failed download. + end with code 1, and could not tell a typo from a failed download. Code + 2, as for an option argparse refuses: 1 also means --check found files + to fetch, and a script reading it reran the install on a typo forever. """ message = " ".join(line.strip() for line in lines) if message.startswith("Error: "): @@ -3074,7 +3076,7 @@ def refuse_name(platform: str, *lines: str) -> NoReturn: message=message) for line in lines: print(line, file=sys.stderr) - sys.exit(1) + sys.exit(2) def normalize_platform(name: str, say: bool = True) -> str: diff --git a/install.sh b/install.sh index 27da4735a..251c35caa 100755 --- a/install.sh +++ b/install.sh @@ -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="ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676" +DEFAULT_INSTALL_SHA256="f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index c68543061..6e01ea755 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1082235, - "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", + "size": 1082390, + "sha256": "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1135862, - "sha256": "a1d165f2bcbc60eec9cc8654e92bf4f1d1790d93e2c33dcd467594a5c85ad9ea" + "size": 1136017, + "sha256": "9c0587fc18a1402e6f86eeaa8abf7bc26f52bf201e362575adc8e417c983b7de" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1082235, - "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", + "size": 1082390, + "sha256": "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1082235, - "sha256": "ce03555a39cc031b1135031a28b80b028e432b47412cb70354dd87372761d676", + "size": 1082390, + "sha256": "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 98c6d74c7..a711787cd 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3980,7 +3980,7 @@ class AnUnknownNameIsAnEvent(unittest.TestCase): "unknown emulator: nope")): with self.subTest(argv=argv): code, events = self._events(*argv) - self.assertNotEqual(code, 0) + self.assertEqual(code, 2) errors = [event for event in events if event["event"] == "error"] self.assertEqual(len(errors), 1, events) self.assertEqual((errors[0]["stage"], errors[0]["reason"]), @@ -4019,7 +4019,7 @@ class ATypoGetsTheClosestName(unittest.TestCase): err = io.StringIO() with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as raised: install.normalize_platform("batocra") - self.assertEqual(raised.exception.code, 1) + self.assertEqual(raised.exception.code, 2) self.assertIn("Did you mean: batocera?", err.getvalue()) self.assertNotIn("recalbox", err.getvalue()) diff --git a/wiki/installer.md b/wiki/installer.md index 67da7754c..da92d321c 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1714,8 +1714,8 @@ fields, the installer refuses to narrow rather than install everything. | Code | Meaning | |------|---------| | `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 | +| `1` | a failure: the one-liner could not download or verify `install.py`, or found no Python 3.8 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--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 an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. 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 | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | @@ -1761,7 +1761,7 @@ other schemas under `api/v1/schemas/`. | `start` | `schema`, `installer`, `mode` | First line. `schema` is 1, `installer` the identity `--version` prints, `mode` one of `install`, `check`, `dry-run`, `list` | | `unsearched` | `folder`, `reason`, `message` | Before `detect`, once for each folder the search for setups could not look in, this user being refused it. What it would hold counts as absent, and the other places are still searched: `detect` then lists what they hold, and an empty `platforms` after an `unsearched` line means nothing was found where the installer could look. `reason` says why in words (`Permission denied`), `message` says it in a sentence | | `detect` | `os`, `os_name`, `platforms` | Once the platforms are known. `os_name` comes from `/etc/os-release`. `platforms` lists `{platform, name, bios_dir}`, and is empty when nothing supported was found | -| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | +| `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of | From da4773a0be3f23b3fa5e0afd341abdfadb7a3f4d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:06:36 +0100 Subject: [PATCH 13/30] fix: answer the system names the listing prints --- install.ps1 | 2 +- install.py | 15 ++++++++++++++- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 19 ++++++++++++++++++- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/install.ps1 b/install.ps1 index 8321df15e..3c579f68a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050" + $defaultInstallSha256 = "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 11db59f4a..a3a6e48aa 100644 --- a/install.py +++ b/install.py @@ -3098,8 +3098,21 @@ def normalize_platform(name: str, say: bool = True) -> str: print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " f"--platform {rule.manifest}.") return rule.manifest + # --list-platforms prints TrimUI with why it gets no folder, and naming + # it answered "Unknown platform". + for rule in _EMBEDDED_RULES: + if key and not rule.manifest and key == _name_key(rule.name): + refuse_name(name.strip(), f"{rule.name} is not installed to: " + f"{_NO_FOLDER.get(rule.name, 'no source names its BIOS folder')}.", + " Name that folder: --platform retroarch " + "--dest FOLDER") + # Every name --list-platforms prints, so that spruce suggests spruceOS + # and the count is the one the listing shows. + known = list(AVAILABLE_PLATFORMS) + list(dict.fromkeys( + rule.name for rule in _EMBEDDED_RULES + if _name_key(rule.name) not in {_name_key(plat) for plat in AVAILABLE_PLATFORMS})) refuse_name(name.strip(), f"Unknown platform '{name.strip()}'. " - + did_you_mean([name], list(AVAILABLE_PLATFORMS), "--list-platforms")) + + did_you_mean([name], known, "--list-platforms")) def _read_limited_json( diff --git a/install.sh b/install.sh index 251c35caa..1641ace5d 100755 --- a/install.sh +++ b/install.sh @@ -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="f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050" +DEFAULT_INSTALL_SHA256="08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 6e01ea755..39e53cb3d 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1082390, - "sha256": "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050", + "size": 1083185, + "sha256": "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1136017, - "sha256": "9c0587fc18a1402e6f86eeaa8abf7bc26f52bf201e362575adc8e417c983b7de" + "size": 1136812, + "sha256": "070626dc9a11addef163495858e5e1ad6808e46522c9570fc4aa2909398b650d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1082390, - "sha256": "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050", + "size": 1083185, + "sha256": "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1082390, - "sha256": "f85ee09109492b6586240c75acd179916f570a663bf02ff8b303c019f88d1050", + "size": 1083185, + "sha256": "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index a711787cd..08283b0ee 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4012,8 +4012,25 @@ class ATypoGetsTheClosestName(unittest.TestCase): self.assertIn(f"--platform {plat}", out.getvalue()) def test_a_system_that_gets_no_list_is_still_refused(self): - with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + """TrimUI, which --list-platforms prints, was answered "Unknown + platform" with no way forward.""" + err = io.StringIO() + with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): install.normalize_platform("TrimUI") + self.assertNotIn("Unknown platform", err.getvalue()) + self.assertIn("--platform retroarch --dest FOLDER", err.getvalue()) + + def test_a_system_name_is_suggested(self): + err = io.StringIO() + with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): + install.normalize_platform("spruce") + self.assertIn("Did you mean: spruceOS?", err.getvalue()) + # The distinct names --list-platforms prints: Batocera is both a + # platform and a system it recognises. + names = [*install.AVAILABLE_PLATFORMS, + *(line.split(":")[0] for line in install._system_lines())] + listed = len({install._name_key(name) for name in names}) + self.assertIn(f"lists all {listed}.", err.getvalue()) def test_a_platform_typo_is_answered_with_one_name(self): err = io.StringIO() From f6de9a0ae304de2950770f79727d00db1fd2fb69 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:07:25 +0100 Subject: [PATCH 14/30] feat: answer ES-DE, LaunchBox and Android by name --- install.ps1 | 2 +- install.py | 17 ++++++++++++++++- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 16 +++++++++++++++- wiki/installer.md | 2 +- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 3c579f68a..ec3802e36 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac" + $defaultInstallSha256 = "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a3a6e48aa..4b3be9c4d 100644 --- a/install.py +++ b/install.py @@ -3079,6 +3079,12 @@ def refuse_name(platform: str, *lines: str) -> NoReturn: sys.exit(2) +# Frontends and systems that keep no BIOS folder of their own: each +# emulator they start reads its own, which a run without --platform finds. +# --platform es-de was "Unknown platform", the answer left unsaid. +_NO_BIOS_FOLDER = ("ES-DE", "LaunchBox", "Android") + + def normalize_platform(name: str, say: bool = True) -> str: """Return the canonical platform id for user-supplied input. @@ -3098,6 +3104,11 @@ def normalize_platform(name: str, say: bool = True) -> str: print(f" {rule.name} gets the {display_name(rule.manifest)} file list: " f"--platform {rule.manifest}.") return rule.manifest + for frontend in _NO_BIOS_FOLDER: + if key and key == _name_key(frontend): + refuse_name(name.strip(), f"{frontend} has no BIOS folder of its own: run again " + "without --platform, and the installer fills the folder of each " + "emulator it finds here.") # --list-platforms prints TrimUI with why it gets no folder, and naming # it answered "Unknown platform". for rule in _EMBEDDED_RULES: @@ -3110,7 +3121,8 @@ def normalize_platform(name: str, say: bool = True) -> str: # and the count is the one the listing shows. known = list(AVAILABLE_PLATFORMS) + list(dict.fromkeys( rule.name for rule in _EMBEDDED_RULES - if _name_key(rule.name) not in {_name_key(plat) for plat in AVAILABLE_PLATFORMS})) + if _name_key(rule.name) not in {_name_key(plat) for plat in AVAILABLE_PLATFORMS} + )) + list(_NO_BIOS_FOLDER) refuse_name(name.strip(), f"Unknown platform '{name.strip()}'. " + did_you_mean([name], known, "--list-platforms")) @@ -14490,6 +14502,9 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: "(--platform takes either name):") for line in _system_lines(): print(f" {line}") + print("\nNo BIOS folder of their own, each emulator reading its own (run without " + "--platform):") + print(f" {', '.join(_NO_BIOS_FOLDER)}") detected = detect_platforms(os_type) if detected: print("\nDetected on this system:") diff --git a/install.sh b/install.sh index 1641ace5d..960eda7b7 100755 --- a/install.sh +++ b/install.sh @@ -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="08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac" +DEFAULT_INSTALL_SHA256="ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 39e53cb3d..eca4e3fc3 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1083185, - "sha256": "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac", + "size": 1083967, + "sha256": "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1136812, - "sha256": "070626dc9a11addef163495858e5e1ad6808e46522c9570fc4aa2909398b650d" + "size": 1137594, + "sha256": "433caee0f48148c8c25122e3cfa4a6d4577160a2b4ddcf2267415ebd21beb843" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1083185, - "sha256": "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac", + "size": 1083967, + "sha256": "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1083185, - "sha256": "08be74d479990f072bd16033f258108a583b34baddfd75d9d9be11f23ae77dac", + "size": 1083967, + "sha256": "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 08283b0ee..c42477b7d 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4020,6 +4020,19 @@ class ATypoGetsTheClosestName(unittest.TestCase): self.assertNotIn("Unknown platform", err.getvalue()) self.assertIn("--platform retroarch --dest FOLDER", err.getvalue()) + def test_a_frontend_without_a_bios_folder_says_what_to_do(self): + """ES-DE, LaunchBox and Android have no BIOS folder: each emulator + reads its own, and --platform es-de was "Unknown platform".""" + for typed, said in (("es-de", "ES-DE"), ("esde", "ES-DE"), + ("launchbox", "LaunchBox"), ("android", "Android")): + err = io.StringIO() + with self.subTest(typed=typed), contextlib.redirect_stderr(err), \ + self.assertRaises(SystemExit) as raised: + install.normalize_platform(typed) + self.assertEqual(raised.exception.code, 2) + self.assertIn(f"{said} has no BIOS folder of its own: run again without " + "--platform", err.getvalue()) + def test_a_system_name_is_suggested(self): err = io.StringIO() with contextlib.redirect_stderr(err), self.assertRaises(SystemExit): @@ -4028,7 +4041,8 @@ class ATypoGetsTheClosestName(unittest.TestCase): # The distinct names --list-platforms prints: Batocera is both a # platform and a system it recognises. names = [*install.AVAILABLE_PLATFORMS, - *(line.split(":")[0] for line in install._system_lines())] + *(line.split(":")[0] for line in install._system_lines()), + *install._NO_BIOS_FOLDER] listed = len({install._name_key(name) for name in names}) self.assertIn(f"lists all {listed}.", err.getvalue()) diff --git a/wiki/installer.md b/wiki/installer.md index da92d321c..d7d04be39 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -950,7 +950,7 @@ what the options name, everything by default. | Option | Effect | |--------|--------| -| `--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. Unknown names are refused with the closest ones | +| `--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. ES-DE, LaunchBox and Android, which keep no BIOS folder, are answered with the run that fills each emulator's folder. Unknown names are refused with the closest ones | | `--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is. A folder that does not exist is named with the nearest one that does, then created, except for a system that runs from its SD card, which refuses it. When more than its last level is missing (a mistyped path), a run at a terminal asks first, and the answer defaults to no | | `--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given | | `--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices | From 338965154948b13d560d5cf3ee0bbab5b6e6dcbe Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:08:12 +0100 Subject: [PATCH 15/30] fix: name a sign-in page sent for the file list --- install.ps1 | 2 +- install.py | 15 +++++++++++++-- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 16 ++++++++++++++++ 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/install.ps1 b/install.ps1 index ec3802e36..36236c052 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0" + $defaultInstallSha256 = "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 4b3be9c4d..a345ace17 100644 --- a/install.py +++ b/install.py @@ -4056,9 +4056,20 @@ def fetch_manifest(plat: str) -> dict: try: return _validate_manifest(_fetch_json(url, MAX_MANIFEST_BYTES, "file list"), plat) except ValueError as exc: + words = f"the file list is not valid: {exc}" + if isinstance(exc.__cause__, (json.JSONDecodeError, UnicodeDecodeError)): + # The parser's "Expecting value: line 1 column 1" told no one + # anything, where the full screen names the usual cause. + words = ("what arrived is not a file list: a Wi-Fi sign-in page or a cut " + "download looks like this") EVENTS.emit("error", stage="manifest", reason="invalid", platform=plat, - message=f"the file list is not valid: {exc}") - print(f" The file list for {plat} is not valid: {exc}", file=sys.stderr) + message=words) + print(f" For {display_name(plat)}, {words}.", file=sys.stderr) + if words.startswith("what arrived"): + for line in _address_lines(url): + print(line, file=sys.stderr) + print(" Check the connection (sign in to the Wi-Fi if it asks), then run the " + "same command again.", file=sys.stderr) sys.exit(1) except urllib.error.HTTPError as exc: EVENTS.emit( diff --git a/install.sh b/install.sh index 960eda7b7..0c4263e04 100755 --- a/install.sh +++ b/install.sh @@ -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="ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0" +DEFAULT_INSTALL_SHA256="5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index eca4e3fc3..7470b4532 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1083967, - "sha256": "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0", + "size": 1084625, + "sha256": "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1137594, - "sha256": "433caee0f48148c8c25122e3cfa4a6d4577160a2b4ddcf2267415ebd21beb843" + "size": 1138252, + "sha256": "bc75188e7d115aa35066fba281deb2bec5ce07553ebdbe2d8444d0dbdfc5d41d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1083967, - "sha256": "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0", + "size": 1084625, + "sha256": "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1083967, - "sha256": "ca1c3c0d721787ee0fd9ab750839893d64f9313885dde6c1f88c5174d4281ce0", + "size": 1084625, + "sha256": "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index c42477b7d..16c784f2c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2404,6 +2404,22 @@ class FileListsAreRetried(unittest.TestCase): self.assertIs(result, missing) self.assertEqual(len(calls), 1) + def test_a_sign_in_page_is_named(self): + """A Wi-Fi sign-in page read as "invalid file list: Expecting value: + line 1 column 1 (char 0)", with no cause and no next step.""" + page = io.BytesIO(b"Sign in to Hotel WiFi") + page.headers = {} + err = io.StringIO() + with unittest.mock.patch.object( + install.urllib.request, "urlopen", + lambda url, timeout=None: contextlib.nullcontext(page)), \ + contextlib.redirect_stderr(err), self.assertRaises(SystemExit): + install.fetch_manifest("retroarch") + said = err.getvalue() + self.assertIn("a Wi-Fi sign-in page or a cut download looks like this", said) + self.assertIn("run the same command again", said) + self.assertNotIn("Expecting value", said) + def test_three_failures_end_with_advice(self): import urllib.error From 9aa26143ad4b16b78267af2f357bb174d11c9fe9 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:08:54 +0100 Subject: [PATCH 16/30] fix: name the old Python the bootstrap found --- install.sh | 21 +++++++++++++++++++-- tests/test_install.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 0c4263e04..0384b82f8 100755 --- a/install.sh +++ b/install.sh @@ -127,10 +127,18 @@ fi find_python() { PYTHON="" PYTHON_LACKING="" + PYTHON_OLD="" for command_name in python3 python; do command -v "$command_name" >/dev/null 2>&1 || continue - "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' \ - 2>/dev/null || continue + if ! "$command_name" -c 'import sys; raise SystemExit(sys.version_info < (3, 8))' \ + 2>/dev/null; then + found_version=$("$command_name" -c \ + 'import sys; print("%d.%d.%d" % tuple(sys.version_info[:3]))' 2>/dev/null) + if [ -n "$found_version" ]; then + PYTHON_OLD=${PYTHON_OLD:-"$command_name here is $found_version"} + fi + continue + fi if "$command_name" -c 'import ssl, unicodedata, zlib' 2>/dev/null; then PYTHON=$command_name return 0 @@ -184,6 +192,15 @@ if [ -z "$PYTHON" ] && [ -n "$PYTHON_LACKING" ]; then echo " (ssl, unicodedata or zlib). Run it with a full Python 3.8 or newer." >&2 exit 1 fi +if [ -z "$PYTHON" ] && [ -n "$PYTHON_OLD" ]; then + # Debian Buster ships 3.7: apt answers that python3 is already the newest + # version, and the same advice looped. + echo "Error: Python 3.8 or newer is required, and $PYTHON_OLD." >&2 + echo " Install a newer one: from this system's packages if they offer one, by" >&2 + echo " updating the system, or from https://www.python.org/downloads/." >&2 + echo " Then run this command again." >&2 + exit 1 +fi if [ -z "$PYTHON" ]; then echo "Error: Python 3.8 or newer is required." >&2 if [ -n "$install_command" ]; then diff --git a/tests/test_install.py b/tests/test_install.py index 16c784f2c..651cb6023 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2628,6 +2628,18 @@ class ABootstrapPythonCanDoHttps(unittest.TestCase): self.assertEqual(proc.returncode, 0, proc.stderr) self.assertIn("installer ran", proc.stdout) + def test_an_old_python_is_named_with_its_version(self): + """Debian Buster's python3 3.7 got "Install it with: sudo apt install + python3", which apt answers with "already the newest version".""" + root, fake_bin = self._bin() + (fake_bin / "python3").write_text( + '#!/bin/sh\ncase "$2" in *print*) echo 3.7.3 ;; esac\nexit 1\n') + proc = self._run(root, fake_bin) + self.assertEqual(proc.returncode, 1) + self.assertIn("Python 3.8 or newer is required, and python3 here is 3.7.3.", + proc.stderr) + self.assertNotIn("Install it with: sudo apt", proc.stderr) + def test_the_missing_module_is_named(self): root, fake_bin = self._bin() proc = self._run(root, fake_bin) From 9a1d7390e61918f3a0f364c058ea1ecb83cd049b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:10:08 +0100 Subject: [PATCH 17/30] fix: try every Python candidate in install.ps1 --- install.ps1 | 33 +++++++++++++++++++-------------- tests/test_install.py | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/install.ps1 b/install.ps1 index 36236c052..a1e01e61b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -68,18 +68,6 @@ $installer = $temporary } - # The py launcher first, then python3 and python. The Microsoft Store - # places a python.exe that only points at the Store: it fails the - # version check like a missing one. - $findPython = { - $found = Get-Command py -ErrorAction SilentlyContinue - if ($found) { return ,@($found, @("-3")) } - foreach ($name in @("python3", "python")) { - $found = Get-Command $name -ErrorAction SilentlyContinue - if ($found) { return ,@($found, @()) } - } - return $null - } $usable = { param($candidate) if (-not $candidate) { return $false } @@ -87,11 +75,28 @@ & $candidate[0].Source @arguments -c "import sys, ssl, unicodedata, zlib; raise SystemExit(sys.version_info < (3, 8))" 2>$null | Out-Null return ($LASTEXITCODE -eq 0) } + # The py launcher first, then python3 and python, the first that + # passes the check: a py launcher left with no usable runtime hid a + # Python 3.12 on PATH. The Microsoft Store places a python.exe that + # only points at the Store: it fails the check like a missing one. + $findPython = { + $candidates = @() + $found = Get-Command py -ErrorAction SilentlyContinue + if ($found) { $candidates += ,@($found, @("-3")) } + foreach ($name in @("python3", "python")) { + $found = Get-Command $name -ErrorAction SilentlyContinue + if ($found) { $candidates += ,@($found, @()) } + } + foreach ($candidate in $candidates) { + if (& $usable $candidate) { return ,$candidate } + } + return $null + } $needPython = "Python 3.8 or newer is required. Install it with " + "'winget install Python.Python.3.12' or from https://www.python.org/downloads/ " + "(tick 'Add python.exe to PATH'), then open a new PowerShell window and run this command again." $candidate = & $findPython - if (-not (& $usable $candidate)) { + if (-not $candidate) { # winget ships with Windows 10 and 11: at a console the bootstrap # offers to run the install the message names, then carries on. $winget = Get-Command winget -ErrorAction SilentlyContinue @@ -109,7 +114,7 @@ $candidate = & $findPython } } - if (-not (& $usable $candidate)) { throw $needPython } + if (-not $candidate) { throw $needPython } } $python = $candidate[0] $launcher = $candidate[1] diff --git a/tests/test_install.py b/tests/test_install.py index 651cb6023..dea417910 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2694,6 +2694,29 @@ class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase): self.assertFalse((fake_bin / "python3").exists()) +@unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell") +class APyLauncherWithoutARuntimeIsPassed(unittest.TestCase): + """findPython returned the py launcher as soon as it existed: one left + with no usable runtime hid a Python 3.12 on PATH, and the bootstrap + offered a second install or gave up.""" + + def test_python_on_path_runs_the_installer(self): + folder = Path(_tempdir(self)) + shutil.copy(REPO_ROOT / "install.ps1", folder / "install.ps1") + (folder / "install.py").write_text("print('installer ran')\n") + fake_bin = folder / "bin" + fake_bin.mkdir() + (fake_bin / "py").write_text("#!/bin/sh\necho 'No installed Python found!' >&2\nexit 1\n") + (fake_bin / "py").chmod(0o755) + (fake_bin / "python3").symlink_to(sys.executable) + proc = subprocess.run( + [shutil.which("pwsh"), "-NoProfile", "-File", str(folder / "install.ps1")], + env={**os.environ, "PATH": f"{fake_bin}{os.pathsep}/usr/bin/nonexistent"}, + capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("installer ran", proc.stdout) + + class ThePowerShellWrapperPassesTheStatusOn(unittest.TestCase): """Every non-zero exit became a red "installer failed" exception and code 1: a --check that found work read as a crash, 2 and 130 were lost.""" From 505cfc044b7e12a55cd6ee436c19fa0e5e3fb9dd Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:10:51 +0100 Subject: [PATCH 18/30] fix: say what a platform's own check compares --- install.ps1 | 2 +- install.py | 5 ++++- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 6 ++++-- tests/test_install_tui.py | 3 ++- wiki/installer.md | 4 ++-- 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/install.ps1 b/install.ps1 index a1e01e61b..0108187fd 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11" + $defaultInstallSha256 = "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a345ace17..0482c2aa5 100644 --- a/install.py +++ b/install.py @@ -1762,7 +1762,10 @@ _VERSION_TEXT = re.compile(r"[0-9A-Za-z][0-9A-Za-z._+-]{0,39}") # What generate_pack.py wrote as every list's version until it recorded the # platform's own: not a version, so a list holding it names none. _NO_LIST_VERSION = "1.0" -_MD5_CHECK = "{name}'s own BIOS check may report some files as wrong though they work." +# What the platform's own check does, not a verdict on it: it compares the +# files with the list of its own version, and no run proved them working. +_MD5_CHECK = ("{name}'s own BIOS check reads the list of the version it runs, and " + "may show some files as not matching it.") def _version_line(path: Path) -> str: diff --git a/install.sh b/install.sh index 0384b82f8..3246396a6 100755 --- a/install.sh +++ b/install.sh @@ -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="5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11" +DEFAULT_INSTALL_SHA256="209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 7470b4532..374f7c557 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1084625, - "sha256": "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11", + "size": 1084827, + "sha256": "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1138252, - "sha256": "bc75188e7d115aa35066fba281deb2bec5ce07553ebdbe2d8444d0dbdfc5d41d" + "size": 1138454, + "sha256": "5748eb969c4bebfca1a560e2c4ffcd8507e20b12ea36c4400386e69a260d330d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1084625, - "sha256": "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11", + "size": 1084827, + "sha256": "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1084625, - "sha256": "5255181d018ce7020a619c6a31419da0dde862626bc57347cd0299a099e64f11", + "size": 1084827, + "sha256": "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index dea417910..d0e9260df 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -6132,7 +6132,8 @@ class TheListVersionIsComparedWithTheSystems(unittest.TestCase): self.assertEqual((listed, installed), ("43.1", "41")) self.assertEqual( text, "This list was built for Batocera 43.1, this machine runs 41. " - "Batocera's own BIOS check may report some files as wrong though they work.") + "Batocera's own BIOS check reads the list of the version it runs, and " + "may show some files as not matching it.") self.assertIsNone(self.note("batocera", "41")) def test_recalbox_reads_only_a_release(self): @@ -6243,7 +6244,8 @@ class TheListVersionIsSaidBeforeAndAfter(unittest.TestCase): ) SAID = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0. " - "RetroBat's own BIOS check may report some files as wrong though they work.") + "RetroBat's own BIOS check reads the list of the version it runs, and " + "may show some files as not matching it.") def test_line_mode_says_it_before_the_install_and_with_the_result(self): proc = self.run_installer() diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 03bc22ffd..318453fff 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -10920,7 +10920,8 @@ class AuditNineteenAndTwelve(_CardFixtures, unittest.TestCase): """code_audit_19 P2-1, tui_audit_12 P2-4: line mode said it, the screen dropped the version event.""" said = ("This list was built for RetroBat 8.3.1, this machine runs 8.2.0.1. " - "RetroBat's own BIOS check may report some files as wrong though they work.") + "RetroBat's own BIOS check reads the list of the version it runs, and " + "may show some files as not matching it.") state = fresh_state() state.stack = ["setup"] with mock.patch.object(install.Path, "exists", return_value=True): diff --git a/wiki/installer.md b/wiki/installer.md index d7d04be39..cf7f3f600 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -355,8 +355,8 @@ The file list names the version of the platform it was built for (Batocera 43.1, RetroBat 8.3.1). Where the system writes its own version in a file, the installer reads it, and when the two differ it says so before the install and again with the result: "This list was built for Batocera 43.1, this machine -runs 41. Batocera's own BIOS check may report some files as wrong though they -work." The install goes on. The files read are +runs 41. Batocera's own BIOS check reads the list of the version it runs, and +may show some files as not matching it." The install goes on. The files read are `/usr/share/batocera/batocera.version` on Batocera, `/recalbox/recalbox.version` on Recalbox (a release, not a branch build), `system\version.info` beside the `bios` folder of RetroBat, and `VERSION_ID` in `/etc/os-release` on Lakka and From 860fa575d9f90563dff5572ae3725049f0d0778d Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:11:29 +0100 Subject: [PATCH 19/30] feat: link each emulator page under a needed file --- install.ps1 | 2 +- install.py | 11 +++++++++-- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 10 ++++++++++ wiki/installer.md | 3 ++- 6 files changed, 31 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 0108187fd..1c80b3ab1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c" + $defaultInstallSha256 = "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 0482c2aa5..00d54382b 100644 --- a/install.py +++ b/install.py @@ -6586,17 +6586,24 @@ def _needed_lines(entries: list[dict], heading: str, every: bool = False) -> lis count = sum(len(names) for names, _cores in by_system.values()) lines = [f" {heading} ({_plural(count, 'file')}):"] systems = sorted(by_system) - for system in systems if every else systems[:8]: + shown = systems if every else systems[:8] + for system in shown: names, cores = by_system[system] readers = f" ({', '.join(sorted(cores))})" if cores else "" lines.append(f" {system}{readers}: {', '.join(sorted(names))}") + # Each emulator's own page, one click away: the index alone sent the + # user searching several hundred pages for theforceengine. + lines += [f" {SITE_URL}emulators/{core}/" for core in sorted(cores)] if not every and len(systems) > 8: # A file several systems share is a group of its own, counted as one. unit = "groups" if any(", " in system for system in systems) else "systems" more = len(systems) - 8 lines.append(f" ... and {more} more {unit if more > 1 else unit[:-1]} " "(--check --verbose lists them all)") - lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/") + if all(by_system[system][1] for system in shown): + lines.append(" Each emulator's page, linked above, says what these files are.") + else: + lines.append(f" Each emulator's page says what these files are: {SITE_URL}emulators/") return lines diff --git a/install.sh b/install.sh index 3246396a6..f6681af45 100755 --- a/install.sh +++ b/install.sh @@ -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="209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c" +DEFAULT_INSTALL_SHA256="3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 374f7c557..4c9547932 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1084827, - "sha256": "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c", + "size": 1085228, + "sha256": "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1138454, - "sha256": "5748eb969c4bebfca1a560e2c4ffcd8507e20b12ea36c4400386e69a260d330d" + "size": 1138855, + "sha256": "373aebd78e83ec88a29e4a14a13c6be8f77d40c2b2f8834543bb40a8c254ec95" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1084827, - "sha256": "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c", + "size": 1085228, + "sha256": "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1084827, - "sha256": "209c7614ad968cfc1cf30e69048acfb306cf5f8e3b14e3b00a6339699419e08c", + "size": 1085228, + "sha256": "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index d0e9260df..6fb058ad4 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4411,6 +4411,16 @@ class WhatTheCollectionLacksSaysForWhom(unittest.TestCase): self.assertIn(" dark-forces (theforceengine): DARK.GOB, SOUNDS.GOB", lines) self.assertIn(" sonic-mania (sonicmania): Data.rsdk", lines) self.assertIn("(3 files)", lines[0]) + # The page of each emulator under its group: the index alone left a + # search over several hundred pages. + self.assertEqual(lines[lines.index(" dark-forces (theforceengine): DARK.GOB, " + "SOUNDS.GOB") + 1], + f" {install.SITE_URL}emulators/theforceengine/") + self.assertIn(f" {install.SITE_URL}emulators/sonicmania/", lines) + self.assertNotIn(f"{install.SITE_URL}emulators/\n", "\n".join(lines) + "\n") + + def test_a_group_with_no_emulator_keeps_the_index(self): + lines = install._needed_lines([{"dest": "x.bin", "system": "x"}], "Needed") self.assertIn(f"{install.SITE_URL}emulators/", lines[-1]) diff --git a/wiki/installer.md b/wiki/installer.md index cf7f3f600..71a289f59 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -507,7 +507,8 @@ BIOS folder: /userdata/bios 426 files were not installed: the collection does not hold them, or its copy fails their check. Needed to start an emulator (1 file): capcom-cps3 (fbneo): sfiii2a.zip - Each emulator's page says what these files are: https://abdess.github.io/retrobios/emulators/ + https://abdess.github.io/retrobios/emulators/fbneo/ + Each emulator's page, linked above, says what these files are. 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. From 0d5911f61422d6d3f574fee47e6f347c535b17cd Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:12:03 +0100 Subject: [PATCH 20/30] fix: name only the platform's cores as readers --- install.ps1 | 2 +- install.py | 27 ++++++++++++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 22 ++++++++++++++++++++++ 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 1c80b3ab1..d887d5509 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf" + $defaultInstallSha256 = "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 00d54382b..c31c9288f 100644 --- a/install.py +++ b/install.py @@ -11832,15 +11832,36 @@ def _copies(entry: dict) -> list[dict]: *entry.get("declared_choices", [])] +# id(manifest) -> (manifest, the cores its entries name), read once per list. +_LISTED_CORES: dict[int, tuple[dict, frozenset]] = {} + + +def _listed_cores(manifest: dict) -> frozenset: + """Every core a copy of the list's entries names: the platform's own. + + system_cores reaches every profile, the target rule needing the cores off + the platform too, and the "read by" line named Recalbox's mame0258 on + Batocera and BizHawk on RetroArch. + """ + cached = _LISTED_CORES.get(id(manifest)) + if cached is None or cached[0] is not manifest: + cores = frozenset( + core for entry in [*manifest.get("files", []), *manifest.get("omitted_files", [])] + for copy in _copies(entry) for core in copy.get("cores") or []) + cached = _LISTED_CORES[id(manifest)] = (manifest, cores) + return cached[1] + + def readers(entry: dict, manifest: dict) -> list[str]: - """The cores that open an entry: as its copies name them, else every core - the platform runs for its systems.""" + """The cores that open an entry: as its copies name them, else the cores + of its systems that the platform's own list names.""" named = sorted({core for copy in _copies(entry) for core in copy.get("cores") or []}) if named: return named system_cores = manifest.get("system_cores") or {} + listed = _listed_cores(manifest) return sorted({core for system in entry_systems(entry) - for core in system_cores.get(system, [])}) + for core in system_cores.get(system, []) if core in listed}) def opened_line(entry: dict, manifest: dict, bios_path: Path) -> str: diff --git a/install.sh b/install.sh index f6681af45..86584bdd3 100755 --- a/install.sh +++ b/install.sh @@ -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="3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf" +DEFAULT_INSTALL_SHA256="d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 4c9547932..b34898f37 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1085228, - "sha256": "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf", + "size": 1086137, + "sha256": "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1138855, - "sha256": "373aebd78e83ec88a29e4a14a13c6be8f77d40c2b2f8834543bb40a8c254ec95" + "size": 1139764, + "sha256": "f9b58890f89971a8c0b9850ece3d132955cbc4823230e59a9224f04305055fba" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1085228, - "sha256": "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf", + "size": 1086137, + "sha256": "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1085228, - "sha256": "3eb409c23f487f8b150c02591a33dbd2ea09343516daa388524c1dc66f860ebf", + "size": 1086137, + "sha256": "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index 6fb058ad4..f155ebaeb 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4397,6 +4397,28 @@ class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase): self.assertIn("Stopped", out) +class ReadersAreThePlatformsCores(unittest.TestCase): + """--check said bbcb.zip is "read by clk, mame, mame0258, mame0278" on + Batocera: system_cores reaches every profile, Recalbox's builds too.""" + + def test_an_off_platform_core_is_not_named(self): + manifest = { + "system_cores": {"bbc": ["clk", "mame", "mame0258"]}, + "files": [{"dest": "bbcb.zip", "systems": ["bbc"], "cores": None}, + {"dest": "other.rom", "systems": ["x"], "cores": ["clk", "mame"]}], + } + self.assertEqual(install.readers(manifest["files"][0], manifest), ["clk", "mame"]) + + def test_on_the_real_lists(self): + for plat, dest, absent in (("batocera", "bbcb.zip", "mame0258"), + ("retroarch", "prboom.wad", "bizhawk")): + manifest = json.loads((REPO_ROOT / "install" / f"{plat}.json").read_text()) + [entry] = [e for e in manifest["files"] + manifest["omitted_files"] + if e["dest"] == dest] + with self.subTest(platform=plat): + self.assertNotIn(absent, install.readers(entry, manifest)) + + class WhatTheCollectionLacksSaysForWhom(unittest.TestCase): """Required files the installer cannot supply were listed by bare name: DARK.GOB, Data.rsdk, ... and 69 more, with no system and no emulator.""" From bc6bd590f1ba0c71f17aeeffa44c4bdc0007d715 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:13:04 +0100 Subject: [PATCH 21/30] fix: keep the dry-run total in view --- install.ps1 | 2 +- install.py | 34 ++++++++++++++++++++++++---------- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 20 deletions(-) diff --git a/install.ps1 b/install.ps1 index d887d5509..5b2422aed 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b" + $defaultInstallSha256 = "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index c31c9288f..83b327f20 100644 --- a/install.py +++ b/install.py @@ -6625,6 +6625,8 @@ def installer_id() -> str: PLAN_FILES_PER_SYSTEM = 8 PLAN_LARGEST_SYSTEMS = 10 PLAN_FRAME_LINES = 6 +# Past this many systems a group sharing files is named by its first two. +PLAN_GROUP_TITLES = 3 # Answers that stop: every menu before this one taught q as quit. _REFUSALS = ("n", "q", "quit", "exit", "stop", "abort") @@ -6692,22 +6694,34 @@ def _print_plan( if not parts: print(f"\n Nothing to {verb}.") by_system: dict[str, list[dict]] = {} + titles: dict[str, str] = {} for entry in to_download: - system = (", ".join(system_title(name, manifest) for name in entry_systems(entry)) - or OTHER_TITLE) + names = [system_title(name, manifest) for name in entry_systems(entry)] + system = ", ".join(names) or OTHER_TITLE by_system.setdefault(system, []).append(entry) + # Batocera's group of systems sharing MAME's device ROMs was one line + # of 732 characters, ten rows on an 80-column terminal. + titles[system] = (system if verbose or len(names) <= PLAN_GROUP_TITLES + else f"{', '.join(names[:2])} and {len(names) - 2} more systems") shown = None if verbose else PLAN_FILES_PER_SYSTEM - listing: list[str] = [] + # (line, its text without styles), the second measuring the rows it takes. + listing: list[tuple[str, str]] = [] for system in sorted(by_system): entries = sorted(by_system[system], key=lambda e: e["dest"]) - listing.append(f" {term.bold(system)} {_sized(entries)}") - listing += [f" {entry['dest']} {term.dim(format_size(entry.get('size', 0)))}" + listing.append((f" {term.bold(titles[system])} {_sized(entries)}", + f" {titles[system]} {_sized(entries)}")) + listing += [(f" {entry['dest']} {term.dim(format_size(entry.get('size', 0)))}", + f" {entry['dest']} {format_size(entry.get('size', 0))}") for entry in entries[:shown]] if shown is not None and len(entries) > shown: - listing.append(f" ... and {len(entries) - shown} more") - screen = terminal_size()[1] - if verbose or len(listing) + len(trees) + PLAN_FRAME_LINES <= screen: - for line in listing: + more = f" ... and {len(entries) - shown} more" + listing.append((more, more)) + columns, screen = terminal_size() + # A line wider than the terminal wraps: counted as one, the total the + # listing must leave in view scrolled out of sight. + rows = sum(-(-max(1, cell_width(plain)) // max(1, columns)) for _line, plain in listing) + if verbose or rows + len(trees) + PLAN_FRAME_LINES <= screen: + for line, _plain in listing: print(line) else: largest = sorted(by_system, key=lambda s: (-_bytes(by_system[s]), s)) @@ -6719,7 +6733,7 @@ def _print_plan( else _plural(len(largest), 'system')) print(f" {groups}, the largest first:") for system in largest[:PLAN_LARGEST_SYSTEMS]: - print(f" {term.bold(system)} {_sized(by_system[system])}") + print(f" {term.bold(titles[system])} {_sized(by_system[system])}") if len(largest) > PLAN_LARGEST_SYSTEMS: # Counted as the line above counts: the groups, when it names them. unit = "group" if "groups" in groups else "system" diff --git a/install.sh b/install.sh index 86584bdd3..b66e4eb87 100755 --- a/install.sh +++ b/install.sh @@ -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="d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b" +DEFAULT_INSTALL_SHA256="e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index b34898f37..3084dc556 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1086137, - "sha256": "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b", + "size": 1087102, + "sha256": "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1139764, - "sha256": "f9b58890f89971a8c0b9850ece3d132955cbc4823230e59a9224f04305055fba" + "size": 1140729, + "sha256": "5091f7a52ce8e2db24d3afa23a2d3e4a035fd3166d0a0389fb207dcc07fa0d3f" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1086137, - "sha256": "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b", + "size": 1087102, + "sha256": "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1086137, - "sha256": "d3bfabb88e93d8ddf991965cd69929e73b0a254550f8f4c86a11ad34fe710a0b", + "size": 1087102, + "sha256": "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index f155ebaeb..c1036f4a5 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4397,6 +4397,37 @@ class AQuestionLeftWithCtrlDOrCtrlCIsAStop(unittest.TestCase): self.assertIn("Stopped", out) +class APlanGroupFitsItsLine(unittest.TestCase): + """Batocera's group of systems sharing MAME's device ROMs was one plan + line of 732 characters: counted as one row, it wrapped to ten and the + total scrolled out of sight.""" + + def _plan(self, files: list[dict], size: tuple[int, int], verbose: bool = False) -> str: + out = io.StringIO() + with unittest.mock.patch.object(install.shutil, "get_terminal_size", + return_value=os.terminal_size(size)), \ + contextlib.redirect_stdout(out): + install._print_plan(files, Path("dest"), install.Terminal(io.StringIO()), + verbose=verbose) + return out.getvalue() + + def test_a_wide_group_is_named_by_its_first_two(self): + shared = [f"system-number-{n:02d}" for n in range(40)] + text = self._plan([{"dest": "dev.rom", "size": 1, "systems": shared}], (80, 40)) + self.assertIn("system-number-00, system-number-01 and 38 more systems", text) + self.assertLess(max(map(len, text.splitlines())), 80) + self.assertIn("system-number-39", self._plan( + [{"dest": "dev.rom", "size": 1, "systems": shared}], (80, 40), verbose=True)) + + def test_wrapped_rows_count_against_the_screen(self): + # Six lines that each wrap to three rows on a 20-column terminal of + # 20 rows: 18 rows of listing, past what the screen leaves it. + files = [{"dest": f"{'x' * 50}{n}.bin", "size": 1, "systems": [f"s{n}"]} + for n in range(3)] + text = self._plan(files, (20, 20)) + self.assertIn("the largest first", text) + + class ReadersAreThePlatformsCores(unittest.TestCase): """--check said bbcb.zip is "read by clk, mame, mame0258, mame0278" on Batocera: system_cores reaches every profile, Recalbox's builds too.""" From 4dd43aa627c27eaf22ce7efec587ab5b77f676eb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:18:39 +0100 Subject: [PATCH 22/30] feat: set up Termux storage from the one-liner --- README.md | 2 +- install.sh | 21 ++++++++++++++++++ platforms/_guides.yml | 4 ++-- scripts/generate_readme.py | 2 +- scripts/generate_site.py | 2 +- tests/test_install.py | 45 ++++++++++++++++++++++++++++++++++++++ wiki/first-install.md | 5 ++--- wiki/installer.md | 7 +++--- 8 files changed, 77 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0df5cf0f0..d9ed0629a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh # Windows (PowerShell) irm https://abdess.github.io/retrobios/i.ps1 | iex -# Android (Termux, after pkg install python and termux-setup-storage) +# Android (Termux: it installs Python and asks for storage access) curl -fsSL https://abdess.github.io/retrobios/i | sh # SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Onion, spruceOS or MinUI card diff --git a/install.sh b/install.sh index b66e4eb87..5f9e24b58 100755 --- a/install.sh +++ b/install.sh @@ -212,6 +212,27 @@ if [ -z "$PYTHON" ]; then exit 1 fi +# Termux reaches shared storage, where RetroArch keeps its system folder, +# once termux-setup-storage has run: the installer told the user to run it +# and start again. At a terminal the bootstrap runs it here. It only asks +# the Termux app, which shows Android's permission dialog and creates +# ~/storage once allowed: the wait gives the user time to answer. +case "${TERMUX_VERSION:-}:${PREFIX:-}" in + ?*:*|*com.termux*) + if [ ! -d "$HOME/storage" ] && command -v termux-setup-storage >/dev/null 2>&1 \ + && (: /dev/null; then + echo "Android asks for the storage permission: allow it, so that the files reach" >/dev/tty + echo "the RetroArch folder." >/dev/tty + termux-setup-storage /dev/tty 2>&1 || true + waited=0 + while [ ! -d "$HOME/storage" ] && [ "$waited" -lt 60 ]; do + sleep 1 + waited=$((waited + 1)) + done + fi + ;; +esac + # Piped from curl, standard input is this script, not the terminal: the # installer would skip every question it can ask. Hand it the terminal when # there is one. diff --git a/platforms/_guides.yml b/platforms/_guides.yml index 2a886327d..aa6bf2dd1 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -116,8 +116,8 @@ terminals: android: label: Android (Termux) how: >- - Install **Termux**, a terminal app. Run `pkg install python` and `termux-setup-storage` - once. Then paste the line. Press Enter. + Install **Termux**, a terminal app. Paste the line. Press Enter. Accept the Python + install and allow the storage access Android asks for. shell: sh device: label: On the device diff --git a/scripts/generate_readme.py b/scripts/generate_readme.py index aba4e9c9f..4854da14d 100644 --- a/scripts/generate_readme.py +++ b/scripts/generate_readme.py @@ -575,7 +575,7 @@ def generate_readme(db: dict, platforms_dir: str, db_path: str = "database.json" "# Windows (PowerShell)", install_command("powershell"), "", - "# Android (Termux, after pkg install python and termux-setup-storage)", + "# Android (Termux: it installs Python and asks for storage access)", install_command("sh"), "", "# SD card mounted on a PC: a muOS, KNULLI, ROCKNIX, Onion, spruceOS or MinUI card", diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 2a763163b..570a8115f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -8471,7 +8471,7 @@ def install_commands(args: str = "", indent: str = "", for label, shell in ( ("Linux, macOS, Steam Deck:", "sh"), ("Windows (PowerShell):", "powershell"), - ("Android (Termux, after `pkg install python` and `termux-setup-storage`):", "sh"), + ("Android (Termux, the command installs Python and asks for storage access):", "sh"), ): lines += [f"{indent}{label}", "", *fence(shell, install_command(shell, args), indent), ""] return lines diff --git a/tests/test_install.py b/tests/test_install.py index c1036f4a5..ab9d932b8 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2694,6 +2694,51 @@ class WindowsWithoutPythonIsOfferedWinget(unittest.TestCase): self.assertFalse((fake_bin / "python3").exists()) +@unittest.skipUnless(shutil.which("script"), "needs a terminal") +class TermuxStorageIsSetUpByTheBootstrap(unittest.TestCase): + """The installer told an Android user to run termux-setup-storage and + start the whole run over: the bootstrap runs it and waits for ~/storage.""" + + def _run(self, storage_exists: bool) -> tuple[subprocess.CompletedProcess, Path]: + root = Path(_tempdir(self)) + shutil.copy(REPO_ROOT / "install.sh", root / "install.sh") + (root / "install.py").write_text("print('installer ran')\n") + home = root / "home" + home.mkdir() + if storage_exists: + (home / "storage").mkdir() + fake_bin = root / "bin" + fake_bin.mkdir() + for tool in ("sed", "tr", "dirname", "cat", "awk", "uname", "sleep", "mkdir"): + found = shutil.which(tool) + if found: + (fake_bin / tool).symlink_to(found) + (fake_bin / "python3").symlink_to(sys.executable) + # The app creates ~/storage once Android's dialog is answered. + (fake_bin / "termux-setup-storage").write_text( + f'#!/bin/sh\necho ran >> "{root}/setup.log"\nmkdir "$HOME/storage"\n') + (fake_bin / "termux-setup-storage").chmod(0o755) + command = (f"PATH={shlex.quote(str(fake_bin))} HOME={shlex.quote(str(home))} " + f"TERMUX_VERSION=0.118 /bin/sh {shlex.quote(str(root / 'install.sh'))}") + proc = subprocess.run( + [shutil.which("script"), "-qec", command, "/dev/null"], + capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL) + return proc, root + + def test_it_runs_once_then_the_installer(self): + proc, root = self._run(storage_exists=False) + self.assertEqual(proc.returncode, 0, proc.stdout) + self.assertEqual((root / "setup.log").read_text(), "ran\n") + self.assertIn("allow it", proc.stdout) + self.assertIn("installer ran", proc.stdout) + + def test_a_set_up_storage_is_left_alone(self): + proc, root = self._run(storage_exists=True) + self.assertEqual(proc.returncode, 0, proc.stdout) + self.assertFalse((root / "setup.log").exists()) + self.assertIn("installer ran", proc.stdout) + + @unittest.skipUnless(shutil.which("pwsh"), "needs PowerShell") class APyLauncherWithoutARuntimeIsPassed(unittest.TestCase): """findPython returned the py launcher as soon as it existed: one left diff --git a/wiki/first-install.md b/wiki/first-install.md index fab2d9241..7610621ee 100644 --- a/wiki/first-install.md +++ b/wiki/first-install.md @@ -53,11 +53,10 @@ curl -fsSL https://abdess.github.io/retrobios/i | sh -Install **Termux**, a terminal app, and run these lines. +Install **Termux**, a terminal app, and run this line. It installs Python +and asks Android for storage access: allow both. ```sh title="Terminal" -pkg install python -termux-setup-storage curl -fsSL https://abdess.github.io/retrobios/i | sh ``` diff --git a/wiki/installer.md b/wiki/installer.md index 71a289f59..b69d2c7c8 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1397,11 +1397,12 @@ writable (Android 10 and earlier), it is a standalone copy target. Under scoped storage the two RetroArch config paths are unreadable from Termux on Android 11 and later, so on current devices the folders are what answers. -Running `termux-setup-storage` once is what makes the shared one reachable: +Running `termux-setup-storage` once is what makes the shared one reachable. +The one-line command runs it when `~/storage` is missing, then waits up to a +minute for the permission Android asks for, as it offers `pkg install python` +when Python is missing: ```bash -pkg install python -termux-setup-storage curl -fsSL https://abdess.github.io/retrobios/i | sh ``` From 3d694c7d70c07ada8c2f0a24c0e21e256f2255bc Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:20:08 +0100 Subject: [PATCH 23/30] feat: tell RomM users to scan after an install --- install.ps1 | 2 +- install.py | 12 ++++++++++++ install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 14 ++++++++++++++ tests/test_install_tui.py | 14 ++++++++++++++ wiki/installer.md | 5 +++++ 7 files changed, 55 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 5b2422aed..b9df7e509 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2" + $defaultInstallSha256 = "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 83b327f20..857d43831 100644 --- a/install.py +++ b/install.py @@ -15519,6 +15519,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: print(f" {shown_path(bios_path)} stays: {exc.strerror}", file=sys.stderr) bulk_lines += hint + if plat_name == "romm" and progress.installed: + bulk_lines.append(ROMM_SCAN_WORDS) total_downloaded += progress.installed # The end line tells BIOS files from game data, as the menu # counted them, over the files that landed: matched by the end @@ -21830,6 +21832,8 @@ def tui_outcome(state: TuiState) -> tuple[str, list[str]]: # Said again with the result, as line mode ends: the system's own # BIOS check may mark the files just put in place. lines.append(state.version) + if state.platform == "romm" and installed: + lines.append(ROMM_SCAN_WORDS) fallback = (state.games or {}).get("fallback") or [] if state.for_games else [] if fallback: # Named with the result, as line mode ends: they got every BIOS of @@ -23023,6 +23027,14 @@ def _closing_lines(state: TuiState) -> list[str]: TUI_QUIT_WORDS = "RetroBIOS: quit. Nothing was installed." +# RomM serves the firmware rows its library scan records (rommapp/romm 5.4.0, +# backend/endpoints/firmware.py:148,175 read db_firmware_handler.list_firmware), +# and its folder watcher is off unless ENABLE_RESCAN_ON_FILESYSTEM_CHANGE is +# set (backend/config/__init__.py:284, backend/watcher.py:106): files put in +# place stay unseen by the player until a scan. +ROMM_SCAN_WORDS = ("RomM lists the firmware its library scan has recorded: start a scan " + "in RomM so that its player finds these files.") + # The result's remedy for the save files left out, as the terminal says it # once the screen is gone: Options are not there to choose from # (code_audit_20 P3-4). Line mode's words. diff --git a/install.sh b/install.sh index 5f9e24b58..c68d70d28 100755 --- a/install.sh +++ b/install.sh @@ -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="e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2" +DEFAULT_INSTALL_SHA256="38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 3084dc556..64455b830 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1087102, - "sha256": "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2", + "size": 1087817, + "sha256": "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1140729, - "sha256": "5091f7a52ce8e2db24d3afa23a2d3e4a035fd3166d0a0389fb207dcc07fa0d3f" + "size": 1141444, + "sha256": "0a1054f931b20cb3dc4534304008aa3064af1f4ca6a9bab8f9659b506da4642d" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1087102, - "sha256": "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2", + "size": 1087817, + "sha256": "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1087102, - "sha256": "e7df99e3ac458c7717bd2ca415bc7d3d8701545652cd303ed19de67077531ab2", + "size": 1087817, + "sha256": "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index ab9d932b8..d87715ade 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4048,6 +4048,20 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess ) +class RomMIsToldToScan(unittest.TestCase, _LoopbackRun): + """RomM serves the firmware its library scan records, and the run ended + on "installed" with no word of the scan the player waits on.""" + + def test_the_end_of_an_install_names_the_scan(self): + env = self.serve(self) + dest = self.home / "library" / "bios" + dest.mkdir(parents=True) + proc = _install_run(env, "--platform", "romm", "--dest", str(dest), + "--system", "atari-lynx", "--no-input") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn(install.ROMM_SCAN_WORDS, " ".join(proc.stdout.split())) + + class AnUnknownNameIsAnEvent(unittest.TestCase): """--events carried start then end with code 1 for an unknown platform, target or system: the reason was on stderr only, which readers ignore.""" diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 318453fff..18983003d 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -4591,6 +4591,20 @@ def _installed_state(dest: Path, code: int = 0, failed: list | None = None) -> i return state +class TheRomMResultNamesTheScan(unittest.TestCase): + """RomM's player sees firmware its scan recorded: the result said + "installed" and left the scan unsaid.""" + + def test_the_result_and_its_farewell_say_it(self): + state = _installed_state(SYSTEM_DIR) + state.platform = "romm" + _title, lines = install.tui_outcome(state) + self.assertIn(install.ROMM_SCAN_WORDS, lines) + self.assertIn(install.ROMM_SCAN_WORDS, install._farewell_lines(state)) + state.platform = "retroarch" + self.assertNotIn(install.ROMM_SCAN_WORDS, install.tui_outcome(state)[1]) + + class AppOnTheCard(unittest.TestCase): """The RetroBIOS app offered on the result screen and in the options.""" diff --git a/wiki/installer.md b/wiki/installer.md index b69d2c7c8..2ac413ad0 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -1354,6 +1354,11 @@ folder, whose parent is the library. Run from the host of a container, where library laid out as `{platform}/roms` with no template found is refused rather than filled in the wrong place, and the message gives the command. +RomM's player reads the firmware its library scan has recorded, and RomM +watches the library folder only when `ENABLE_RESCAN_ON_FILESYSTEM_CHANGE` is +set. An install that put files in place ends by saying so: start a scan in +RomM so that the player finds them. + ## Android Android reports itself as Linux, so the runtime is named from its own From fd36670532658294a72708bb41d77d604fd68bc6 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:20:44 +0100 Subject: [PATCH 24/30] fix: name --from-pack and the packs beside a typo --- install.ps1 | 2 +- install.py | 10 +++++++++- install.sh | 2 +- install/launchers/index.json | 16 ++++++++-------- tests/test_install.py | 14 ++++++++++++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/install.ps1 b/install.ps1 index b9df7e509..d89008099 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd" + $defaultInstallSha256 = "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 857d43831..467efbff8 100644 --- a/install.py +++ b/install.py @@ -10888,7 +10888,15 @@ class PackSource: elif path.is_file(): given.append(path) else: - raise ValueError(f"{path} does not exist") + # "X does not exist" named neither the option nor the packs + # beside it, where a typo in the name is the usual cause. + near = sorted(p.name for p in path.parent.glob("*_BIOS_Pack*.zip") + if p.is_file()) if path.parent.is_dir() else [] + beside = (f" The packs in {path.parent}: {', '.join(near[:3])}" + + (f" and {len(near) - 3} more" if len(near) > 3 else "") + "." + if near else "") + raise ValueError(f"{path} does not exist: --from-pack names a pack ZIP, " + f"its parts or the folder holding them.{beside}") if not given: raise ValueError(f"no pack ZIP in {', '.join(map(str, paths))}") packs: dict[str, list[Path]] = {} diff --git a/install.sh b/install.sh index c68d70d28..01f821f5d 100755 --- a/install.sh +++ b/install.sh @@ -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="38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd" +DEFAULT_INSTALL_SHA256="37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 64455b830..9dc8fb5bf 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1087817, - "sha256": "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd", + "size": 1088455, + "sha256": "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1141444, - "sha256": "0a1054f931b20cb3dc4534304008aa3064af1f4ca6a9bab8f9659b506da4642d" + "size": 1142082, + "sha256": "cce8bbfd699e23eb524244c01141d0a3316a3c3b769c54d3367e924b8d428ed2" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1087817, - "sha256": "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd", + "size": 1088455, + "sha256": "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1087817, - "sha256": "38182a427e302ef13f994bad06ec51b8270f1b5949d9c1f9663b46437fdcd1fd", + "size": 1088455, + "sha256": "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e", "executable": false }, { diff --git a/tests/test_install.py b/tests/test_install.py index d87715ade..a719d965c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4048,6 +4048,20 @@ def _install_run(env: dict, *argv: str, **kwargs) -> subprocess.CompletedProcess ) +class AMissingPackNamesTheOptionAndThePacksBeside(unittest.TestCase): + """A typo in --from-pack said "Error: X does not exist": not the option, + not the pack sitting next to the name typed.""" + + def test_the_pack_beside_is_named(self): + folder = Path(_tempdir(self)) + (folder / "RetroArch_BIOS_Pack.zip").write_bytes(b"") + with self.assertRaises(ValueError) as raised: + install.PackSource._resolve([folder / "RetroArch_BIOS_Pak.zip"]) + said = str(raised.exception) + self.assertIn("--from-pack names a pack ZIP", said) + self.assertIn(f"The packs in {folder}: RetroArch_BIOS_Pack.zip.", said) + + class RomMIsToldToScan(unittest.TestCase, _LoopbackRun): """RomM serves the firmware its library scan records, and the run ended on "installed" with no word of the scan the player waits on.""" From 8d86b7b4f3895c325153fe22ed1daa38c29bf133 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:28:36 +0100 Subject: [PATCH 25/30] fix: expect code 2 for an unknown system --- tests/test_install_selection.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_install_selection.py b/tests/test_install_selection.py index f1129e35e..bdefbe751 100644 --- a/tests/test_install_selection.py +++ b/tests/test_install_selection.py @@ -648,7 +648,7 @@ class TheOneLinerNarrows(unittest.TestCase): def test_an_unknown_system_is_refused_before_any_download(self): with FakeRepository() as repo: proc = repo.run("--system", "sega-saturn") - self.assertEqual(proc.returncode, 1) + self.assertEqual(proc.returncode, 2) self.assertIn("unknown system: sega-saturn", proc.stderr) self.assertIn("nintendo-gba", proc.stderr) self.assertEqual(repo.installed(), []) @@ -1239,7 +1239,7 @@ class ThePlatformsOwnSystemNamesAreAccepted(unittest.TestCase): listing = repo.run("--list-systems", "--no-input") self.assertIn("nintendo-gba (gba)", listing.stdout) typo = repo.run("--system", "psxx", "--no-input") - self.assertEqual(typo.returncode, 1) + self.assertEqual(typo.returncode, 2) self.assertIn("Did you mean: sony-playstation", typo.stderr) @@ -1570,7 +1570,7 @@ class AnIdAnotherSetupUses(unittest.TestCase): "--dest", str(never), "--system", "snk-neogeo", "--dry-run", "--no-input"], capture_output=True, text=True, timeout=120, check=False, stdin=subprocess.DEVNULL) - self.assertEqual(proc.returncode, 1, proc.stdout + proc.stderr) + self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr) self.assertIn("Did you mean: arcade?", proc.stderr) self.assertNotIn("snk-neogeo-cd", proc.stderr) self.assertFalse(never.exists()) From ddafc2ff429777361d2196111cd299ec1e8367b8 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:30:06 +0100 Subject: [PATCH 26/30] feat: carry the board list in the pack --- install.ps1 | 2 +- install.py | 83 +++++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 ++-- scripts/generate_pack.py | 144 +++++++++++++++++++--------------- tests/test_install_offline.py | 69 ++++++++++++++++ wiki/installer.md | 15 ++-- 7 files changed, 238 insertions(+), 93 deletions(-) diff --git a/install.ps1 b/install.ps1 index d89008099..b95560063 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e" + $defaultInstallSha256 = "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index 467efbff8..a900c389c 100644 --- a/install.py +++ b/install.py @@ -4124,13 +4124,52 @@ def fetch_manifest(plat: str) -> dict: sys.exit(1) +def _pack_alone(plat: str) -> bool: + """A pack read without a file list for plat: one built before packs + carried their install list records the systems of its files only.""" + return (LOCAL.pack is not None and LOCAL.manifest is None + and (LOCAL.pack.install_list or {}).get("platform") != plat) + + +def local_targets(plat: str) -> "dict | None": + """The cores per board this run has without the network: the list the + pack carries, else targets/.json beside --manifest, where the + repository keeps it (install/targets/). None when there is neither.""" + if LOCAL.pack is not None and LOCAL.pack.targets is not None \ + and plat in LOCAL.pack.platforms(): + return LOCAL.pack.targets + if LOCAL.manifest is None: + return None + beside = LOCAL.manifest.parent / "targets" / f"{plat}.json" + raw = _read_capped(beside, MAX_TARGETS_BYTES) + if raw is None: + return None + try: + return _validate_targets(json.loads(raw.decode("utf-8"))) + except (UnicodeDecodeError, ValueError) as exc: + print(f" Error: the target list {beside} is not valid: {exc}", file=sys.stderr) + sys.exit(1) + + def fetch_targets(plat: str) -> "dict | None": - """Download the target core list. + """The target core list: the one this run has at hand, else downloaded. Empty on 404, where the platform publishes no targets. None when the list could not be read: a timeout says nothing about the platform, and - answering "no targets" for it sent users to the full pack. + answering "no targets" for it sent users to the full pack. A run without + the network and with no list at hand is refused, code 2, before any + file is written. """ + held = local_targets(plat) + if held is not None: + return held + if LOCAL.offline: + source = (f"{LOCAL.pack.name} carries none" if LOCAL.pack is not None + else f"{offline_named()} makes no request") + print(f"Error: --target reads the cores of each {display_name(plat)} board from a " + f"list, and {source}. Put install/targets/{plat}.json beside the file list " + "named with --manifest, or run with the network.", file=sys.stderr) + sys.exit(2) url = TARGETS_URL.format(platform=plat) try: return _validate_targets(_fetch_json(url, MAX_TARGETS_BYTES, "target list")) @@ -9089,7 +9128,7 @@ def _older_list_advice(plat: str = "") -> str: the pack's: the one-line installer does not change it, and a current pack was said to come from an older release. """ - if LOCAL.pack is not None and LOCAL.manifest is None: + if _pack_alone(plat): return (f" The list in {LOCAL.pack.name} does not record it: add --manifest FILE, " f"a copy of install/{plat or ''}.json, or run without the option.") if RELEASE_REF != DEFAULT_RELEASE_REF: @@ -9167,7 +9206,7 @@ def _requested_narrowing( """The systems, cores and regions named on the command line, checked.""" if not (args.system or args.core or args.region): return None - pack_alone = LOCAL.pack is not None and LOCAL.manifest is None + pack_alone = _pack_alone(plat) recorded = recorded_axes(files, omitted) if pack_alone: # A pack records the systems of its files, not their cores or @@ -10862,6 +10901,11 @@ class PackSource: # The version of the platform's list the pack was built from, "" for # a pack that does not record it. self.list_version = "" + # The platform's install list and its cores per board, which a pack + # built since carries: with them a pack alone narrows to a board as + # the network list does. None for a pack that carries none. + self.install_list: dict | None = None + self.targets: dict | None = None try: for path in self.files: archive = zipfile.ZipFile(path) @@ -10963,6 +11007,15 @@ class PackSource: version = data.get("list_version") if isinstance(version, str) and _VERSION_TEXT.fullmatch(version): self.list_version = version + if isinstance(data.get("install_list"), dict): + self.install_list = data["install_list"] + if data.get("targets") is not None: + try: + self.targets = _validate_targets(data["targets"]) + except ValueError: + # Read as a pack that carries none: --target is then refused + # with the reason, never applied from a broken list. + self.targets = None for key in ("system_titles", "system_names"): held = data.get(key) if isinstance(held, dict): @@ -10999,9 +11052,11 @@ class PackSource: if f"_{PLATFORM_NAMES[plat].replace(' ', '_')}_" in stem] def manifest(self, plat: str) -> dict: - """A file list for plat built from the pack's own: the same files at - the same places, as extracting the pack would leave them, with the - systems it records.""" + """A file list for plat: the install list the pack carries, else one + built from the pack's own, the same files at the same places, as + extracting the pack would leave them, with the systems it records.""" + if self.install_list is not None and self.install_list.get("platform") == plat: + return self.install_list return { "manifest_version": 2, "platform": plat, @@ -13238,13 +13293,14 @@ def main() -> None: # The runs the full-screen interface starts read it from here, and # the words name the option given. os.environ[OFFLINE_VARIABLE] = OFFLINE_FLAG_VALUE - if offline_requested() and (args.target or args.list_targets): + if (offline_requested() and (args.target or args.list_targets) + and not (args.manifest or args.from_pack)): + # No list can be at hand: a pack carries one, and so can the folder + # of a --manifest (targets/.json). parser.error("--target and --list-targets read a core list from the network, " - f"which {offline_named()} does not use") + f"which {offline_named()} does not use: name a pack with --from-pack, " + "or a file list with --manifest and targets/.json beside it") install_bounded_opener() - if args.from_pack and args.target: - parser.error("--target reads a core list from the network, which --from-pack " - "does not use") if getattr(args, "from") and ( args.list_platforms or args.list_targets or args.list_systems or args.list_cores or args.list_emulators @@ -14947,7 +15003,8 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # narrows to it: a Batocera RPi4 took 8.6 GiB where its board needs # 5.5. Only on the device itself, where the board file exists. target = args.target - if not target and not args.every_board and args.dest is None and not LOCAL.offline: + if (not target and not args.every_board and args.dest is None + and (not LOCAL.offline or local_targets(plat_name) is not None)): board = detect_board(plat_name) if board: known = fetch_targets(plat_name) or {} diff --git a/install.sh b/install.sh index 01f821f5d..4b2234df3 100755 --- a/install.sh +++ b/install.sh @@ -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="37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e" +DEFAULT_INSTALL_SHA256="d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index 9dc8fb5bf..dea4d98e2 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1088455, - "sha256": "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e", + "size": 1091372, + "sha256": "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1142082, - "sha256": "cce8bbfd699e23eb524244c01141d0a3316a3c3b769c54d3367e924b8d428ed2" + "size": 1144999, + "sha256": "eaa8cf2c295c6d57b349ef7b28fcb48cb47a7a13c251b13ff66dedbc5764bfd6" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1088455, - "sha256": "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e", + "size": 1091372, + "sha256": "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1088455, - "sha256": "37d1cbaddd22995b6390fcb95ed3d44d899f935bc93d288fe989d4dfae92775e", + "size": 1091372, + "sha256": "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e", "executable": false }, { diff --git a/scripts/generate_pack.py b/scripts/generate_pack.py index 07dc00ecc..6e5a7e683 100644 --- a/scripts/generate_pack.py +++ b/scripts/generate_pack.py @@ -2273,67 +2273,7 @@ def generate_target_manifests(targets_dir: str, output_dir: str) -> None: for yml_file in sorted(targets_path.glob("*.yml")): if yml_file.name.startswith("_"): continue - with open(yml_file) as f: - data = yaml_load(f) or {} - targets = data.get("targets", {}) - if not isinstance(targets, dict): - raise ValueError(f"{yml_file}: targets must be a mapping") - - # The pack builder accepts the aliases declared in _overrides.yml, so - # --target switch works there. Emitting only canonical names left the - # installer rejecting the same word and, worse, carrying on with every - # file instead: the user asked for a filter and got the full pack. - overrides_path = targets_path / "_overrides.yml" - alias_map: dict[str, list[str]] = {} - platform_overrides: dict = {} - if overrides_path.is_file(): - with open(overrides_path) as f: - all_overrides = yaml_load(f) or {} - platform_overrides = ( - all_overrides.get(yml_file.stem, {}).get("targets", {}) or {} - ) - for tname, ovr in platform_overrides.items(): - names = [a for a in (ovr or {}).get("aliases", []) if isinstance(a, str)] - if names: - alias_map[tname] = names - - result: dict[str, list[str] | None] = {} - for target_name, target_info in targets.items(): - if not isinstance(target_name, str) or not target_name: - raise ValueError(f"{yml_file}: target names must be non-empty strings") - if not isinstance(target_info, dict): - raise ValueError(f"{yml_file}: target {target_name!r} must be a mapping") - cores = target_info.get("cores") - if cores is None: - result[target_name] = None - continue - if ( - not isinstance(cores, list) - or any(not isinstance(core, str) or not core for core in cores) - or len(cores) != len(set(cores)) - ): - raise ValueError( - f"{yml_file}: target {target_name!r} cores must be unique " - "non-empty strings" - ) - # The builder's own reading, overrides included: a list published - # another way makes the installer serve what the pack does not. - # An empty list is a board with no core, not one with every core. - resolved = load_target_config( - yml_file.stem, target_name, str(targets_path.parent) - ) - result[target_name] = None if resolved is None else sorted(resolved) - - for target_name, names in alias_map.items(): - if target_name not in result: - continue - for alias in names: - if alias in result: - raise ValueError( - f"{yml_file}: alias {alias!r} collides with a target name" - ) - result[alias] = result[target_name] - + result = target_list(yml_file) out_path = Path(output_dir) / f"{yml_file.stem}.json" write_text_atomic(out_path, json.dumps(result, indent=2, sort_keys=True) + "\n") count += 1 @@ -2341,6 +2281,73 @@ def generate_target_manifests(targets_dir: str, output_dir: str) -> None: print(f"Generated {count} target manifest(s) in {output_dir}") +def target_list(yml_file: Path) -> dict[str, list[str] | None]: + """A platform's cores per hardware target, as install/targets/ publishes + them and a pack carries them: aliases included, overrides applied.""" + targets_path = yml_file.parent + with open(yml_file) as f: + data = yaml_load(f) or {} + targets = data.get("targets", {}) + if not isinstance(targets, dict): + raise ValueError(f"{yml_file}: targets must be a mapping") + + # The pack builder accepts the aliases declared in _overrides.yml, so + # --target switch works there. Emitting only canonical names left the + # installer rejecting the same word and, worse, carrying on with every + # file instead: the user asked for a filter and got the full pack. + overrides_path = targets_path / "_overrides.yml" + alias_map: dict[str, list[str]] = {} + platform_overrides: dict = {} + if overrides_path.is_file(): + with open(overrides_path) as f: + all_overrides = yaml_load(f) or {} + platform_overrides = ( + all_overrides.get(yml_file.stem, {}).get("targets", {}) or {} + ) + for tname, ovr in platform_overrides.items(): + names = [a for a in (ovr or {}).get("aliases", []) if isinstance(a, str)] + if names: + alias_map[tname] = names + + result: dict[str, list[str] | None] = {} + for target_name, target_info in targets.items(): + if not isinstance(target_name, str) or not target_name: + raise ValueError(f"{yml_file}: target names must be non-empty strings") + if not isinstance(target_info, dict): + raise ValueError(f"{yml_file}: target {target_name!r} must be a mapping") + cores = target_info.get("cores") + if cores is None: + result[target_name] = None + continue + if ( + not isinstance(cores, list) + or any(not isinstance(core, str) or not core for core in cores) + or len(cores) != len(set(cores)) + ): + raise ValueError( + f"{yml_file}: target {target_name!r} cores must be unique " + "non-empty strings" + ) + # The builder's own reading, overrides included: a list published + # another way makes the installer serve what the pack does not. + # An empty list is a board with no core, not one with every core. + resolved = load_target_config( + yml_file.stem, target_name, str(targets_path.parent) + ) + result[target_name] = None if resolved is None else sorted(resolved) + + for target_name, names in alias_map.items(): + if target_name not in result: + continue + for alias in names: + if alias in result: + raise ValueError( + f"{yml_file}: alias {alias!r} collides with a target name" + ) + result[alias] = result[target_name] + return result + + def _resolve_systems(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: """Turn --system into the platform's system ids, before any work. @@ -2930,6 +2937,7 @@ def _run_platform_packs( # The install list of the same choices names each file's # systems: the pack records them, so a pack alone (a # handheld without Wi-Fi) can be narrowed by system. + board_list = Path(args.platforms_dir) / "targets" / f"{representative}.yml" systems_for[os.path.basename(zip_path)] = pack_systems(generate_manifest( representative, args.platforms_dir, db, args.bios_dir, registry_path, emulators_dir=args.emulators_dir, @@ -2939,7 +2947,7 @@ def _run_platform_packs( offline=args.offline, required_only=required_only, data_registry=data_registry, no_game_data=getattr(args, "no_game_data", False), - )) + ), target_list(board_list) if board_list.is_file() else None) print("\nVerifying packs and generating manifests...") skip_conf = bool(system_filter or args.split) @@ -4770,7 +4778,7 @@ def _manifest_core_index( -def pack_systems(install_manifest: dict) -> dict: +def pack_systems(install_manifest: dict, targets: dict | None = None) -> dict: """What a pack's manifest.json records of its systems, from the install list built with the same choices: each file's systems, by its path in the pack, and the names a person reads for them. @@ -4778,6 +4786,11 @@ def pack_systems(install_manifest: dict) -> dict: A pack recorded none: the installer refused --system with a pack alone ("the file list comes from an older release") and offered no Choose systems, the route of a handheld without Wi-Fi. + + The install list itself and the platform's cores per board (targets, as + install/targets/ publishes them) ride along: with the pack alone, a + board was never applied and --target was refused, and a Batocera RPi4 + card took 8.9 GB where its board reads 5.8. """ files = install_manifest.get("files", []) entries = [*files, *install_manifest.get("omitted_files", [])] @@ -4803,6 +4816,9 @@ def pack_systems(install_manifest: dict) -> dict: # the one the machine runs when it has the pack alone. if install_manifest.get("version"): systems["list_version"] = install_manifest["version"] + systems["install_list"] = install_manifest + if targets is not None: + systems["targets"] = targets return systems @@ -4818,7 +4834,7 @@ def annotate_pack_systems(manifest: dict, systems: dict | None) -> None: for key in ("system_titles", "system_names"): if systems.get(key): manifest[key] = systems[key] - for key in ("list_records", "list_version"): + for key in ("list_records", "list_version", "install_list", "targets"): if key in systems: manifest[key] = systems[key] diff --git a/tests/test_install_offline.py b/tests/test_install_offline.py index deaf72b81..15fe5c38e 100644 --- a/tests/test_install_offline.py +++ b/tests/test_install_offline.py @@ -386,6 +386,75 @@ class SplitParts(Scratch): self.assertFalse(self.dest.exists()) +def _carrying(pack: Path, extra: dict) -> None: + """Add what a pack built since carries to its manifest.json.""" + with zipfile.ZipFile(pack) as archive: + members = {info.filename: archive.read(info) for info in archive.infolist()} + listed = {**json.loads(members["manifest.json"]), **extra} + members["manifest.json"] = json.dumps(listed).encode() + with zipfile.ZipFile(pack, "w", zipfile.ZIP_DEFLATED) as archive: + for name, data in members.items(): + archive.writestr(name, data) + + +# A GBA board and a PlayStation board: PAYLOAD's files split between them. +BOARDS = {"gba-board": ["mgba"], "psx-board": ["pcsx_rearmed"]} + + +class ABoardWithoutTheNetwork(Scratch): + """A pack alone, or --offline, never applied a board: --target was + refused, and a board found on the device was ignored. A Batocera RPi4 + card took 8.9 GB from its pack where its board reads 5.8.""" + + def setUp(self): + super().setUp() + self.downloads = self.root / "downloads" + self.downloads.mkdir() + self.pack = _pack(self.downloads, "RetroArch_v1_BIOS_Pack.zip", PAYLOAD) + + def installed(self) -> set[str]: + return {p.relative_to(self.dest).as_posix() for p in self.dest.rglob("*") + if p.is_file()} + + def test_the_builder_writes_both_into_the_pack_list(self): + sys.path.insert(0, str(REPO_ROOT / "scripts")) + import generate_pack # noqa: PLC0415 + + install_list = manifest() + board_file = REPO_ROOT / "platforms" / "targets" / "batocera.yml" + boards = generate_pack.target_list(board_file) + self.assertEqual(boards, json.loads( + (REPO_ROOT / "install" / "targets" / "batocera.json").read_text())) + pack_list = {"files": []} + generate_pack.annotate_pack_systems( + pack_list, generate_pack.pack_systems(install_list, boards)) + self.assertEqual(pack_list["install_list"], install_list) + self.assertEqual(pack_list["targets"], boards) + + def test_the_pack_applies_its_board_list(self): + _carrying(self.pack, {"install_list": manifest(), "targets": BOARDS}) + proc = self.run_installer("--from-pack", str(self.pack), "--target", "gba-board", + "--no-input") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertEqual(self.installed(), {"gba_bios.bin"}) + + def test_a_list_beside_the_manifest_applies_offline(self): + (self.root / "targets").mkdir() + (self.root / "targets" / "retroarch.json").write_text(json.dumps(BOARDS)) + self.complete() + proc = self.run_installer("--offline", "--check", "--manifest", str(self.list), + "--target", "psx-board", "--no-input") + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + self.assertIn("for target psx-board", proc.stdout) + + def test_a_pack_without_a_list_says_why(self): + proc = self.run_installer("--from-pack", str(self.pack), "--target", "gba-board", + "--no-input") + self.assertEqual(proc.returncode, 2) + self.assertIn(f"{self.pack.name} carries none", proc.stderr) + self.assertFalse(self.dest.exists()) + + class FromPackInstallsOffline(Scratch): def setUp(self): super().setUp() diff --git a/wiki/installer.md b/wiki/installer.md index 2ac413ad0..d3b5491ca 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -124,9 +124,12 @@ The platform is read from the pack's name when `--platform` is not given. The file list is the one the pack carries (`manifest.json`), with each file's SHA-1 and size, and every file is checked against it before it takes its place. A data tree (Dolphin's `Sys/`, PPSSPP's assets) comes from the pack -too, not from its source archive. `--target` reads a core list from the -network and is refused with `--from-pack`. A board found on the device is not -applied either: every board's files are installed. At a terminal the run +too, not from its source archive. A pack built since 2026-11 also carries +its platform's install list and the cores of each board (the +`install/targets/.json` list): `--target` applies with the pack +alone, and so does a board found on the device. An older pack carries +neither, and `--target` is then refused before anything is written. At a +terminal the run finds a card mounted here, asks the folder when nothing is found, and names the BIOS folder on its menu before anything is copied. @@ -953,7 +956,7 @@ what the options name, everything by default. |--------|--------| | `--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. ES-DE, LaunchBox and Android, which keep no BIOS folder, are answered with the run that fills each emulator's folder. Unknown names are refused with the closest ones | | `--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is. A folder that does not exist is named with the nearest one that does, then created, except for a system that runs from its SD card, which refuses it. When more than its last level is missing (a mistyped path), a run at a terminal asks first, and the answer defaults to no | -| `--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given | +| `--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given, from a pack alone too when it carries the core list per board | | `--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices | | `--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) | | `--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores` | @@ -978,7 +981,7 @@ what the options name, everything by default. | `--events` | Write the run as JSON events on standard output, one object per line, and the usual text on standard error. Implies `--no-input`. See [Machine-readable events](#machine-readable-events) | | `--from FOLDER` | Copy the BIOS files a folder holds (an old SD card, a backup, a ZIP) instead of downloading them, each one recognised by its hash and copied, never moved. Repeat for several folders. See [Copy from an old card](#copy-from-an-old-card) | | `--from-pack PATH` | Install from a pack already downloaded: its ZIP, one of its parts (the others are found in the same folder), or the folder holding them. Makes no network request and checks every file's hash. See [Without a network](#without-a-network-offline-install) | -| `--offline` | Make no network request. `--check`, `--from`, `--from-pack` and `--manifest` work without one, the file list stops the run at once when none of them gives it, and a file the run would download fails at once. `RETROBIOS_INSTALL_OFFLINE=1` does the same. Refused with `--target` and `--list-targets`, which read a core list from the network | +| `--offline` | Make no network request. `--check`, `--from`, `--from-pack` and `--manifest` work without one, the file list stops the run at once when none of them gives it, and a file the run would download fails at once. `RETROBIOS_INSTALL_OFFLINE=1` does the same. `--target` and `--list-targets` read the core list per board a pack carries, or `targets/.json` beside `--manifest`, and are refused when neither gives one | | `--manifest FILE` | Read the file list from FILE, a copy of `install/.json`, instead of downloading it. See [Without a network](#without-a-network-offline-install) | | `--restore FOLDER` | Put back the files a run replaced and kept in FOLDER, a dated folder inside `retrobios-backup`. Takes no other option but `--dry-run` and `--no-input`. See [Files you already had](#files-you-already-had) | | `--tui` | Open the full-screen interface, or stop with the reason it cannot open here (code 2) instead of falling back to the questions. Wins over `RETROBIOS_PLAIN` and `ACCESSIBLE`. See [The full-screen interface](#the-full-screen-interface) | @@ -1722,7 +1725,7 @@ fields, the installer refuses to narrow rather than install everything. |------|---------| | `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 with its ssl, unicodedata and zlib modules (nothing was run), an unusable base URL, `--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 an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. 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 | +| `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`, `--target` with `--from-pack` or `--offline` (or `RETROBIOS_INSTALL_OFFLINE` set) when no core list per board is at hand (the pack carries none and none is beside `--manifest` as `targets/.json`), `--list-targets` with `--offline` and neither, `--add-app` with `--check`, `--dry-run`, a listing, `--from-pack` or `--offline`. The message then starts with `install.py: error:`. Also an unknown platform, target, system, core or emulator, said in an `Error:` or `Unknown platform` line and an `error` event whose `stage` is `option`. 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 | | `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) | From 9c128e46b238f50e9f251f8ac9b8f08e238d60cb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:34:38 +0100 Subject: [PATCH 27/30] feat: apply the board a mounted card names --- install.ps1 | 2 +- install.py | 66 ++++++++++++++++++++++++--- install.sh | 2 +- install/launchers/index.json | 16 +++---- schemas/install-events.schema.json | 32 +++++++++++++ scripts/generate_event_schema.py | 4 +- tests/test_install.py | 72 ++++++++++++++++++++++++++++++ wiki/installer.md | 3 +- 8 files changed, 177 insertions(+), 20 deletions(-) diff --git a/install.ps1 b/install.ps1 index b95560063..a84e0e364 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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 = "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e" + $defaultInstallSha256 = "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a" $maximumInstallerBytes = 2MB $installerExit = 0 $installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null } diff --git a/install.py b/install.py index a900c389c..a980b4d31 100644 --- a/install.py +++ b/install.py @@ -676,6 +676,12 @@ EVENT_KEYS: dict[str, tuple[str, ...]] = { # message says it and what it means. Not written when either version # is unknown, and never a refusal. "version": ("platform", "list_version", "installed_version", "message"), + # Before choices: the board the system or its card names, read with no + # --target given. applied is whether the run keeps its cores' files + # only, false for a board the platform's list does not name. message + # says it. A kind of its own: the full-screen interface always gives + # --dest, and the board was never applied there. + "board": ("platform", "board", "applied", "message"), # Before the file events of a check: the systems the run's list lets # one choose among, --system aside, in id order (other for the files # no system names). What Choose systems lists, and what the plan counts @@ -1471,6 +1477,33 @@ def derivative_board(platform: str) -> tuple[str, str] | None: return (name, board) if board else None +# The board a card names on a partition a computer reads. Batocera writes +# its image's board to boot/batocera.board on the BATOCERA boot partition, +# the FAT one beside SHARE (batocera-43.1, board/batocera/scripts/ +# post-image-script.sh:60), and reads it there itself (package/batocera/ +# core/batocera-scripts/scripts/batocera-info). batocera.arch is inside the +# image, out of a computer's reach. +_CARD_BOARD_FILES: dict[str, str] = {"batocera": "boot/batocera.board"} + + +def card_board(platform: str) -> str | None: + """The board the card mounted here names, None where no card or two + cards of different boards do: a card is most often prepared on a + computer, where the system's own board file does not exist.""" + where = _CARD_BOARD_FILES.get(platform) + if where is None: + return None + boards: set[str] = set() + for mount in _mounted_cards(): + try: + value = (mount / where).read_text(encoding="utf-8", errors="replace") + except OSError: + continue + if value.strip() and not _CONTROLS.search(value.strip()): + boards.add(value.strip().lower()) + return boards.pop() if len(boards) == 1 else None + + def detect_board(platform: str) -> str | None: """The board this system was built for, or None where it names none.""" kind, where = _BOARD_SOURCES.get(platform, ("", "")) @@ -15001,19 +15034,28 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None: # Target filtering. With no --target, a system that names its board # narrows to it: a Batocera RPi4 took 8.6 GiB where its board needs - # 5.5. Only on the device itself, where the board file exists. + # 5.5. Read from the device itself, else from the card mounted here: + # a card prepared on a computer took every board's files, and the + # full-screen interface, which always gives --dest, never applied one. target = args.target - if (not target and not args.every_board and args.dest is None + if (not target and not args.every_board and (not LOCAL.offline or local_targets(plat_name) is not None)): board = detect_board(plat_name) + where = "detected" + if not board and args.dest is not None: + board, where = card_board(plat_name), "found on the card" if board: known = fetch_targets(plat_name) or {} if board in known: target = board - print(f" Board {board} detected (--every-board installs all boards)") + words = f"Board {board} {where} (--every-board installs all boards)" else: - print(f" Board {board} has no {PLATFORM_NAMES.get(plat_name, plat_name)} " - "core list: installing the files of every board") + words = (f"Board {board} {where} has no " + f"{PLATFORM_NAMES.get(plat_name, plat_name)} core list: " + "installing the files of every board") + print(f" {words}") + EVENTS.emit("board", platform=plat_name, board=board, + applied=target == board, message=words) else: derived = derivative_board(plat_name) if derived: @@ -16428,7 +16470,8 @@ def _folded(text: str) -> str: # --system plan: Back with the same ticks gives that plan back. _CHECK_FIELDS = ("check", "files", "systems", "choices", "set_aside", "found", "scanned", "unreadable", "saves", "check_summary", "checked_flags", "cli_systems", - "facts", "ticked", "version", "checked_dests", "games", "games_unavailable") + "facts", "ticked", "version", "board", "checked_dests", "games", + "games_unavailable") class TuiState: @@ -16595,6 +16638,9 @@ class TuiState: self.version: str = "" # The note in one line, for a plan with no room for its sentences. self.version_short: str = "" + # The board event of the check: the board read from the system or + # its card, said on the plan. + self.board: str = "" # The same of the confirm or preview run, and of the install: a # choice of systems can leave the save file out, or keep it. self.dry_saves: dict | None = None @@ -18120,7 +18166,7 @@ def _start_run(state: TuiState, run: str) -> list[tuple]: state.choices = state.set_aside = None state.found, state.scanned, state.unreadable = [], [], [] state.check_summary = state.saves = None - state.version = state.version_short = "" + state.version = state.version_short = state.board = "" state.games = state.games_reading = state.games_unavailable = None state.checked_flags = tui_flags(state) elif run in ("confirm", "preview"): @@ -20042,6 +20088,8 @@ def _take_event(state: TuiState, event: dict) -> None: state.choices = list(event["systems"]) elif kind == "set_aside" and state.run == "check": state.set_aside = set(event["dests"]) + elif kind == "board" and state.run == "check": + state.board = str(event["message"]) elif kind == "version" and state.run == "check": state.version = str(event["message"]) state.version_short = (f"List for {display_name(state.platform, state.manifest)} " @@ -21256,6 +21304,10 @@ def _view_plan(state: TuiState, width: int, height: int) -> tuple: free = next((index for index, line in enumerate(lines) if str(line).startswith("Free space:")), len(lines)) lines.insert(free, state.version) + if state.board: + free = next((index for index, line in enumerate(lines) + if str(line).startswith("Free space:")), len(lines)) + lines.insert(free, state.board) if state.status: lines.append(state.status) if defined: diff --git a/install.sh b/install.sh index 4b2234df3..a3ee4f2ac 100755 --- a/install.sh +++ b/install.sh @@ -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="d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e" +DEFAULT_INSTALL_SHA256="993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a" MAX_INSTALLER_BYTES=2097152 cleanup() { diff --git a/install/launchers/index.json b/install/launchers/index.json index dea4d98e2..5c18b2e3a 100644 --- a/install/launchers/index.json +++ b/install/launchers/index.json @@ -27,8 +27,8 @@ { "dest": "roms/ports/retrobios/install.py", "repo_path": "install.py", - "size": 1091372, - "sha256": "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e", + "size": 1093946, + "sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a", "executable": false }, { @@ -71,8 +71,8 @@ "muos": { "archive": { "dest": "ARCHIVE/RetroBIOS.muxapp", - "size": 1144999, - "sha256": "eaa8cf2c295c6d57b349ef7b28fcb48cb47a7a13c251b13ff66dedbc5764bfd6" + "size": 1147573, + "sha256": "e0bc43ae6a7ce3e0db3b19392eba9dc1561db3a0c95bfe3933dd7c870bdf19a8" }, "members": [ { @@ -92,8 +92,8 @@ { "name": "RetroBIOS/install.py", "repo_path": "install.py", - "size": 1091372, - "sha256": "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e", + "size": 1093946, + "sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a", "executable": false }, { @@ -166,8 +166,8 @@ { "dest": ".config/modules/retrobios/install.py", "repo_path": "install.py", - "size": 1091372, - "sha256": "d32691fb483d73a2845911957c2ce0fb18c0e7990605b3b466ef936ef8cb882e", + "size": 1093946, + "sha256": "993f6128d67d32a96bcacb83ca542ef745701a2f98eb348330a1e86728ba597a", "executable": false }, { diff --git a/schemas/install-events.schema.json b/schemas/install-events.schema.json index d7ef598bc..8fb69ecd2 100644 --- a/schemas/install-events.schema.json +++ b/schemas/install-events.schema.json @@ -16,6 +16,7 @@ "error", "scanned", "version", + "board", "choices", "set_aside", "file", @@ -59,6 +60,9 @@ { "$ref": "#/$defs/version" }, + { + "$ref": "#/$defs/board" + }, { "$ref": "#/$defs/choices" }, @@ -292,6 +296,34 @@ } } }, + "board": { + "type": "object", + "additionalProperties": false, + "required": [ + "event", + "platform", + "board", + "applied", + "message" + ], + "properties": { + "event": { + "const": "board" + }, + "platform": { + "type": "string" + }, + "board": { + "type": "string" + }, + "applied": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + }, "choices": { "type": "object", "additionalProperties": false, diff --git a/scripts/generate_event_schema.py b/scripts/generate_event_schema.py index 35d667bad..6c2a309dc 100644 --- a/scripts/generate_event_schema.py +++ b/scripts/generate_event_schema.py @@ -35,7 +35,7 @@ KEY_TYPES: dict[str, dict] = { **dict.fromkeys(( "installer", "os", "os_name", "stage", "reason", "platform", "message", "folder", "dest", "system", "name", "bios_dir", "path", "member", - "list_version", "installed_version", + "list_version", "installed_version", "board", ), _TEXT), "mode": {"enum": ["install", "check", "dry-run", "list"]}, "state": {"enum": ["present", "wrong_hash", "missing"]}, @@ -45,7 +45,7 @@ KEY_TYPES: dict[str, dict] = { "bytes_needed", "data_missing", "not_installable", "installed", "up_to_date", "pending", "code", "games", "recognised", "not_games", ), _COUNT), - **dict.fromkeys(("truncated", "ok", "interrupted", "required_only"), _FLAG), + **dict.fromkeys(("truncated", "ok", "interrupted", "required_only", "applied"), _FLAG), **dict.fromkeys(("systems", "other_drives", "dests", "written"), _NAMES), **dict.fromkeys(("platforms", "failed", "skipped", "left_out", "kept", "found", "fallback"), _RECORDS), diff --git a/tests/test_install.py b/tests/test_install.py index a719d965c..43d3a31c7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3190,6 +3190,78 @@ class AndroidEmulatorsOutOfReachAreSaid(unittest.TestCase): self.assertIn("DraStic is installed", err.getvalue()) +class ACardPreparedOnAComputerGetsItsBoard(unittest.TestCase): + """A Batocera RPi4 card prepared on a PC took every board's files (8.9 GB + where bcm2711 reads 5.8): the board was read only on the device, and + only without --dest, which the full-screen interface always gives.""" + + def setUp(self): + self.root = Path(_tempdir(self)) + self.boot = self.root / "BATOCERA" + (self.boot / "boot").mkdir(parents=True) + (self.boot / "boot" / "batocera.board").write_text("bcm2711\n") + self.share = self.root / "SHARE" + (self.share / "system").mkdir(parents=True) + (self.share / "system" / "batocera.conf").write_text("system.hostname=BATOCERA\n") + lists = self.root / "lists" + (lists / "targets").mkdir(parents=True) + one, two = b"pi core file", b"pc core file" + self.list = lists / "batocera.json" + self.list.write_text(json.dumps({ + "manifest_version": 2, "platform": "batocera", "base_destination": "bios", + "files": [ + {"dest": "pi.bin", "sha1": hashlib.sha1(one).hexdigest(), "size": len(one), + "repo_path": "bios/pi.bin", "cores": ["picore"], "systems": ["pi"]}, + {"dest": "pc.bin", "sha1": hashlib.sha1(two).hexdigest(), "size": len(two), + "repo_path": "bios/pc.bin", "cores": ["pccore"], "systems": ["pc"]}, + ], + "system_cores": {"pi": ["picore"], "pc": ["pccore"]}, + })) + (lists / "targets" / "batocera.json").write_text( + json.dumps({"bcm2711": ["picore"], "x86_64": ["pccore"]})) + + def run_main(self, *extra: str) -> tuple[str, list[dict]]: + out = io.StringIO() + argv = ["install.py", "--platform", "batocera", "--dest", str(self.share), + "--manifest", str(self.list), "--offline", "--check", "--events", *extra] + with unittest.mock.patch.object(install, "_mounted_cards", return_value=[self.boot]), \ + unittest.mock.patch.object(sys, "argv", argv), \ + unittest.mock.patch.dict(os.environ), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()), \ + contextlib.suppress(SystemExit): + install.main() + events = [json.loads(line) for line in out.getvalue().splitlines() + if line.startswith("{")] + return out.getvalue(), events + + def test_the_boot_partition_names_the_board(self): + with unittest.mock.patch.object(install, "_mounted_cards", return_value=[self.boot]): + self.assertEqual(install.card_board("batocera"), "bcm2711") + self.assertIsNone(install.card_board("retroarch")) + + def test_the_run_keeps_that_boards_files(self): + _out, events = self.run_main() + [board] = [event for event in events if event["event"] == "board"] + self.assertEqual((board["board"], board["applied"]), ("bcm2711", True)) + self.assertIn("found on the card", board["message"]) + files = {event["dest"] for event in events if event["event"] == "file"} + self.assertEqual(files, {"pi.bin"}) + + def test_every_board_keeps_them_all(self): + _out, events = self.run_main("--every-board") + self.assertFalse([event for event in events if event["event"] == "board"]) + files = {event["dest"] for event in events if event["event"] == "file"} + self.assertEqual(files, {"pi.bin", "pc.bin"}) + + def test_the_plan_says_it(self): + state = install.TuiState() + state.run = "check" + install._take_event(state, {"event": "board", "platform": "batocera", + "board": "bcm2711", "applied": True, + "message": "Board bcm2711 found on the card (x)"}) + self.assertEqual(state.board, "Board bcm2711 found on the card (x)") + + class TheBoardTheSystemNamesIsTheTarget(unittest.TestCase): """Batocera, Recalbox and ROCKNIX each write the board they were built for, but the installer read none of them, so a Batocera RPi4 installed diff --git a/wiki/installer.md b/wiki/installer.md index d3b5491ca..96e263078 100644 --- a/wiki/installer.md +++ b/wiki/installer.md @@ -956,7 +956,7 @@ what the options name, everything by default. |--------|--------| | `--platform NAME` | Install for this platform instead of the detected one. A system `--list-platforms` names under its own files (`muos`, `arkos`) stands for the file list it gets, and the run says which. ES-DE, LaunchBox and Android, which keep no BIOS folder, are answered with the run that fills each emulator's folder. Unknown names are refused with the closest ones | | `--dest PATH` | Destination directory, overriding detection. Without `--platform`, the file list is the platform detected here: at a terminal the installer asks, Enter taking the one found, and otherwise it uses the one found, RetroArch's when nothing is found, and refuses when several are. The run prints whose list it is. A folder that does not exist is named with the nearest one that does, then created, except for a system that runs from its SD card, which refuses it. When more than its last level is missing (a mistyped path), a run at a terminal asks first, and the answer defaults to no | -| `--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given, from a pack alone too when it carries the core list per board | +| `--target NAME` | Keep only the files the cores of that hardware target need, and the systems they emulate. An unknown target is refused rather than ignored, since carrying on would install everything. On Batocera, Recalbox, ROCKNIX and Lakka the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`) and applied when no `--target` is given, from a pack alone too when it carries the core list per board. On a computer, the board a Batocera card names on its BATOCERA partition (`boot/batocera.board`) is applied the same way, in line mode and on the full screen | | `--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices | | `--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) | | `--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores` | @@ -1774,6 +1774,7 @@ other schemas under `api/v1/schemas/`. | `error` | `stage`, `reason`, `platform`, `message` | The file list could not be read (`stage` is `manifest`): `reason` is `network`, `not_found` or `invalid`, `clock` when the server's certificate was refused and the system clock reads a date before the installer was written, `no_ca` when the machine holds no list of trusted certificates (`SSL_CERT_FILE` names one), `no_ssl` when this Python was built without its `ssl` module and cannot use HTTPS, or `offline` when `--offline` forbids the request. A folder named with `--from` cannot be read (`stage` is `from`, `reason` `not_found`, `not_a_folder` for a file that is no ZIP, `unreadable`, or `system` for the root of the system, `/proc`, `/sys` or `/dev`). The BIOS folder sits under a folder this user cannot open (`stage` is `dest`, `reason` `unreadable`), and the run ends with code 1 before anything is written. A platform, target, system, core or emulator named on the command line is unknown or cannot be used here (`stage` is `option`, `reason` `unknown`, `platform` the platform it was checked against or the name typed), refused before any download with code 2. The BIOS folder cannot be created (`stage` is `dest`, `reason` `not_writable`): a `summary` follows with what the run installed before it. Two systems are detected on one machine and no `--dest` names the folder (`stage` is `detect`, `reason` `conflict`). The platform named with `--platform` is found nowhere and no folder can be assumed for it: a card system with no card named, a portable emulator, Batocera or Recalbox named on a computer (their folder is the `bios` folder of their SHARE drive), or no usual folder on this system or one set aside behind a folder this user cannot open (`stage` is `detect`, `reason` `not_found`). So is a platform named by a file list or a pack, in a run without questions where it is found nowhere here. A `--dest` refused as the card of the system named carries the same `stage` and `reason`: another system's card, a folder inside a card, a drive with none of the files its card always has, or a folder that does not exist. `message` says it in words, and is the only thing that tells these cases apart | | `scanned` | `folder`, `files`, `truncated`, `other_drives` | Each folder named with `--from`, once read and before `detect`. `files` counts the files found, `truncated` is true when the search stopped at 200,000 of them, and `other_drives` lists the folders below it on another drive, which were not entered | | `version` | `platform`, `list_version`, `installed_version`, `message` | Before `choices`, when the system here runs another version than the one the list was built for: `list_version` is the list's, `installed_version` the one the system's version file states, and `message` says it and what it means. Not written when either version is unknown. The run goes on | +| `board` | `platform`, `board`, `applied`, `message` | Before `choices`, with no `--target` and no `--every-board`, when the system names its board or a Batocera card mounted here does (`boot/batocera.board` on its BATOCERA partition): `applied` is true when the run keeps that board's files only, false for a board the platform's list does not name, which keeps every board's files. `message` says it | | `choices` | `platform`, `systems` | Before `checking`: the systems the run's list lets one choose among, sorted by id, `other` last for the files no system names. The run's other options apply (`--core`, `--region`, `--no-game-data`, the files left out for an unknown save directory), `--system` does not: these are the systems a screen lists to choose from, and the count a `--system` is out of | | `set_aside` | `platform`, `dests` | Right after `choices`: the files of the list the run sets aside before any choice, in list order, those read from a save directory whose place is not known among them. A screen chooses its cores among the rest, the cores `--core` accepts | | `file` | `platform`, `dest`, `systems`, `size`, `state` | Each file of the list after the check. `state` is `present` (hash verified), `wrong_hash` or `missing` | From 806cb6c71922ebd74a7861b567750be367267b80 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:01:25 +0100 Subject: [PATCH 28/30] fix: expect code 2 for a typo in two more tests --- tests/test_install.py | 2 +- tests/test_install_cores.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_install.py b/tests/test_install.py index 43d3a31c7..06ec275ef 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -4894,7 +4894,7 @@ class TheEmulatorNamesHaveTheirOwnListing(unittest.TestCase, _LoopbackRun): def test_the_hint_counts_what_the_listing_prints(self): proc = _install_run(self.env, "--platform", "retroarch", "--no-input", "--emulator", "nope") - self.assertEqual(proc.returncode, 1, proc.stdout) + self.assertEqual(proc.returncode, 2, proc.stdout) found = re.search(r"--list-emulators lists all (\d+)\.", proc.stderr) self.assertIsNotNone(found, proc.stderr) listing = _install_run(self.env, "--platform", "retroarch", "--no-input", diff --git a/tests/test_install_cores.py b/tests/test_install_cores.py index c0c057470..e379dde6d 100644 --- a/tests/test_install_cores.py +++ b/tests/test_install_cores.py @@ -415,7 +415,7 @@ class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): contextlib.redirect_stderr(io.StringIO()) as err: with self.assertRaises(SystemExit) as stop: install.main() - self.assertEqual(stop.exception.code, 1) + self.assertEqual(stop.exception.code, 2) self.assertIn("unknown target 'steamos' for RetroArch", err.getvalue()) self.assertIn("Nothing was installed", err.getvalue()) fetch.assert_not_called() From 37d9165f3323ba67b45413dcbad3f9f22bb43a42 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:03:54 +0100 Subject: [PATCH 29/30] fix: let a pack alone narrow by core in a test --- tests/test_install_tui.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_install_tui.py b/tests/test_install_tui.py index 18983003d..8d94e96f3 100644 --- a/tests/test_install_tui.py +++ b/tests/test_install_tui.py @@ -7176,10 +7176,10 @@ class _LineModeFindings(_CardFixtures): by, and each remedy changes the outcome.""" listing, pack, listed = self.minui() self.assertEqual(listed["list_records"], ["cores", "regions"]) - code, text, _kept = self.narrowed("minui", from_pack=[pack], core=["mgba"]) - self.assertEqual(code, 1) - self.assertIn("Add --manifest FILE, a copy of install/minui.json, which records them", - text) + # The pack carries its install list since 2026-11: --core narrows it + # alone, as with the list named beside it. + code, text, kept = self.narrowed("minui", from_pack=[pack], core=["mgba"]) + self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) code, text, kept = self.narrowed("minui", from_pack=[pack], manifest=listing, core=["mgba"]) self.assertEqual((code, kept), (0, ["Bios/MGBA/gba_bios.bin"]), text) @@ -7188,8 +7188,9 @@ class _LineModeFindings(_CardFixtures): self.assertEqual(listed["list_records"], ["regions", "systems"]) code, text, _kept = self.narrowed("misterfpga", from_pack=[mister], core=["x"]) self.assertEqual(code, 1) - self.assertIn("MiSTer FPGA's own file list records no cores either. Run it without " - "--core, or narrow with --system.", text) + # Its own list, which the pack carries, refuses it as --manifest would. + self.assertIn("the misterfpga file list records no cores, so --core cannot narrow it", + text) self.assertNotIn("--manifest", text) code, text, kept = self.narrowed("misterfpga", from_pack=[mister], system=["nintendo-gba"]) From ad810f0c68fb60809827658cf75df4320f29ec1c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:05:09 +0100 Subject: [PATCH 30/30] fix: run the card board test in a child process --- tests/test_install.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/test_install.py b/tests/test_install.py index 06ec275ef..06076be57 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -3221,18 +3221,21 @@ class ACardPreparedOnAComputerGetsItsBoard(unittest.TestCase): json.dumps({"bcm2711": ["picore"], "x86_64": ["pccore"]})) def run_main(self, *extra: str) -> tuple[str, list[dict]]: - out = io.StringIO() - argv = ["install.py", "--platform", "batocera", "--dest", str(self.share), - "--manifest", str(self.list), "--offline", "--check", "--events", *extra] - with unittest.mock.patch.object(install, "_mounted_cards", return_value=[self.boot]), \ - unittest.mock.patch.object(sys, "argv", argv), \ - unittest.mock.patch.dict(os.environ), \ - contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()), \ - contextlib.suppress(SystemExit): - install.main() - events = [json.loads(line) for line in out.getvalue().splitlines() + # A child process: main() changes the run's globals (the file list, + # the event stream), which leaked into the next tests. + code = ("import sys; from pathlib import Path; sys.path.insert(0, sys.argv[1]); " + "import install; card = Path(sys.argv[2]); " + "install._mounted_cards = lambda: [card]; " + "sys.argv = ['install.py', *sys.argv[3:]]; install.main()") + env = {**os.environ, "RETROBIOS_OS": "linux", "HOME": str(self.root)} + proc = subprocess.run( + [sys.executable, "-c", code, str(REPO_ROOT), str(self.boot), + "--platform", "batocera", "--dest", str(self.share), "--manifest", + str(self.list), "--offline", "--check", "--events", *extra], + env=env, capture_output=True, text=True, timeout=120, stdin=subprocess.DEVNULL) + events = [json.loads(line) for line in proc.stdout.splitlines() if line.startswith("{")] - return out.getvalue(), events + return proc.stdout + proc.stderr, events def test_the_boot_partition_names_the_board(self): with unittest.mock.patch.object(install, "_mounted_cards", return_value=[self.boot]):