fix: offer only a pack that serves the system

Release packs now exist for six platforms only, and install.py refuses a
pack named for another. build_port.py writes packs.txt from the
registry's release_pack groups. Offline, the launcher takes only a pack
spelling the detected system, names that pack, or says no release pack
serves it and gives Wi-Fi or the computer route.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 18:39:45 +02:00
1 parent e6ce3085b1
commit d614acbef3
4 files changed
+161 -20

No files matched your search

+33 -6
View File
@@ -304,9 +304,10 @@ rb_detect() {
done < <(rb_py "$RB_EVENTS" status "$RB_STATE/detect.jsonl" 2>> "$RB_LOG")
}
# The first pack in RB_PACK_PATH, one whose name spells this system first:
# install.py reads the platform from the same spelling (Name_With_Underscores
# between two underscores).
# The first pack in RB_PACK_PATH whose name spells this system: install.py
# reads the platform from the same spelling (Name_With_Underscores between
# two underscores) and refuses a pack named for another. Any pack only when
# the system is not known, since install.py then reads it from the name.
rb_find_pack() {
local dir file base stem first="" want=""
if [ -n "${RB_PLATFORM_NAME:-}" ]; then
@@ -323,13 +324,27 @@ rb_find_pack() {
printf '%s\n' "$file"
return 0
fi
[ -z "$first" ] && first="$file"
[ -z "$want" ] && [ -z "$first" ] && first="$file"
done
done
[ -n "$first" ] && printf '%s\n' "$first"
return 0
}
# How the release pack that serves this system is named: the platforms of its
# group, as packs.txt lists them. build_port.py writes packs.txt from the
# registry's release_pack platforms, so a system with no line has no pack.
rb_pack_label() {
[ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] || return 1
awk -F'\t' -v p="$RB_PLATFORM" '$1 == p { print $2; found = 1; exit } END { exit !found }' \
"$RB_DIR/packs.txt"
}
# Whether the system is known to have no release pack.
rb_no_pack_serves() {
[ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] && ! rb_pack_label > /dev/null
}
rb_same_dir() {
[ -n "$1" ] && [ -n "$2" ] || return 1
[ "$(stat -L -c '%d:%i' "$1" 2>/dev/null)" = "$(stat -L -c '%d:%i' "$2" 2>/dev/null)" ]
@@ -355,8 +370,14 @@ rb_other_card() {
}
rb_pack_text() {
local dir="${RB_PACK_PATH%%:*}" pack
if [ -n "${RB_PLATFORM_NAME:-}" ]; then
local dir="${RB_PACK_PATH%%:*}" pack label
if rb_no_pack_serves; then
printf '%s\n' "No network connection, and the releases carry no BIOS pack for ${RB_PLATFORM_NAME:-$RB_PLATFORM}: connect to Wi-Fi and start RetroBIOS again. $RB_PC_ROUTE"
return 0
fi
if label=$(rb_pack_label); then
pack="the $label BIOS pack (a file whose name starts with ${label}_ and ends in _BIOS_Pack.zip)"
elif [ -n "${RB_PLATFORM_NAME:-}" ]; then
pack="the BIOS pack whose name contains ${RB_PLATFORM_NAME// /_}"
else
pack="the BIOS pack for this system (a file whose name ends in _BIOS_Pack.zip)"
@@ -752,6 +773,12 @@ rb_here() {
printf 'No connection. Looking for the BIOS folder and a pack on the card...\n'
# Detection also names the BIOS folder the other card must not be.
rb_detect
if rb_no_pack_serves; then
# Copying from the other card needs a file list too: nothing to ask.
rb_message "$(rb_pack_text)"
printf '1\n' > "$RB_STATE/status"
return 1
fi
other=$(rb_other_card)
if [ -n "$other" ] && rb_ask "Copy the BIOS files from your other card? $other holds files. RetroBIOS copies the ones this system needs, each checked by its hash, and leaves that card as it is."; then
args=(--from "$other")
+50 -6
View File
@@ -46,6 +46,8 @@ MUOS_APP = "RetroBIOS.muxapp"
TOOLS_ZIP = "RetroBIOS-tools.zip"
# Every archive a release attaches, in the order the release notes list them.
ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP)
# Which release pack serves each platform, read by tui.txt offline.
PACKS_TABLE = "packs.txt"
_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE)
@@ -57,7 +59,7 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str:
return found.group(1)
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]:
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path | None]]:
"""What every launcher runs: the installer, the interface launcher, the
event reader, the licenses, and the gptokeyb mapping where one is read."""
shared = root / "ports" / "shared"
@@ -67,14 +69,17 @@ def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Pa
(f"{folder}/rbevents.py", shared / "rbevents.py"),
(f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
(f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
# Written by build_archive from the registry.
(f"{folder}/{PACKS_TABLE}", None),
]
if mapping:
members.append((f"{folder}/retrobios.gptk", shared / "retrobios.gptk"))
return members
def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
"""(name in the archive, source file) for every member of *name*, sorted."""
def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path | None]]:
"""(name in the archive, source file) for every member of *name*, sorted.
The source is None for the packs table build_archive writes."""
ports = root / "ports"
if name == PORT_ZIP:
source = ports / "RetroBIOS"
@@ -121,6 +126,40 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]
return sorted(members)
def release_pack_labels(root: Path = REPO_ROOT) -> dict[str, str]:
"""Platform -> the platform part of the release pack that serves it.
Only the platforms the registry marks release_pack have a pack, grouped
as the release builds them (pipeline.py --include-archived). install.py
refuses a pack whose name does not spell the platform, so a platform
outside these groups has no pack to copy, whatever its file list.
"""
from common import ( # noqa: PLC0415
group_identical_platforms,
list_release_pack_platforms,
load_platform_config,
)
from generate_pack import _name_part, _platform_pack_stem # noqa: PLC0415
platforms_dir = str(root / "platforms")
pool = list_release_pack_platforms(platforms_dir, include_archived=True)
labels: dict[str, str] = {}
for group, representative in group_identical_platforms(pool, platforms_dir):
stem = _platform_pack_stem(group, representative, platforms_dir)
config = load_platform_config(representative, platforms_dir)
version = config.get("version", config.get("dat_version", ""))
tag = f"_{_name_part(str(version))}" if version else ""
label = stem[: -len(tag)] if tag and stem.endswith(tag) else stem
for platform in group:
labels[platform] = label
return dict(sorted(labels.items()))
def _packs_table(root: Path) -> bytes:
return "".join(f"{platform}\t{label}\n"
for platform, label in release_pack_labels(root).items()).encode()
def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the launcher archive *name* to *output* and return it."""
pinned = pinned_installer_sha256(root)
@@ -137,9 +176,14 @@ def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path:
members = archive_members(name, root)
output.parent.mkdir(parents=True, exist_ok=True)
scratch = output.with_name(f".{output.name}.tmp")
with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for member, path in members:
_add_pack_member(zf, str(path), member)
table = output.with_name(f".{output.name}.{PACKS_TABLE}")
table.write_bytes(_packs_table(root))
try:
with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for member, path in members:
_add_pack_member(zf, str(path or table), member)
finally:
table.unlink()
scratch.replace(output)
return output
+70 -2
View File
@@ -28,6 +28,8 @@ import unittest
import zipfile
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
PORTS = REPO_ROOT / "ports"
sys.path.insert(0, str(REPO_ROOT / "scripts"))
@@ -235,6 +237,21 @@ class TheArchives(unittest.TestCase):
second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes()
self.assertEqual(first, second)
def test_each_names_the_release_packs(self):
"""packs.txt names the platforms a release publishes a pack for, as
the registry marks them, and the pack's name for each."""
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text())
released = {name for name, entry in registry["platforms"].items()
if entry.get("release_pack") is True}
labels = build_port.release_pack_labels()
self.assertEqual(set(labels), released)
self.assertEqual(labels["retroarch"], "RetroArch")
expected = "".join(f"{p}\t{label}\n" for p, label in sorted(labels.items()))
for name, (_script, folder) in self.LAYOUT.items():
with self.subTest(archive=name):
with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf:
self.assertEqual(zf.read(f"{folder}/packs.txt").decode(), expected)
def test_an_unpinned_installer_is_refused(self):
root = _tempdir(self)
for name in ("install.py", "install.sh", "LICENSE", "NOTICE"):
@@ -296,10 +313,12 @@ if "--check" in sys.argv:
if os.environ.get("FAKE_DETECT_HANG"):
time.sleep(60)
print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS",
"platforms": [{"platform": "retroarch", "name": "RetroArch",
"platforms": [{"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"name": os.environ.get("FAKE_PLATFORM_NAME", "RetroArch"),
"bios_dir": "/roms/bios"}]}))
print(json.dumps({"event": "error", "stage": "manifest", "reason": "network",
"platform": "retroarch", "message": "unreachable"}))
"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"message": "unreachable"}))
print(json.dumps({"event": "end", "code": 1}))
sys.exit(1)
if os.environ.get("FAKE_REFUSE"):
@@ -1249,6 +1268,24 @@ class TheMuosApp(unittest.TestCase):
[call] = device.tui_calls()
self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)])
def test_offline_names_the_pack_that_serves_muos(self):
"""muOS runs RetroArch, and install.py reads it as retroarch: the
RetroArch pack serves it."""
device, app = self.device(muxterm=True)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
self.assertEqual(proc.returncode, 1)
# muxterm draws what its child writes: here the launcher's stdout.
screen = " ".join(proc.stdout.decode().split())
self.assertIn("download the RetroArch BIOS pack (a file whose name starts with "
"RetroArch_ and ends in _BIOS_Pack.zip)", screen)
def test_offline_takes_no_pack_named_for_another_system(self):
device, app = self.device(muxterm=True)
(device.root / "mmc" / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
def test_offline_reads_a_pack_on_the_second_card(self):
device, app = self.device(muxterm=True)
(device.root / "sdcard").mkdir()
@@ -1445,6 +1482,22 @@ INI
# Stopped without the sudo control.ini names.
device.assert_mapper_stopped()
def test_rocknix_offline_names_no_pack_the_releases_lack(self):
"""No release pack spells ROCKNIX, and install.py refuses one named
for another system: the RetroArch pack on the card is not offered,
and the message gives the computer route."""
device, tools = self.device(FAKE_CURL="7")
self.rocknix(device)
(tools / "retrobios" / "RetroArch_v1.22.2_BIOS_Pack.zip").write_bytes(b"")
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm",
FAKE_PLATFORM="rocknix", FAKE_PLATFORM_NAME="ROCKNIX")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("the releases carry no BIOS pack for ROCKNIX", screen)
self.assertIn("--dest", screen)
self.assertNotIn("download", screen)
def test_rocknix_start_select_in_foot(self):
device, tools = self.device()
self.rocknix(device)
@@ -1458,6 +1511,21 @@ INI
class TheDocs(unittest.TestCase):
def test_the_offline_packs_named_are_the_ones_that_serve(self):
"""The wiki names the pack each firmware takes offline: the release
pack of the platform install.py detects it as, or none."""
labels = build_port.release_pack_labels()
served = {}
for rule in install._EMBEDDED_RULES:
served.setdefault(rule.name, labels.get(rule.manifest))
for name in ("muOS", "ArkOS", "dArkOS", "JELOS"):
self.assertEqual(served[name], "RetroArch", name)
self.assertEqual(served["Batocera"], "Batocera")
for name in ("ROCKNIX", "KNULLI", "AmberELEC"):
self.assertIsNone(served[name], name)
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
self.assertIn("The releases carry no pack for ROCKNIX,\nKnulli or AmberELEC", text)
def test_muos_paths_are_the_card_muos_mounts(self):
"""muOS mounts the first card at /mnt/mmc (MustardOS internal
mux_launch.sh, GET_VAR storage/rom/mount)."""
+8 -6
View File
@@ -987,13 +987,15 @@ running install is then stopped the way B stops it, and the last screen says
what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y
ticks a system, Select shows its keyboard, and Menu quits.
**Without Wi-Fi.** On a computer, download the BIOS pack whose name contains
your system (every part, for a pack in parts) and copy it next to RetroBIOS:
into `ports/retrobios` for the PortMaster port and on Batocera, to the root
of either card on muOS, into the `retrobios` folder of the Tools entry. With
no connection, RetroBIOS installs from it, as
**Without Wi-Fi.** On a computer, download the BIOS pack that serves your
system (every part, for a pack in parts): RetroArch's on muOS, ArkOS,
dArkOS and JELOS, Batocera's on Batocera. Copy it next to RetroBIOS: into
`ports/retrobios` for the PortMaster port and on Batocera, to the root of
either card on muOS, into the `retrobios` folder of the Tools entry. With no
connection, RetroBIOS installs from it, as
[`--from-pack`](#without-a-network-offline-install) does. With no pack there,
it names the pack and the folder.
it names the pack and the folder. The releases carry no pack for ROCKNIX,
Knulli or AmberELEC: there RetroBIOS needs Wi-Fi, or the computer route.
On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS
files from your other card**: muOS's `MUOS/bios` on the card the system does