feat: offer the other card in offline launchers

Without a network, muOS and ArkOS launchers ask to copy the BIOS files from the card the system does not read (muOS bind.sh, dArkOS roms2), then install with --from and the pack. CI checks that shellcheck is present, and the test fails under CI without it.
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 16:56:28 +02:00
1 parent c496014f64
commit 9646a9f429
7 files changed
+204 -6

No files matched your search

+5
View File
@@ -227,6 +227,11 @@ jobs:
- name: Hold install.py to Python 3.8
run: vermin --no-tips -t=3.8- --violations install.py
# The handheld launchers' test runs shellcheck and fails under CI
# without it. The Ubuntu image ships it: this names it if one stops.
- name: Check that shellcheck is installed
run: shellcheck --version
# The site is built as deploy-site.yml builds it, from the lockfile.
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
+7
View File
@@ -48,6 +48,13 @@ RB_CONSOLE="${CUR_TTY:-/dev/tty0}"
RB_MAPPER="$GPTOKEYB"
RB_FALLBACK=1
RB_PACK_PATH="$GAMEDIR:/$directory/ports:/$directory"
# ArkOS and dArkOS, as control.txt tells them apart (its plymouth theme): the
# games card is /$directory, roms2 when fstab mounts a second card, and its
# bios folder is the one RetroArch reads. The other card's is offered.
if [ -e "${RB_ARK_MARKER:-/usr/share/plymouth/themes/text.plymouth}" ]; then
RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}"
RB_BIOS_DEST="/$directory/bios"
fi
# shellcheck source=../shared/tui.txt
source "$GAMEDIR/tui.txt"
# shellcheck source=retrobios/screens.txt
+6
View File
@@ -21,6 +21,12 @@ MUXTERM="$MUOS/frontend/muxterm"
RB_DIR="$APP_DIR"
RB_PACK_PATH="$APP_DIR:$ROM_MOUNT"
RB_LOG="$APP_DIR/log.txt"
# muOS binds MUOS/bios from SD2 when that card has one, else from SD1, onto
# /run/muos/storage/bios (script/device/bind.sh:10-11,30-52): the other card's
# MUOS/bios is the one offered to copy from.
SD2_MOUNT="$(GET_VAR "device" "storage/sdcard/mount")"
RB_OTHER_CARDS="$ROM_MOUNT/MUOS/bios:$SD2_MOUNT/MUOS/bios"
RB_BIOS_DEST="${RB_BIOS_DEST:-/run/muos/storage/bios}"
# PortMaster keeps its runtimes here: the Python runtime is used when
# PortMaster has downloaded it.
controlfolder="$PM_DIR"
+67 -2
View File
@@ -20,6 +20,10 @@
# RB_GPTK its mapping (default retrobios.gptk next to this file)
# RB_PACK_PATH folders searched for a pack when there is no network,
# separated by ":", the first one named to the player
# RB_OTHER_CARDS BIOS folders the firmware keeps on each of its cards,
# separated by ":", as its own code names them
# RB_BIOS_DEST the one of them the firmware reads, never offered as the
# other card
# RB_FALLBACK 1 when the launcher has screens of its own to fall back to
# ESUDO sudo where the launcher needs it, as PortMaster sets it
@@ -33,6 +37,8 @@ RB_LOG="${RB_LOG:-$RB_DIR/log.txt}"
RB_ROUTES="${RB_ROUTES:-app vt}"
RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}"
RB_PACK_PATH="${RB_PACK_PATH:-$RB_DIR}"
RB_OTHER_CARDS="${RB_OTHER_CARDS:-}"
RB_BIOS_DEST="${RB_BIOS_DEST:-}"
RB_FONTS="${RB_FONTS:-/usr/share/consolefonts}"
RB_FB="${RB_FB:-/sys/class/graphics/fb0/virtual_size}"
RB_ROUTE_TABLE="${RB_ROUTE_TABLE:-/proc/net/route}"
@@ -204,6 +210,30 @@ rb_find_pack() {
return 0
}
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)" ]
}
# The BIOS folder on the firmware's other card, when it holds a file: a
# two-card device reads one card (muOS binds MUOS/bios from SD2 when it has
# one, else from SD1), and the files bought for it often sit on the other.
rb_other_card() {
local dir
local IFS=":"
for dir in ${RB_OTHER_CARDS:-}; do
[ -d "$dir" ] || continue
if rb_same_dir "$dir" "${RB_BIOS_DEST:-}" || rb_same_dir "$dir" "${RB_BIOS_DIR:-}"; then
continue
fi
if [ -n "$(find "$dir" -type f -print 2>/dev/null | head -n 1)" ]; then
printf '%s\n' "$dir"
return 0
fi
done
return 0
}
rb_pack_text() {
local dir="${RB_PACK_PATH%%:*}" pack
if [ -n "${RB_PLATFORM_NAME:-}" ]; then
@@ -401,6 +431,29 @@ rb_message() {
printf '\033[2J\033[H'
}
# A question on the current terminal: true for A or Enter, false for B, Esc,
# any other button or no answer.
rb_ask() {
local cols key
cols=$(stty size 2>/dev/null | awk '{print $2}')
case "$cols" in
''|*[!0-9]*) cols=40 ;;
esac
[ "$cols" -ge 20 ] || cols=40
printf '\033[2J\033[H'
printf 'RetroBIOS\n\n'
printf '%s\n' "$1" | fold -s -w $((cols - 1))
printf '\n%s\n' "A or Enter: yes. B or Esc: no."
if ! IFS= read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" key; then
printf '\033[2J\033[H'
return 1
fi
# The rest of an escape sequence (an arrow) is not a second answer.
read -r -s -t 0.05 -n 8 _ 2>/dev/null
printf '\033[2J\033[H'
[ -z "$key" ] || [ "$key" = " " ]
}
# --- The run --------------------------------------------------------------
# The interface on the current terminal: install.py --tui, from a pack on the
@@ -418,15 +471,26 @@ rb_here() {
printf '1\n' > "$RB_STATE/status"
return 1
fi
local other
if ! rb_online; then
# Detection also names the BIOS folder the other card must not be.
rb_detect
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")
fi
pack=$(rb_find_pack)
if [ -z "$pack" ]; then
rb_message "$(rb_pack_text)"
if [ "${#args[@]}" -gt 0 ]; then
# --from needs the list of files, which comes from the network or a pack.
rb_message "$(rb_pack_text) The pack carries the list of files: with it, the files on your other card are copied too."
else
rb_message "$(rb_pack_text)"
fi
printf '1\n' > "$RB_STATE/status"
return 1
fi
args=(--from-pack "$pack")
args+=(--from-pack "$pack")
if [ -n "$RB_PLATFORM" ]; then
args+=(--platform "$RB_PLATFORM")
fi
@@ -462,6 +526,7 @@ rb_launch() {
return 125
fi
export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK
export RB_OTHER_CARDS RB_BIOS_DEST
export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE
trap 'rb_cleanup; exit 143' TERM HUP
rb_mapper_start
+6
View File
@@ -39,6 +39,12 @@ if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then
RB_ROUTES="vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}"
RB_PACK_PATH="$RB_DIR:$HERE:/roms:/roms2"
# The second card is /roms2, and "Switch to SD2 for Roms" moves RetroArch's
# system folder there (dArkOS scripts/Switch to SD2 for Roms.sh:91-92): the
# folder RetroArch reads is the destination, the other card's is offered.
RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}"
RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \
"$ROOT/home/ark/.config/retroarch/retroarch.cfg" 2>/dev/null)
controlfolder="$ROOT/opt/system/Tools/PortMaster"
mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb")
[ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \
+107 -4
View File
@@ -98,8 +98,13 @@ class EveryScriptParses(unittest.TestCase):
text=True, check=False)
self.assertEqual(proc.returncode, 0, proc.stderr)
@unittest.skipUnless(shutil.which("shellcheck"), "shellcheck is not installed")
def test_shellcheck(self):
"""CI provides shellcheck (validate.yml checks it), so there it may
not skip."""
if shutil.which("shellcheck") is None:
if os.environ.get("CI"):
self.fail("CI must provide shellcheck for the handheld launchers")
self.skipTest("shellcheck is not installed")
proc = subprocess.run(
["shellcheck", "--shell=bash", "--severity=style", "--external-sources",
"--source-path=SCRIPTDIR", *map(str, SCRIPTS)],
@@ -385,8 +390,11 @@ class Device:
found.write_text(FAKE_INSTALL)
return into
def run(self, argv: list[str], stdio_tty: bool = False, **env: str):
"""Run a launcher; its console output is read from the pty."""
def run(self, argv: list[str], stdio_tty: bool = False, keys: str = "", **env: str):
"""Run a launcher with *keys* waiting on the console as buttons
pressed. Its console output is read from the pty."""
if keys:
os.write(self.master, keys.encode())
stdin = self.slave if stdio_tty else subprocess.DEVNULL
stdout = self.slave if stdio_tty else subprocess.PIPE
proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout,
@@ -584,6 +592,24 @@ class ThePortMasterPort(unittest.TestCase):
self.assertEqual(self.dialog(device)[0], "INIT no-check")
self.assertEqual(device.mapper_args(), [])
def test_arkos_offers_the_card_portmaster_does_not_run_from(self):
device = self.device(FAKE_CURL="7")
marker = device.root / "text.plymouth"
marker.write_text("")
roms = device.root / "roms" / "bios"
other = device.root / "roms2" / "bios"
roms.mkdir(parents=True)
other.mkdir(parents=True)
(roms / "a.bin").write_bytes(b"x")
(other / "b.bin").write_bytes(b"y")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker),
RB_OTHER_CARDS=f"{roms}:{other}")
[call] = device.tui_calls()
# The port's games card is roms here: its bios is the destination.
self.assertEqual(call["args"][:3], ["--tui", "--from", str(other)])
def test_under_a_compositor_foot_shows_the_interface(self):
device = self.device(WAYLAND_DISPLAY="wayland-1")
device.tool("foot", FAKE_TERMAPP)
@@ -638,7 +664,10 @@ class TheMuosApp(unittest.TestCase):
(muos / "script" / "var").mkdir(parents=True)
(muos / "script" / "var" / "retro.sh").write_text(
'SETUP_APP() { printf "SETUP_APP %s\\n" "$1" >> "$FAKE_STATE/tools.log"; }\n'
f'GET_VAR() {{ echo "{device.root / "mmc"}"; }}\n')
'GET_VAR() { case "$2" in\n'
f' storage/rom/mount) echo "{device.root / "mmc"}" ;;\n'
f' storage/sdcard/mount) echo "{device.root / "sdcard"}" ;;\n'
' *) echo "" ;;\nesac; }\n')
if muxterm:
(muos / "frontend").mkdir()
shutil.copy(device.tool("muxterm", FAKE_TERMAPP), muos / "frontend" / "muxterm")
@@ -678,6 +707,58 @@ class TheMuosApp(unittest.TestCase):
[call] = device.tui_calls()
self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)])
def two_cards(self, device: Device) -> tuple[Path, Path, Path]:
"""SD1 and SD2 both hold MUOS/bios, SD2's is the one muOS binds."""
sd1 = device.root / "mmc" / "MUOS" / "bios"
sd2 = device.root / "sdcard" / "MUOS" / "bios"
sd1.mkdir(parents=True)
sd2.mkdir(parents=True)
(sd1 / "scph5501.bin").write_bytes(b"x")
(sd2 / "gba_bios.bin").write_bytes(b"y")
pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
return sd1, sd2, pack
def test_offline_offers_the_other_card(self):
device, app = self.device(muxterm=False)
sd1, sd2, pack = self.two_cards(device)
proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\r", FAKE_CURL="7",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertIn("Copy the BIOS files from your other card?", device.screen)
self.assertIn(str(sd1), device.screen)
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from", str(sd1), "--from-pack", str(pack),
"--platform", "retroarch"])
def test_b_declines_the_other_card(self):
device, app = self.device(muxterm=False)
_sd1, sd2, pack = self.two_cards(device)
device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b", FAKE_CURL="7",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
"--platform", "retroarch"])
def test_the_card_the_system_reads_is_never_offered(self):
device, app = self.device(muxterm=False)
sd1, _sd2, _pack = self.two_cards(device)
shutil.rmtree(device.root / "sdcard")
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd1))
self.assertNotIn("other card", device.screen)
[call] = device.tui_calls()
self.assertNotIn("--from", call["args"])
def test_online_asks_nothing(self):
device, app = self.device(muxterm=False)
_sd1, sd2, _pack = self.two_cards(device)
device.run([str(app / "mux_launch.sh"), str(app)],
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
self.assertNotIn("other card", device.screen)
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
class TheToolsEntry(unittest.TestCase):
def device(self, **env: str) -> tuple[Device, Path]:
@@ -702,6 +783,28 @@ class TheToolsEntry(unittest.TestCase):
device.assert_mapper_stopped()
device.assert_console_restored()
def test_arkos_other_card_without_a_pack_needs_one(self):
"""--from needs the list of files, which an offline run takes from a
pack: the message says so."""
device, tools = self.device(FAKE_CURL="7")
fs = device.root / "fs"
(fs / "opt" / "system").mkdir(parents=True)
(fs / "opt" / "inttools").mkdir(parents=True)
roms, roms2 = device.root / "roms" / "bios", device.root / "roms2" / "bios"
roms.mkdir(parents=True)
roms2.mkdir(parents=True)
(roms / "bios.bin").write_bytes(b"x")
cfg = fs / "home" / "ark" / ".config" / "retroarch"
cfg.mkdir(parents=True)
(cfg / "retroarch.cfg").write_text(f'system_directory = "{roms2}"\n')
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], keys="\r",
RB_CONSOLE=device.console, RB_OTHER_CARDS=f"{roms}:{roms2}")
self.assertEqual(proc.returncode, 1)
screen = " ".join(device.screen.split())
self.assertIn(f"Copy the BIOS files from your other card? {roms} holds files", screen)
self.assertIn("with it, the files on your other card are copied too", screen)
self.assertEqual(device.tui_calls(), [])
def test_amberelec_console(self):
device, tools = self.device()
etc = device.root / "fs" / "etc"
+6
View File
@@ -979,6 +979,12 @@ 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.
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
not read, or the other of `/roms/bios` and `/roms2/bios` on ArkOS and dArkOS.
A says yes, as [`--from`](#copy-from-an-old-card) does. The list of files
still comes from the pack, so the pack is needed too.
**What it needs.** Python 3.8 or newer: the system's, else PortMaster's
`python_3.11` runtime on aarch64, which the PortMaster port downloads on
first use and the Tools entry then reuses. That runtime uses the system's