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] 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` |