chore: merge installer fixes, third pass

This commit is contained in:
Abdessamad Derraz committed 2026-11-06 04:28:18 +01:00
commit b7def89e19
17 files changed
+5449 -4714

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@
# and Invoke-WebRequest throws on a cut transfer before the hash is read:
# the site's build rewrites this line there (scripts/sitebootstrap.py).
$mismatchAdvice = "The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
$defaultInstallSha256 = "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1"
$defaultInstallSha256 = "34fd006cc0456c61b9a621e52f35cee6d3fd55ff14bbc6cea207ddc7bc57d858"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+102 -17
View File
@@ -528,7 +528,8 @@ class Progress:
BAR = 24
def __init__(self, total_files: int, total_bytes: int, term: Terminal) -> None:
def __init__(self, total_files: int, total_bytes: int, term: Terminal,
events: bool = True) -> None:
self.total_files = total_files
self.expected_bytes = total_bytes
self.total_bytes = max(total_bytes, 1)
@@ -548,6 +549,15 @@ class Progress:
self._drawn = 0.0
self._evented = self.start
self._lock = threading.Lock()
# A data tree's archive draws the line and writes no event: the
# progress events count the files of the download event.
self.events = events
def expect(self, total_bytes: int) -> None:
"""The size the transfer turns out to have, once its server says it."""
with self._lock:
self.expected_bytes = total_bytes
self.total_bytes = max(total_bytes, 1)
def add(self, count: int, resumed: bool = False, network: bool | None = None) -> None:
"""count bytes written, resumed ones left out of the speed. network
@@ -563,7 +573,7 @@ class Progress:
if self.term.live and not self.term.quiet and now - self._drawn >= 0.1:
self._drawn = now
self._draw()
if EVENTS.on and now - self._evented >= EVENT_PROGRESS_INTERVAL:
if self.events and EVENTS.on and now - self._evented >= EVENT_PROGRESS_INTERVAL:
self._evented = now
EVENTS.emit("progress", files_done=self.files, files_total=self.total_files,
bytes_done=self.bytes, bytes_total=self.expected_bytes)
@@ -1488,8 +1498,11 @@ _BOARD_NAMES: dict[str, dict[str, str]] = {
# 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"}
# image, out of a computer's reach. KNULLI writes boot/knulli.board the same
# way (knulli-linux 20260511, board/scripts/post-image-script.sh:66). Both
# write the image's name, which the target list maps to its board.
_CARD_BOARD_FILES: dict[str, str] = {"batocera": "boot/batocera.board",
"knulli": "boot/knulli.board"}
def card_board(platform: str) -> str | None:
@@ -3905,9 +3918,26 @@ def _validate_targets(data: object) -> dict[str, dict]:
):
raise ValueError(f"invalid core list for target {target}")
normalized[target] = {"cores": cores}
images = value.get("images") if isinstance(value, dict) else None
if images is None:
continue
if not isinstance(images, list) or len(images) > 10_000 or not all(
isinstance(image, str) and 0 < len(image) <= 128 for image in images):
raise ValueError(f"invalid image list for target {target}")
normalized[target]["images"] = images
return normalized
def board_target(board: str, targets: dict[str, dict]) -> str | None:
"""The target a board name stands for: the name itself, else the one
target whose images carry it. A card names its image (Batocera writes
anbernic-rg35xx-plus for the h700 target, x86-64-v3 for zen3)."""
if board in targets:
return board
owners = [name for name, info in targets.items() if board in info.get("images", ())]
return owners[0] if len(owners) == 1 else None
NETWORK_ADVICE = (" Check the connection, and HTTPS_PROXY if this network needs a "
"proxy, then run the same command again.\n"
f" More help: {TROUBLESHOOTING_PAGE}")
@@ -7032,15 +7062,20 @@ def download_files(
return failed, interrupted
def _fetch_archive(url: str, directory: Path) -> Path:
"""A data directory's source archive, in *directory*, retried like files."""
def _fetch_archive(url: str, directory: Path, progress: Progress | None = None) -> Path:
"""A data directory's source archive, in *directory*, retried like files.
progress draws its bytes as they come, an attempt that failed taken back."""
_network_allowed(url)
reason: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
handle = tempfile.NamedTemporaryFile(dir=directory, suffix=".archive", delete=False)
received = 0
try:
with handle, urllib.request.urlopen(url, timeout=60) as resp:
received = 0
if progress is not None:
length = resp.headers.get("Content-Length", "")
if length.isdigit():
progress.expect(int(length))
while True:
if _stop.is_set():
raise _Interrupted()
@@ -7051,6 +7086,8 @@ def _fetch_archive(url: str, directory: Path) -> Path:
if received > MAX_DATA_ARCHIVE_SIZE:
raise ValueError("archive exceeds the size limit")
handle.write(chunk)
if progress is not None:
progress.add(len(chunk))
return Path(handle.name)
except urllib.error.HTTPError as exc:
Path(handle.name).unlink(missing_ok=True)
@@ -7063,6 +7100,8 @@ def _fetch_archive(url: str, directory: Path) -> Path:
except BaseException:
Path(handle.name).unlink(missing_ok=True)
raise
if progress is not None and received:
progress.add(-received)
if attempt < MAX_RETRIES:
time.sleep(_retry_delay(attempt, None))
raise reason if reason else OSError("download failed")
@@ -7129,7 +7168,8 @@ def data_directory_state(entry: dict, bios_path: Path,
def install_data_directory(entry: dict, bios_path: Path,
state: tuple[Path, dict[str, dict], int] | None = None) -> dict:
state: tuple[Path, dict[str, dict], int] | None = None,
progress: Progress | None = None) -> dict:
"""Rebuild one data tree from its source archive, file by file verified.
state is the tree's data_directory_state when the run already read it:
each read hashes every file in place.
@@ -7153,7 +7193,7 @@ def install_data_directory(entry: dict, bios_path: Path,
# The pack holds the tree where it extracts, already mapped.
members = LOCAL.pack.tree(entry.get("dest", ""))
else:
archive = _fetch_archive(entry["url"], Path(scratch))
archive = _fetch_archive(entry["url"], Path(scratch), progress)
members = _archive_members(archive, entry)
for rel, declared, opener in members:
meta = needed.pop(rel, None)
@@ -7178,7 +7218,12 @@ def install_data_directory(entry: dict, bios_path: Path,
# Kept apart, so the advice is the offline one, not the network's.
counts["error"] = OfflineReason(str(exc))
except (urllib.error.URLError, OSError, ValueError, tarfile.TarError) as exc:
counts["error"] = str(getattr(exc, "reason", None) or exc)
# The tree's folder refused, not the source: said as the BIOS files
# say it, never as an archive that could not be read.
counts["error"] = (NOT_WRITABLE if isinstance(exc, OSError)
and not isinstance(exc, urllib.error.URLError)
and _write_refused(exc) and not _folder_writable(root)
else str(getattr(exc, "reason", None) or exc))
except _MEMBER_ERRORS as exc:
# A damaged, encrypted or unsupported member, in words.
counts["error"] = unreadable_words(exc)
@@ -13451,9 +13496,10 @@ def main() -> None:
):
parser.error("--from copies files into the BIOS folder, which a listing "
"does not write")
if (args.from_pack or args.manifest) and (args.list_platforms or args.list_targets):
if (args.from_pack or args.manifest) and args.list_platforms:
# --list-targets reads its boards from them, where --target does.
parser.error("--from-pack and --manifest give a file list, which --list-platforms "
"and --list-targets do not read")
"does not read")
if args.no_game_data and (args.list_platforms or args.list_targets):
parser.error("--no-game-data narrows a file list, not --list-platforms "
"or --list-targets")
@@ -13847,6 +13893,18 @@ _SHARE_RULES: tuple[_OsRule, ...] = (
_OsRule("rocknix", "ROCKNIX", "file_matches",
(".config/system/configs/system.cfg", r"^root\.password=rocknix\s*$"),
"roms/bios"),
# Lakka's is /storage, its STORAGE partition (Lakka-LibreELEC v6.1,
# packages/sysutils/busybox/scripts/fs-resize:31). RetroArch reads its
# BIOS through /tmp/system, an overlay whose upper folder is
# /storage/system (packages/lakka/retroarch_base/retroarch/system.d/
# tmp-system.mount:12). With HOME=/storage (config/retroarch-env.conf:1)
# RetroArch writes .config/retroarch/retroarch.cfg from the /etc
# skeleton at its first start (RetroArch 69a4f0ea, configuration.c:
# 3650-3676), whose system_directory is that overlay (config/
# retroarch.cfg:3217): no other system we install for reads /tmp/system.
_OsRule("lakka", "Lakka", "file_matches",
(".config/retroarch/retroarch.cfg",
r'^system_directory = "/tmp/system"\s*$'), "system"),
# MiSTer's is its exFAT partition, /media/fat (Main_MiSTer e0a234f9,
# file_io.cpp:1177), whose root holds the MiSTer binary and menu.rbf
# (Distribution_MiSTer ce602319) and which reads each core's files
@@ -15194,9 +15252,11 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
board, where = card_board(plat_name), "found on the card"
if board:
known = fetch_targets(plat_name) or {}
image, board = board, board_target(board, known) or board
if board in known:
target = board
words = f"Board {board} {where} (--every-board installs all boards)"
named = board if image == board else f"{board} ({image})"
words = f"Board {named} {where} (--every-board installs all boards)"
else:
words = (f"Board {board} {where} has no "
f"{PLATFORM_NAMES.get(plat_name, plat_name)} core list: "
@@ -15803,9 +15863,31 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
# PPSSPP's assets. Fetched from their source archive, each file
# checked against the hash the list records.
if data_dirs and not total_interrupted:
print("\nData directories:")
# A tree whose folder this user cannot write is not fetched: the
# BIOS files above already said the cause and its remedy, and
# each archive was named as unreadable.
writable = []
for entry, state in data_states:
counts = install_data_directory(entry, bios_path, state)
if state[1] and not _folder_writable(state[0]):
failures.append((entry["ref"], NOT_WRITABLE))
total_errors += 1
else:
writable.append((entry, state))
if writable:
print("\nData directories:")
for entry, state in writable:
# Its archive is drawn as the BIOS files were, and counted
# in the bytes and time the run ends on: 82 MB came in
# silence and the end said 310.5 KB.
fetching = bool(state[1]) and LOCAL.pack is None
progress = Progress(len(state[1]), 0, term, events=False) if fetching else None
try:
counts = install_data_directory(entry, bios_path, state, progress)
finally:
if progress is not None:
progress.finish()
total_bytes += progress.transferred()
total_seconds += progress.elapsed()
total_downloaded += counts["installed"]
data_landed += counts["installed"]
total_up_to_date += counts["present"]
@@ -15831,7 +15913,10 @@ def _install(args: argparse.Namespace, real_stdout: TextIO) -> None:
f", {counts['changed'] + counts['absent']} no longer in "
"the source (the pack has them)")
print(line)
if counts["error"]:
if counts["error"] == NOT_WRITABLE:
failures.append((entry["ref"], NOT_WRITABLE))
total_errors += 1
elif counts["error"]:
print(f" {entry['ref']}: could not read "
f"{LOCAL.pack.name if LOCAL.pack is not None else entry['url']}: "
f"{counts['error']}", file=sys.stderr)
@@ -19426,7 +19511,7 @@ def setup_list(setups: list, mounted: set, needle: str = "",
# The systems that run from an SD card on a board kept on a desk, not in
# the hand: their row stays among the platforms, and the handhelds' block,
# which leads when nothing is found here, keeps Onion four presses away.
_DESK_CARDS = frozenset({"misterfpga"})
_DESK_CARDS = frozenset({"misterfpga", "lakka"})
# The setups a computer runs, by its system, first in the setup list when
# nothing was found here. RetroBat runs on Windows alone (retrobat.org), and
+1 -1
View File
@@ -23,7 +23,7 @@ DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/ins
# curl or wget stop on a cut transfer before the hash is read: the site's
# build rewrites this line there (scripts/sitebootstrap.py).
MISMATCH_ADVICE="The download was most likely cut short: try again. Within minutes of an update of this project the two files can come from caches of different ages. Waiting five minutes settles that."
DEFAULT_INSTALL_SHA256="d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1"
DEFAULT_INSTALL_SHA256="34fd006cc0456c61b9a621e52f35cee6d3fd55ff14bbc6cea207ddc7bc57d858"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+8 -8
View File
@@ -27,8 +27,8 @@
{
"dest": "roms/ports/retrobios/install.py",
"repo_path": "install.py",
"size": 1098257,
"sha256": "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1",
"size": 1103111,
"sha256": "34fd006cc0456c61b9a621e52f35cee6d3fd55ff14bbc6cea207ddc7bc57d858",
"executable": false
},
{
@@ -71,8 +71,8 @@
"muos": {
"archive": {
"dest": "ARCHIVE/RetroBIOS.muxapp",
"size": 1151884,
"sha256": "54ff79a8c51879532db206bb24dd7b3a884a2c8de1d3bcc07f1a2d4f66084c71"
"size": 1156738,
"sha256": "35c0920008baadca997d0f778e99ce46d702bbe3d3f0d33104d7158422b292aa"
},
"members": [
{
@@ -92,8 +92,8 @@
{
"name": "RetroBIOS/install.py",
"repo_path": "install.py",
"size": 1098257,
"sha256": "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1",
"size": 1103111,
"sha256": "34fd006cc0456c61b9a621e52f35cee6d3fd55ff14bbc6cea207ddc7bc57d858",
"executable": false
},
{
@@ -166,8 +166,8 @@
{
"dest": ".config/modules/retrobios/install.py",
"repo_path": "install.py",
"size": 1098257,
"sha256": "d9b9e08c5a3ff983f8bb440512edd0c68e33493ff6c3c5b119dfe72582b3a3d1",
"size": 1103111,
"sha256": "34fd006cc0456c61b9a621e52f35cee6d3fd55ff14bbc6cea207ddc7bc57d858",
"executable": false
},
{
+3580 -3391
View File
File diff suppressed because it is too large. Load diff
+1343 -1274
View File
File diff suppressed because it is too large. Load diff
+124 -1
View File
@@ -1,6 +1,6 @@
platform: batocera
source: https://github.com/batocera-linux/batocera.linux/tree/batocera-43.1
scraped_at: '2026-11-05T10:55:18Z'
scraped_at: '2026-11-06T02:18:59Z'
targets:
a3gen2:
architecture: aarch64
@@ -169,6 +169,8 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- khadas-vim4
bcm2711:
architecture: aarch64
cores:
@@ -1063,6 +1065,12 @@ targets:
- xvic
- yquake2
- zc210
images:
- bananapi-m2-zero
- cha
- orangepi-one
- orangepi-pc
- orangepi-plus2e
h5:
architecture: aarch64
cores:
@@ -1214,6 +1222,9 @@ targets:
- xvic
- yquake2
- zc210
images:
- orangepi-pc2
- tritium-h5
h6:
architecture: aarch64
cores:
@@ -1364,6 +1375,10 @@ targets:
- xvic
- yquake2
- zc210
images:
- orangepi-3
- orangepi-3-lts
- orangepi-one-plus
h616:
architecture: aarch64
cores:
@@ -1523,6 +1538,10 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- orangepi-zero2
- orangepi-zero3
- x96-mate
h700:
architecture: aarch64
cores:
@@ -1695,6 +1714,22 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- anbernic-rg28xx
- anbernic-rg34xx
- anbernic-rg34xx-sp
- anbernic-rg35xx-2024
- anbernic-rg35xx-2024-rev6-panel
- anbernic-rg35xx-h
- anbernic-rg35xx-h-rev6-panel
- anbernic-rg35xx-plus
- anbernic-rg35xx-plus-rev6-panel
- anbernic-rg35xx-pro
- anbernic-rg35xx-sp
- anbernic-rg35xx-sp-v2-panel
- anbernic-rg40xx-h
- anbernic-rg40xx-v
- anbernic-rgcubexx
jh7110:
architecture: riscv64
cores:
@@ -2328,6 +2363,8 @@ targets:
- ymir
- yquake2
- zc210
images:
- dragonq6a
rk3128:
architecture: arm
cores:
@@ -2471,6 +2508,10 @@ targets:
- xvic
- yquake2
- zc210
images:
- powkiddy_a13
- ps5000
- ps7000
rk3288:
architecture: arm
cores:
@@ -2626,6 +2667,9 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- miqi
- tinkerboard
rk3326:
architecture: aarch64
cores:
@@ -2780,6 +2824,10 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- r36s
- rg351v-v2
- rk3326
rk3328:
architecture: aarch64
cores:
@@ -2932,6 +2980,9 @@ targets:
- xvic
- yquake2
- zc210
images:
- roc-cc
- rock64
rk3399:
architecture: aarch64
cores:
@@ -3094,6 +3145,15 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- hugsun-x99
- nanopi-m4v2
- orangepi-800
- orangepi4-lts
- rg552
- rock-pi-4
- rock960
- rockpro64
rk3568:
architecture: aarch64
cores:
@@ -3268,6 +3328,17 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- anbernic-rgxx3
- firefly-station-m2
- firefly-station-p2
- odroid-m1
- odroid-m1s
- orangepi-3b
- powkiddy-x55
- radxa-zero-3w
- rock-3a
- rock-3c
rk3588-sdio:
architecture: aarch64
cores:
@@ -3455,6 +3526,9 @@ targets:
- ymir
- yquake2
- zc210
images:
- orangepi-5-max
- orangepi-5-ultra
rk3588:
architecture: aarch64
cores:
@@ -3642,6 +3716,25 @@ targets:
- ymir
- yquake2
- zc210
images:
- bananapi-m7
- coolpi-4b
- firefly-station-m3
- friendlyelec-cm3588-nas
- gameforce-ace
- indiedroid-nova
- khadas-edge-2
- mekotronics-r58
- nanopi-m6
- orangepi-5
- orangepi-5-plus
- orangepi-5-pro
- orangepi-5b
- quartzpro64
- rock-5a
- rock-5b
- rock-5b-plus
- rock-5c
s812:
architecture: arm
cores:
@@ -3940,6 +4033,14 @@ targets:
- xvic
- yquake2
- zc210
images:
- lepotato
- lepotatov2
- minix-neo-u1
- odroidc2
- p201
- tvbox
- vim
s905gen2:
architecture: aarch64
cores:
@@ -4102,6 +4203,9 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- khadas-vim2
- radxa-zero
s905gen3:
architecture: aarch64
cores:
@@ -4264,6 +4368,10 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- khadas-vim3l
- odroidc4
- tvbox-gen3
s922x:
architecture: aarch64
cores:
@@ -4431,6 +4539,15 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- gtking
- gtkingpro
- odroidn2
- odroidn2l
- odroidn2plus
- ogu
- vim3
- zero2
s9gen4:
architecture: aarch64
cores:
@@ -4591,6 +4708,8 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- khadas-vim1s
sm8250:
architecture: aarch64
cores:
@@ -5147,6 +5266,8 @@ targets:
- yabasanshiro
- yquake2
- zc210
images:
- orangepi-4a
x86_64:
architecture: x86_64
cores:
@@ -5599,3 +5720,5 @@ targets:
- ymir
- yquake2
- zc210
images:
- x86-64-v3
+43 -1
View File
@@ -1,6 +1,6 @@
platform: knulli
source: https://github.com/knulli-cfw/knulli-linux/tree/20260511
scraped_at: '2026-11-05T10:55:16Z'
scraped_at: '2026-11-06T02:19:00Z'
targets:
a133:
architecture: aarch64
@@ -165,6 +165,13 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- magicx-zero-28
- magicx-zero-40
- powkiddy-v20
- powkiddy-v90s
- trimui-brick
- trimui-smart-pro
a527:
architecture: aarch64
cores:
@@ -329,6 +336,9 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- helegany-action-pi
- trimui-smart-pro-s
atm7039:
architecture: arm
cores:
@@ -460,6 +470,8 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- rg35xx
h700:
architecture: aarch64
cores:
@@ -625,6 +637,17 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- rg-cubexx
- rg28xx
- rg34xx
- rg34xx-sp
- rg35xx-h
- rg35xx-plus
- rg35xx-pro
- rg35xx-sp
- rg40xx-h
- rg40xx-v
r16:
architecture: arm
cores:
@@ -762,6 +785,8 @@ targets:
- xscpu64
- xvic
- zc210
images:
- miyoo-a30
rk3128:
architecture: arm
cores:
@@ -901,6 +926,10 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- powkiddy_a13
- ps5000
- ps7000
rk3326:
architecture: aarch64
cores:
@@ -1052,6 +1081,11 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- g350
- magicx-mini-m
- pixel2
- r36s
rk3566:
architecture: aarch64
cores:
@@ -1211,6 +1245,12 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- miyoo-flip
- powkiddy-rgb30
- powkiddy-x55
- rg-arc-s
- rg-ds
rk3576:
architecture: aarch64
cores:
@@ -1369,6 +1409,8 @@ targets:
- xvic
- yabasanshiro
- zc210
images:
- rg-vita-pro
sm8250:
architecture: aarch64
cores:
+5 -1
View File
@@ -13,7 +13,11 @@
"additionalProperties": false,
"required": ["cores"],
"properties": {
"cores": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }
"cores": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
"images": {
"description": "The image names the target builds, as a card's board file writes them (boot/batocera.board, boot/knulli.board).",
"type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true
}
}
}
]
+2 -1
View File
@@ -362,7 +362,8 @@ def _child(spec: dict) -> int:
def held(files: list[dict], _bios: Path, **_kwargs: object) -> tuple:
return [], list(files), []
def tree_landed(entry: dict, _bios: Path, state: object = None) -> dict:
def tree_landed(entry: dict, _bios: Path, state: object = None,
_progress: object = None) -> dict:
return {"present": 0, "installed": len(entry["files"]), "changed": 0,
"absent": 0, "error": "", "not_kept": []}
+14 -4
View File
@@ -2415,7 +2415,7 @@ 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]:
def target_list(yml_file: Path) -> dict[str, list[str] | dict | 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
@@ -2443,7 +2443,7 @@ def target_list(yml_file: Path) -> dict[str, list[str] | None]:
if names:
alias_map[tname] = names
result: dict[str, list[str] | None] = {}
result: dict[str, list[str] | dict | 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")
@@ -2468,7 +2468,15 @@ def target_list(yml_file: Path) -> dict[str, list[str] | None]:
resolved = load_target_config(
yml_file.stem, target_name, str(targets_path.parent)
)
result[target_name] = None if resolved is None else sorted(resolved)
listed = None if resolved is None else sorted(resolved)
images = target_info.get("images")
# The image names a card's board file holds (Batocera writes
# anbernic-rg35xx-plus for the h700 target): the installer reads
# the board of a card prepared on a computer through them.
result[target_name] = (
{"cores": listed, "images": images}
if images and listed is not None else listed
)
for target_name, names in alias_map.items():
if target_name not in result:
@@ -2478,7 +2486,9 @@ def target_list(yml_file: Path) -> dict[str, list[str] | None]:
raise ValueError(
f"{yml_file}: alias {alias!r} collides with a target name"
)
result[alias] = result[target_name]
# An alias names the board, not its images.
held = result[target_name]
result[alias] = held["cores"] if isinstance(held, dict) else held
return result
@@ -36,6 +36,35 @@ except ImportError:
_TARGET_FLAG_RE = re.compile(r"^(BR2_PACKAGE_BATOCERA_TARGET_\w+)=y", re.MULTILINE)
# The images a target flag builds, each a board directory whose last segment
# post-image-script.sh writes to boot/<system>.board on the card
# (batocera-43.1 board/batocera/scripts/post-image-script.sh:50,60, KNULLI
# 20260511 board/scripts/post-image-script.sh:57,66): Config.in lists them
# under BR2_TARGET_<SYSTEM>_IMAGES, one "default" per target flag.
_IMAGES_DEFAULT_RE = re.compile(
r'^\s*default\s+"([^"]*)"\s+if\s+(BR2_PACKAGE_BATOCERA_TARGET_\w+)\s*$')
def _parse_images(text: str) -> dict[str, list[str]]:
"""Each target flag's image names, in the words a card's board file uses."""
lines = text.replace("\\\n", " ").splitlines()
start = next((n for n, line in enumerate(lines)
if re.match(r"^\s*config\s+BR2_TARGET_[A-Z]+_IMAGES\s*$", line)), None)
if start is None:
return {}
images: dict[str, list[str]] = {}
for line in lines[start + 1:]:
if not line.strip():
continue
m = _IMAGES_DEFAULT_RE.match(line)
if m:
images.setdefault(m.group(2), []).extend(
path.rsplit("/", 1)[-1] for path in m.group(1).split())
elif not re.match(r"^\s*(string|default|help)\b", line):
break
return images
_IF_RE = re.compile(r"^if\s+(.*)$")
_DEFAULT_Y_RE = re.compile(r"^\s*default\s+y\s*(?:#.*)?$")
@@ -428,6 +457,9 @@ class Scraper(BaseTargetScraper):
)
if not package_to_emulators:
raise RuntimeError(f"no package->emulator mappings parsed from {es_systems_url}")
images_by_flag = _parse_images(config_in_text)
if not images_by_flag:
raise RuntimeError(f"no target image list parsed from {config_in_url}")
targets: dict[str, dict] = {}
for board_name in sorted(boards):
@@ -456,6 +488,9 @@ class Scraper(BaseTargetScraper):
"architecture": arch,
"cores": sorted(emulators),
}
images = sorted(set(images_by_flag.get(primary_flag, [])))
if images and images != [target_key]:
targets[target_key]["images"] = images
print(
f" {len(emulators)} emulators ({len(selected_packages)} packages selected)",
file=sys.stderr,
+123 -6
View File
@@ -1584,6 +1584,8 @@ class TargetFlagIsAppliedOrRefused(unittest.TestCase):
with self.subTest(platform=platform):
self.assertEqual(sorted(targets), sorted(known))
for target, cores in targets.items():
# A target that names its card images carries them beside.
cores = cores["cores"] if isinstance(cores, dict) else cores
with self.subTest(platform=platform, target=target):
resolved = load_target_config(platform, target, platforms)
self.assertEqual(cores, None if resolved is None else sorted(resolved))
@@ -1861,12 +1863,28 @@ class TestManualPlatformPrompt(unittest.TestCase):
self.assertEqual(chosen, [("batocera", Path("~/my bios").expanduser())])
def test_default_directory_comes_from_the_table(self):
answers = iter(["lakka", ""])
answers = iter(["retropie", ""])
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
unittest.mock.patch.object(install, "_mounted_cards", return_value=[]), \
contextlib.redirect_stdout(io.StringIO()):
chosen = install._prompt_manual_platform("linux")
self.assertEqual(chosen, [("lakka", install.DEFAULT_DESTS["lakka"])])
self.assertEqual(chosen, [("retropie", install.DEFAULT_DESTS["retropie"])])
def test_lakka_on_a_computer_asks_for_its_card(self):
"""/storage/system, a folder only the Lakka machine has, was taken
on a computer: Lakka keeps its BIOS on its card's STORAGE part."""
card = Path(_tempdir(self))
(card / ".config" / "retroarch").mkdir(parents=True)
(card / ".config" / "retroarch" / "retroarch.cfg").write_text(
'system_directory = "/tmp/system"\n')
answers = iter(["lakka"])
out = io.StringIO()
with unittest.mock.patch("builtins.input", lambda _prompt="": next(answers)), \
unittest.mock.patch.object(install, "_mounted_cards", return_value=[card]), \
contextlib.redirect_stdout(out):
chosen = install._prompt_manual_platform("linux")
self.assertEqual(chosen, [("lakka", card / "system")])
self.assertIn("Lakka card found", out.getvalue())
def test_batocera_on_a_computer_gets_no_default(self):
"""/userdata/bios, a folder only the Batocera
@@ -3347,6 +3365,44 @@ class ACardPreparedOnAComputerGetsItsBoard(unittest.TestCase):
files = {event["dest"] for event in events if event["event"] == "file"}
self.assertEqual(files, {"pi.bin"})
def test_a_card_naming_its_image_gets_the_board_of_that_image(self):
"""A Batocera RG35XX Plus card names anbernic-rg35xx-plus, a zen3 one
x86-64-v3: neither is a target name, and the card took every
board's files (9.0 GB where h700 reads 6.1)."""
(self.boot / "boot" / "batocera.board").write_text("anbernic-rg35xx-plus\n")
(self.list.parent / "targets" / "batocera.json").write_text(json.dumps({
"h700": {"cores": ["picore"], "images": ["anbernic-rg28xx",
"anbernic-rg35xx-plus"]},
"x86_64": ["pccore"]}))
out, events = self.run_main()
[board] = [event for event in events if event["event"] == "board"]
self.assertEqual((board["board"], board["applied"]), ("h700", True))
self.assertIn("Board h700 (anbernic-rg35xx-plus) found on the card", out)
files = {event["dest"] for event in events if event["event"] == "file"}
self.assertEqual(files, {"pi.bin"})
def test_an_image_two_boards_build_names_neither(self):
targets = install._validate_targets({
"zen3": {"cores": ["a"], "images": ["x86-64-v3"]},
"rk3326": {"cores": ["b"], "images": ["r36s"]},
"rk3566": {"cores": ["c"], "images": ["r36s"]}})
self.assertEqual(install.board_target("x86-64-v3", targets), "zen3")
self.assertEqual(install.board_target("zen3", targets), "zen3")
self.assertIsNone(install.board_target("r36s", targets))
def test_a_knulli_card_names_its_image_too(self):
(self.boot / "boot" / "knulli.board").write_text("rg35xx-plus\n")
with unittest.mock.patch.object(install, "_mounted_cards", return_value=[self.boot]):
self.assertEqual(install.card_board("knulli"), "rg35xx-plus")
published = json.loads((REPO_ROOT / "install" / "targets" / "knulli.json").read_text())
self.assertEqual(install.board_target(
"rg35xx-plus", install._validate_targets(published)), "h700")
batocera = install._validate_targets(json.loads(
(REPO_ROOT / "install" / "targets" / "batocera.json").read_text()))
for image, board in (("anbernic-rg35xx-plus", "h700"), ("x86-64-v3", "zen3"),
("odroidn2", "s922x"), ("bcm2711", "bcm2711")):
self.assertEqual(install.board_target(image, batocera), board)
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"])
@@ -3667,13 +3723,74 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
},
}
def test_the_archive_is_drawn_and_counted(self):
"""82 MB of archives came with no progress line, and the run ended
on "310.5 KB downloaded", the BIOS files alone."""
work = Path(_tempdir(self))
body = self._zip(work).read_bytes()
attempts: list[int] = []
class Response(io.BytesIO):
headers = {"Content-Length": str(len(body))}
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def urlopen(_url, timeout=None):
attempts.append(1)
if len(attempts) == 1:
class Cut(Response):
def read(self, size=-1):
if self.tell():
raise ConnectionResetError("cut")
return super().read(5)
return Cut(body)
return Response(body)
progress = install.Progress(1, 0, install.Terminal(io.StringIO()), events=False)
with unittest.mock.patch.object(install.urllib.request, "urlopen", urlopen), \
unittest.mock.patch.object(install, "_network_allowed", lambda url: None), \
unittest.mock.patch.object(install, "_retry_delay", lambda *a: 0), \
unittest.mock.patch.object(install.EVENTS, "emit") as emit:
path = install._fetch_archive("https://example.invalid/D.zip", work, progress)
self.assertEqual(path.read_bytes(), body)
self.assertEqual(len(attempts), 2)
# The cut attempt's five bytes are taken back.
self.assertEqual((progress.transferred(), progress.expected_bytes),
(len(body), len(body)))
emit.assert_not_called()
def test_the_run_ends_on_the_archive_bytes_too(self):
from test_install_selection import FakeRepository # noqa: PLC0415
member = os.urandom(300_000)
with FakeRepository() as repo:
with zipfile.ZipFile(repo.root / "tree.zip", "w") as zf:
zf.writestr("Sys/big.bin", member)
archive = (repo.root / "tree.zip").stat().st_size
listed = json.loads((repo.root / "install" / "retroarch.json").read_text())
listed["data_directories"] = [{
"ref": "tree", "dest": "tree", "type": "zip",
"url": f"http://127.0.0.1:{repo.httpd.server_address[1]}/tree.zip",
"systems": ["nintendo-gba"],
"files": {"Sys/big.bin": {"sha1": hashlib.sha1(member).hexdigest(),
"size": len(member)}}}]
(repo.root / "install" / "retroarch.json").write_text(json.dumps(listed))
proc = repo.run("--no-input")
bios = sum(len(payload) for payload, *_ in FakeRepository.PAYLOADS.values())
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn(f"{install.format_size(bios + archive)} downloaded in", proc.stdout)
def test_verified_members_land_the_rest_are_said(self):
work = Path(_tempdir(self))
archive = self._zip(work)
bios = work / "system"
bios.mkdir()
def fetch(url, directory):
def fetch(url, directory, progress=None):
copy = Path(directory) / "a.zip"
shutil.copy(archive, copy)
return copy
@@ -3710,7 +3827,7 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Endless())
with unittest.mock.patch.object(install, "_fetch_archive",
lambda url, d: Path(d) / "a"), \
lambda url, d, progress=None: Path(d) / "a"), \
unittest.mock.patch.object(install, "_archive_members", members):
counts = install.install_data_directory(entry, work)
self.assertEqual(counts["changed"], 1)
@@ -3738,7 +3855,7 @@ class DataDirectoriesAreInstalled(unittest.TestCase):
yield "Sys/GC/font.bin", 4, lambda: io.BufferedReader(Broken())
with unittest.mock.patch.object(install, "_fetch_archive",
lambda url, d: Path(d) / "a"), \
lambda url, d, progress=None: Path(d) / "a"), \
unittest.mock.patch.object(install, "_archive_members", members):
counts = install.install_data_directory(entry, work)
self.assertTrue(counts["error"])
@@ -6418,7 +6535,7 @@ class AFileYouHadIsKept(unittest.TestCase):
"type": "zip", "files": {"Sys/a.ini": {
"sha1": hashlib.sha1(self.NEW).hexdigest(), "size": len(self.NEW)}}}
def fetch(url, directory):
def fetch(url, directory, progress=None):
copy = Path(directory) / "a.zip"
shutil.copy(archive, copy)
return copy
+42
View File
@@ -463,6 +463,21 @@ class ABoardWithoutTheNetwork(Scratch):
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn("for target psx-board", proc.stdout)
def test_the_boards_are_listed_from_where_target_reads_them(self):
"""Offline, the refusal of --list-targets named --manifest, and
--manifest with --list-targets was refused: no form listed a board."""
(self.root / "targets").mkdir()
(self.root / "targets" / "retroarch.json").write_text(json.dumps(BOARDS))
_carrying(self.pack, {"install_list": manifest(), "targets": BOARDS})
for given in (["--offline", "--manifest", str(self.list)],
["--from-pack", str(self.pack)]):
with self.subTest(given=given[-1]):
proc = self.run_installer(*given, "--list-targets", "--no-input")
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
for board in BOARDS:
self.assertIn(f" {board} (", proc.stdout)
self.assertFalse(self.dest.exists())
def test_a_pack_without_a_list_says_why(self):
proc = self.run_installer("--from-pack", str(self.pack), "--target", "gba-board",
"--no-input")
@@ -936,6 +951,33 @@ class OfflineLeavesFilesMissing(Scratch):
self.assertNotIn("could not read", proc.stderr)
self.assertNotIn("no longer in the source", proc.stdout)
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0,
"a read-only folder is refused by POSIX permissions")
def test_a_tree_in_a_folder_not_writable_is_not_fetched(self):
"""A read-only card blamed each data archive's URL with a raw
errno, and that second cause split the one-line summary."""
listed = manifest()
listed["data_directories"] = [{
"ref": "bluemsx", "dest": "Machines", "type": "zip",
"url": "https://example.invalid/blueMSX.zip", "systems": ["nintendo-gba"],
"files": {"a.txt": {"sha1": _sha1(b"a"), "size": 1}},
}, {
"ref": "fbneo-cheats", "dest": "fbneo/cheats", "type": "zip",
"url": "https://example.invalid/cheats.zip", "systems": ["nintendo-gba"],
"files": {"b.txt": {"sha1": _sha1(b"b"), "size": 1}},
}]
self.list.write_text(json.dumps(listed))
self.complete()
self.dest.chmod(0o555)
self.addCleanup(self.dest.chmod, 0o755)
proc = self.run_installer("--manifest", str(self.list), "--no-input")
out = proc.stdout + proc.stderr
self.assertEqual(proc.returncode, 2, out)
self.assertNotIn("could not read", out)
self.assertNotIn("Errno", out)
self.assertNotIn("Data directories:", out)
self.assertIn("failed: 2 files (folder not writable), first bluemsx", out)
class OneOfflineVocabulary(Scratch):
"""--offline said one
+4
View File
@@ -2284,6 +2284,10 @@ class CardsSpaceAndConfirm(unittest.TestCase):
"bios"),
"ROCKNIX": ({".config/system/configs/system.cfg": "root.password=rocknix\n"},
"rocknix", "roms/bios"),
# Lakka's STORAGE partition, once RetroArch has written its config.
"Lakka": ({".config/retroarch/retroarch.cfg":
'menu_driver = "xmb"\nsystem_directory = "/tmp/system"\n'},
"lakka", "system"),
}
def card(self, files: dict) -> Path:
+15 -4
View File
@@ -96,7 +96,9 @@ class FailedRequestsStopTheScrape(unittest.TestCase):
b"BR2_PACKAGE_BATOCERA_SYSTEM=y\n")
if url.endswith("Config.in"):
return _Body(b"config BR2_PACKAGE_BATOCERA_SYSTEM\n"
b"\tselect BR2_PACKAGE_MGBA\n")
b"\tselect BR2_PACKAGE_MGBA\n\n"
b"config BR2_TARGET_BATOCERA_IMAGES\n\tstring \"x\"\n"
b"\tdefault \"x86_64\" if BR2_PACKAGE_BATOCERA_TARGET_X86_64\n")
return _Body(
b"gba:\n emulators:\n libretro:\n"
b" mgba: {requireAnyOf: [BR2_PACKAGE_MGBA]}\n"
@@ -263,7 +265,14 @@ class KnulliPerBoard(unittest.TestCase):
"\tconfig BR2_PACKAGE_BATOCERA_GLES3\n\tdefault y\nendif\n\n"
"config BR2_PACKAGE_KNULLI_SYSTEM\n"
"\tselect BR2_PACKAGE_LIBRETRO_MGBA\n"
"\tselect BR2_PACKAGE_PPSSPP\tif BR2_PACKAGE_BATOCERA_GLES3\n"
"\tselect BR2_PACKAGE_PPSSPP\tif BR2_PACKAGE_BATOCERA_GLES3\n\n"
"config BR2_TARGET_KNULLI_IMAGES_DEFINITIONS\n"
"\tbool \"target images definitions\"\n"
"\tconfig BR2_TARGET_KNULLI_IMAGES\n\tstring \"target images\"\n"
"\tdefault \"allwinner/h700/rg35xx-sp allwinner/h700/rg35xx-plus\" \\\n"
"\t\tif BR2_PACKAGE_BATOCERA_TARGET_H700\n"
"\tdefault \"allwinner/r16/miyoo-a30\"\tif BR2_PACKAGE_BATOCERA_TARGET_SUNXI_R16\n\n"
"\tchoice\n\tprompt \"knulli target\"\n"
),
"package/emulationstation/knulli-es-system/es_systems.yml": (
"gba:\n emulators:\n libretro:\n"
@@ -316,10 +325,12 @@ class KnulliPerBoard(unittest.TestCase):
self.assertEqual(data["source"],
f"https://github.com/knulli-cfw/knulli-linux/tree/{pin}")
self.assertEqual(sorted(data["targets"]), ["h700", "r16"])
# The images name what a card's boot/knulli.board holds.
self.assertEqual(data["targets"]["h700"],
{"architecture": "aarch64", "cores": ["mgba", "ppsspp"]})
{"architecture": "aarch64", "cores": ["mgba", "ppsspp"],
"images": ["rg35xx-plus", "rg35xx-sp"]})
self.assertEqual(data["targets"]["r16"],
{"architecture": "arm", "cores": ["mgba"]})
{"architecture": "arm", "cores": ["mgba"], "images": ["miyoo-a30"]})
self.assertTrue(all(pin in url or "buildroot/c2d7985b" in url for url in seen), seen)
+7 -4
View File
@@ -342,7 +342,9 @@ file list is downloaded once and handed to each run.
[Copy from an old card](#copy-from-an-old-card).
6. Downloads what is still missing or wrong, up to 8 files at a time. At a
terminal one line shows the progress, the speed and the time left, while in a
log each finished file gets its own line. In an interactive run, a
log each finished file gets its own line. A data tree's archive gets the
same line at a terminal, and the bytes and time the run ends on count
it with the files. In an interactive run, a
download of 1 GB or more is confirmed first (`--yes` skips the question).
7. Verifies each download against the declared size, SHA-256 and SHA-1 before
it touches the destination, then moves it into place with `os.replace` so
@@ -961,7 +963,7 @@ what the options name, everything by default.
|--------|--------|
| <span id="option-platform"></span>`--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 |
| <span id="option-dest"></span>`--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 |
| <span id="option-target"></span>`--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, Lakka, KNULLI and RetroPie the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`, `knulli.arch`, the Pi model in `/proc/cpuinfo` where `/opt/retropie` exists) 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 |
| <span id="option-target"></span>`--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, Lakka, KNULLI and RetroPie the board is read from the system (`batocera.arch`, `recalbox.arch`, `HW_DEVICE`, `LIBREELEC_DEVICE` or `LIBREELEC_PROJECT`, `knulli.arch`, the Pi model in `/proc/cpuinfo` where `/opt/retropie` exists) 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 or KNULLI card names on its boot partition (`boot/batocera.board`, `boot/knulli.board`) is applied the same way, in line mode and on the full screen. The card names its image (`anbernic-rg35xx-plus`, `x86-64-v3`), which the target list maps to its board (`h700`, `zen3`) |
| <span id="option-every-board"></span>`--every-board` | Install the files of every board instead of the one the system names, for a card that moves between devices |
| <span id="option-installed-cores"></span>`--installed-cores` | Keep only what the cores already installed read (RetroArch, Lakka, RetroPie). See [Installed cores](#installed-cores) |
| <span id="option-cores-dir"></span>`--cores-dir PATH` | The folder RetroArch loads its cores from, for `--installed-cores` when it is not found. Implies `--installed-cores`. A folder that does not exist or cannot be opened is refused with the reason (code 1) |
@@ -1101,6 +1103,7 @@ gives for the device, below the card's root instead of its mount point:
| `system/configs/emulationstation/about.info` holding the line `KNULLI` | KNULLI | `bios`, KNULLI's list |
| `system/batocera.conf` holding the line `system.hostname=KNULLI` | KNULLI | `bios`, KNULLI's list |
| `.config/system/configs/system.cfg` holding the line `root.password=rocknix` | ROCKNIX | `roms/bios`, ROCKNIX's list |
| `.config/retroarch/retroarch.cfg` holding the line `system_directory = "/tmp/system"` | Lakka | `system`, Lakka's list |
| `MiSTer`, `menu.rbf` | MiSTer FPGA | `games`, MiSTer FPGA's list |
| `MUOS/` | muOS | `MUOS/bios`, RetroArch's list |
| `spruce/` | spruceOS | `BIOS`, RetroArch's list |
@@ -1225,7 +1228,7 @@ beside its executable wherever the archive was extracted, so a forced
`--platform bizhawk` that finds no install stops and asks for `--dest`.
The systems that run from an SD card keep their files there: MinUI, NextUI,
MyMinUI, KNULLI, ROCKNIX, MiSTer FPGA, AmberELEC and EmuELEC (the card's games part, which
MyMinUI, KNULLI, ROCKNIX, Lakka (its STORAGE partition, which a Linux computer reads, once Lakka has started once), MiSTer FPGA, AmberELEC and EmuELEC (the card's games part, which
they mount as `/storage/roms`), and each system of the
[Linux handhelds](../platforms/linux-handhelds.md) that gets RetroArch's list
(Onion, muOS, spruceOS, ArkOS, UnofficialOS and the others). Run on the
@@ -1785,7 +1788,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 |
| `board` | `platform`, `board`, `applied`, `message` | Before `choices`, with no `--target` and no `--every-board`, when the system names its board or a Batocera or KNULLI card mounted here does (`boot/batocera.board` or `boot/knulli.board` on its boot partition, `board` then being the board its image maps to): `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` |