chore: merge the handheld launchers

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 16:56:52 +02:00
commit af32a0c6bf
27 files changed
+2099 -259

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:
+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "479b73569414c3e22a10b3a64fe8e57460b858ffa6a3fc0088357affea2e983e"
$defaultInstallSha256 = "163a216c1b09e8d788d4de6148194705813d4c6b8a956c84a70a267205f2c25d"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+4
View File
@@ -10665,6 +10665,10 @@ def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]:
flags.append((flag, str(Path(value).absolute())))
flags += [("--emulator", name) for name in args.emulator or []]
flags += [("--from-pack", str(Path(pack).absolute())) for pack in args.from_pack or []]
# The handheld launchers start --tui --from the other card: without it here
# the runs downloaded what that card holds.
flags += [("--from", str(Path(folder).absolute()))
for folder in getattr(args, "from", None) or []]
for flag in ("--every-board", "--standalone-copies", "--verbose"):
if getattr(args, flag[2:].replace("-", "_")):
flags.append((flag, None))
+1 -1
View File
@@ -18,7 +18,7 @@ esac
TEMP_INSTALLER=""
TEMP_DIRECTORY=""
DEFAULT_INSTALL_URL="https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
DEFAULT_INSTALL_SHA256="479b73569414c3e22a10b3a64fe8e57460b858ffa6a3fc0088357affea2e983e"
DEFAULT_INSTALL_SHA256="163a216c1b09e8d788d4de6148194705813d4c6b8a956c84a70a267205f2c25d"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+19 -8
View File
@@ -2,17 +2,28 @@
RetroBIOS runs the RetroBIOS installer (`install.py`, from
[github.com/Abdess/retrobios](https://github.com/Abdess/retrobios)) on the
device. The installer finds the BIOS folder of the system, checks every file
its emulators read against its hash, and downloads the missing ones. The
screens are PortMaster's own.
device, in its full-screen interface. The installer finds the BIOS folder of
the system, checks every file its emulators read against its hash, and
installs the missing ones.
Needs a network connection. Python 3.8 or newer: the system's, or
PortMaster's `python_3.11` runtime on aarch64, downloaded on first use.
With a network connection the files are downloaded. Without one, copy the
BIOS pack of your system (a `_BIOS_Pack.zip` file from the releases page)
into `ports/retrobios` on the card, and RetroBIOS installs from it.
Python 3.8 or newer: the system's, or PortMaster's `python_3.11` runtime on
aarch64, downloaded on first use.
Under a desktop session with neither foot nor xterm, RetroBIOS opens
PortMaster's own screens instead of the full-screen interface.
## Controls
| Button | Action |
|--|--|
| D-Pad | Move in a list |
| A | Choose |
| B | Back, on PortMaster releases that read it (every list also has a Back or Quit entry) |
| D-Pad | Move |
| A or Start | Choose |
| B or Select | Back |
| X | Tick or untick a system |
| Y | Options |
| L1 / R1 | Page up / down |
| Start + Select | Quit |
+41 -4
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# RetroBIOS: checks the BIOS folder of this system and downloads the files
# it lacks, with PortMaster's own screens.
# it lacks, in the installer's full-screen interface on the console, the way
# PortMaster's Moonlight and Rlvm ports draw dialog there.
XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
@@ -14,29 +15,65 @@ else
controlfolder="/roms/ports/PortMaster"
fi
# shellcheck source=/dev/null
source "$controlfolder/control.txt"
# shellcheck source=/dev/null
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
get_controls
# Set by control.txt.
: "${directory:?}" "${GPTOKEYB:=}" "${sdl_controllerconfig:=}"
GAMEDIR="/$directory/ports/retrobios"
cd "$GAMEDIR" || exit 1
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
# Appended to, as the launcher and install.py also write this file: a tee
# writing from its own offset would overwrite their lines.
: > "$GAMEDIR/log.txt" && exec > >(tee -a "$GAMEDIR/log.txt") 2>&1
# PortMasterDialog.txt has shipped with PortMaster since before 2024.02.26,
# and the 8.5.23 copy JELOS and KOLOS preinstall has no funcs.txt to source
# it: it is sourced here.
# it: it is sourced here. It downloads the Python runtime when one is
# needed, and draws the fallback screens.
if [ ! -f "$controlfolder/PortMasterDialog.txt" ]; then
echo "RetroBIOS needs PortMaster 2024.02.26 or newer: update PortMaster, then start RetroBIOS again."
exit 1
fi
# shellcheck source=/dev/null
source "$controlfolder/PortMasterDialog.txt"
RB_DIR="$GAMEDIR"
RB_ROUTES="app vt"
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
source "$GAMEDIR/screens.txt"
export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig"
rb_main
rb_select_python
# A launcher's own arguments are not install.py's (muOS passes the app folder).
# shellcheck disable=SC2119
rb_launch
status=$?
if [ "$status" = "125" ]; then
# No console or terminal app shows the interface here: PortMaster's own
# screens do.
rb_main
status=$?
fi
rb_release_python
if declare -F pm_finish > /dev/null; then
pm_finish
+1 -1
View File
@@ -13,7 +13,7 @@
],
"desc": "Checks the BIOS folder of this system against the files its emulators read, and downloads the missing ones from the RetroBIOS collection, each verified by its hash.",
"desc_md": null,
"inst": "Needs a network connection: the list of files and the files come from the internet. Uses the system's Python 3.8 or newer, or PortMaster's python_3.11 runtime on aarch64.",
"inst": "Downloads the files with a network connection. Without one, copy the BIOS pack of your system (a _BIOS_Pack.zip file from github.com/Abdess/retrobios/releases) into ports/retrobios. Uses the system's Python 3.8 or newer, or PortMaster's python_3.11 runtime on aarch64.",
"inst_md": null,
"genres": [
"other"
+45 -98
View File
@@ -1,7 +1,12 @@
#!/bin/bash
# shellcheck shell=bash
# RetroBIOS screens, drawn by PortMaster's own GUI (pugwash fifo_control)
# through the functions of PortMasterDialog.txt. Sourced by RetroBIOS.sh
# after PortMasterDialog.txt.
# after PortMasterDialog.txt and tui.txt.
#
# They are the port's fallback: RetroBIOS.sh opens them only when no terminal
# can show install.py --tui, that is under a compositor that has neither foot
# nor xterm, or when the interface refuses the console it was given.
#
# Only what PortMaster 2024.02.26 and the 8.5.23 copy JELOS and KOLOS ship
# is used: PortMasterDialogInit, PortMasterDialog, PortMasterDialogResult,
@@ -13,97 +18,42 @@
# and their wait for an answer gives up after 30 seconds: rb_answer keeps
# waiting while pugwash is alive.
#
# Inputs: GAMEDIR, controlfolder, ESUDO, and RB_INSTALL (install.py).
# pugwash starts in "no-check" mode, never "no-harbour": only with
# HarbourMaster loaded does it apply the A/B fix PortMaster keeps for Miyoo,
# TrimUI, Knulli and the Xbox-layout ArkOS and AmberELEC pads.
#
# Inputs: GAMEDIR, controlfolder, ESUDO, and what tui.txt defines
# (RB_INSTALL, RB_STATE, RB_LOG, rb_py, rb_online, rb_find_pack).
RB_STATE="${RB_STATE:-$GAMEDIR/run}"
RB_LOG="${RB_LOG:-$GAMEDIR/log.txt}"
RB_INSTALL="${RB_INSTALL:-$GAMEDIR/install.py}"
RB_EVENTS="${RB_EVENTS:-$GAMEDIR/rbevents.py}"
RB_RUNTIME="python_3.11"
RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer"
RB_PYTHON=""
RB_PYTHONHOME=""
RB_MOUNTED=""
RB_PYTHON_PROBLEM=""
RB_SOURCE_ARGS=()
# Read by tui.txt's rb_python_text.
# shellcheck disable=SC2034
RB_PYTHON_PROBLEM="${RB_PYTHON_PROBLEM:-}"
rb_arch() {
uname -m
}
# --- Python runtime -------------------------------------------------------
rb_py() {
if [ -n "$RB_PYTHONHOME" ]; then
PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@"
else
"$RB_PYTHON" "$@"
fi
}
# --- Python ---------------------------------------------------------------
rb_python_ok() {
"$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1
}
# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11
# runtime on aarch64, mounted as PortMaster's own Python ports do. Sets
# RB_PYTHON_PROBLEM when neither can run install.py.
rb_select_python() {
if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then
RB_PYTHON="python3"
return 0
fi
if [ "$(rb_arch)" != "aarch64" ]; then
RB_PYTHON_PROBLEM="old"
# tui.txt's rb_runtime calls this when the runtime is not on the card yet:
# PortMaster downloads it the way its own ports get theirs.
rb_fetch_runtime() {
local squashfs="$1" answer
if [ -z "$(PortMasterIPCheck)" ]; then
RB_PYTHON_PROBLEM="runtime_offline"
return 1
fi
rb_runtime
}
rb_runtime() {
local python_dir="$HOME/$RB_RUNTIME"
local squashfs="$controlfolder/libs/$RB_RUNTIME.squashfs"
if [ ! -f "$squashfs" ]; then
if [ -z "$(PortMasterIPCheck)" ]; then
RB_PYTHON_PROBLEM="runtime_offline"
return 1
fi
PortMasterDialogInit "no-check"
PortMasterDialog "messages_begin"
PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime."
local answer
answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs")
PortMasterDialog "messages_end"
PortMasterDialogExit
if [ "$answer" != "OKAY" ] || [ ! -f "$squashfs" ]; then
RB_PYTHON_PROBLEM="runtime_failed"
return 1
fi
fi
mkdir -p "$python_dir"
if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then
$ESUDO umount "$python_dir" >/dev/null 2>&1
fi
if ! $ESUDO mount "$squashfs" "$python_dir"; then
RB_PYTHON_PROBLEM="runtime_failed"
return 1
fi
RB_MOUNTED="$python_dir"
RB_PYTHON="$python_dir/bin/python3"
RB_PYTHONHOME="$python_dir"
if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then
PortMasterDialogInit "no-check"
PortMasterDialog "messages_begin"
PortMasterDialog "message" "This system's Python is older than 3.8. Downloading PortMaster's $RB_RUNTIME runtime."
answer=$(rb_answer "check_runtime" "$RB_RUNTIME.squashfs")
PortMasterDialog "messages_end"
PortMasterDialogExit
if [ "$answer" != "OKAY" ] || [ ! -f "$squashfs" ]; then
RB_PYTHON_PROBLEM="runtime_failed"
return 1
fi
return 0
}
rb_release_python() {
if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then
$ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1
fi
RB_MOUNTED=""
}
# --- Dialog primitives ----------------------------------------------------
# A command that waits for the user, and its answer. PortMasterDialogResult
@@ -174,12 +124,19 @@ rb_notice() {
# --- Reading install.py ---------------------------------------------------
# Run a --check and read its records into RB_* variables.
# Run a --check and read its records into RB_* variables. Without a network
# the files come from a pack on the card, when there is one.
rb_check() {
mkdir -p "$RB_STATE"
PortMasterDialog "messages_begin"
PortMasterDialog "message" "Reading the BIOS folder and the file list."
rb_py "$RB_INSTALL" --check --events "${RB_PLATFORM_ARGS[@]}" > "$RB_STATE/check.jsonl" 2>> "$RB_LOG"
RB_SOURCE_ARGS=()
if ! rb_online; then
local pack
pack=$(rb_find_pack)
[ -n "$pack" ] && RB_SOURCE_ARGS=(--from-pack "$pack")
fi
rb_py "$RB_INSTALL" --check --events "${RB_SOURCE_ARGS[@]}" "${RB_PLATFORM_ARGS[@]}" > "$RB_STATE/check.jsonl" 2>> "$RB_LOG"
PortMasterDialog "messages_end"
rb_py "$RB_EVENTS" status "$RB_STATE/check.jsonl" > "$RB_STATE/status.tsv" 2>> "$RB_LOG"
rb_read_status "$RB_STATE/status.tsv"
@@ -235,15 +192,7 @@ rb_pending() {
# --- Screens --------------------------------------------------------------
rb_screen_no_python() {
local found
found=$(python3 --version 2>&1 | awk '{print $2}')
local text
case "$RB_PYTHON_PROBLEM" in
old) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch)." ;;
runtime_offline) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download." ;;
*) text="Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run." ;;
esac
rb_notice "Python 3.8 or newer is needed" "$text $RB_PC_ROUTE" "quit" "Quit" > /dev/null
rb_notice "Python 3.8 or newer is needed" "$(rb_python_text)" "quit" "Quit" > /dev/null
}
# What the check found when it found nothing to show: no platform, no
@@ -256,7 +205,7 @@ rb_screen_problem() {
elif [ "$RB_DETECTED" = "0" ]; then
rb_notice "No BIOS folder found" "install.py knows no BIOS folder on $RB_OS_NAME. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit"
elif [ "$RB_ERROR" = "network" ]; then
rb_notice "No connection" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files: without a connection nothing can be checked or downloaded. Connect to Wi-Fi, then check again." "check" "Check again" "quit" "Quit"
rb_notice "No connection" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. $(rb_pack_text)" "check" "Check again" "quit" "Quit"
elif [ "$RB_ERROR" = "invalid" ]; then
rb_notice "The file list is newer than this port" "$RB_ERROR_TEXT. Update RetroBIOS from PortMaster, or use the installer on a computer. $RB_PC_ROUTE" "check" "Check again" "quit" "Quit"
else
@@ -354,7 +303,7 @@ rb_update() {
PortMasterDialog "message" "Downloading to $RB_BIOS_DIR."
PortMasterDialog "progress" "Starting" 0 1
local kind a b c d e f
rb_py "$RB_INSTALL" --events "${RB_PLATFORM_ARGS[@]}" "${args[@]}" 2>> "$RB_LOG" \
rb_py "$RB_INSTALL" --events "${RB_SOURCE_ARGS[@]}" "${RB_PLATFORM_ARGS[@]}" "${args[@]}" 2>> "$RB_LOG" \
| rb_py "$RB_EVENTS" follow "$RB_STATE/summary.tsv" 2>> "$RB_LOG" \
| while IFS=$'\t' read -r kind a b c d e f; do
case "$kind" in
@@ -425,15 +374,14 @@ rb_screen_summary() {
# --- Flow -----------------------------------------------------------------
# The screens, once rb_select_python has run.
rb_main() {
if ! rb_select_python; then
PortMasterDialogInit "no-harbour"
PortMasterDialogInit "no-check"
if [ -z "$RB_PYTHON" ]; then
rb_screen_no_python
PortMasterDialogExit
rb_release_python
return 1
fi
PortMasterDialogInit "no-harbour"
RB_PLATFORM_ARGS=()
local action systems chosen
rb_check
@@ -466,6 +414,5 @@ rb_main() {
esac
done
PortMasterDialogExit
rb_release_python
return 0
}
+23
View File
@@ -0,0 +1,23 @@
# RetroBIOS for Batocera and Knulli
Copy `RetroBIOS.sh`, `RetroBIOS.sh.keys` and the `retrobios` folder into
`roms/ports` on the SHARE partition (`/userdata/roms/ports`), update the
game list, then start **RetroBIOS** from **Ports**.
It checks the BIOS folder (`/userdata/bios`) against the files its emulators
read and installs the missing ones: downloaded with a network connection,
or from a pack copied into `roms/ports/retrobios` without one.
The controller drives it through `RetroBIOS.sh.keys`: D-pad to move, A or
Start to choose, B or Select to go back, X to tick a system, Y for the
options, L1 and R1 to page. B on the first screen closes it.
## Not tried on a device yet
EmulationStation covers the text console on Batocera and Knulli, and no
terminal for a script is documented there. RetroBIOS opens xterm (the X11
builds) or foot when either is installed, else switches to console 4, runs
there, and switches back. Which of the two shows up on a given build is not
verified. If the screen stays black, `retrobios/log.txt` says which route
was taken, and the computer route always works: with the card mounted, run
the one-line command from abdess.github.io/retrobios/wiki/installer.
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# RetroBIOS for Batocera and Knulli, started from Ports: checks the BIOS
# folder and downloads the files it lacks, in the installer's full-screen
# interface. Batocera gives the controller the keys of RetroBIOS.sh.keys
# (evmapy) while this runs.
#
# The interface needs a terminal the player can see, and EmulationStation
# covers the console. In order: xterm or foot in the graphical session, else
# console 4 switched to and back from. Console 4 because Batocera's exit
# hotkey sends Alt+F4 (batocera_launch emulators/sh.py), which on a console
# switches to console 4: there it leaves the interface where it is.
HERE="$(cd "$(dirname "$0")" && pwd)"
RB_DIR="$HERE/retrobios"
RB_ROUTES="app vt"
RB_VT_SWITCH="${RB_VT_SWITCH:-4}"
RB_PACK_PATH="$RB_DIR:$HERE:/userdata"
RB_LOG="$RB_DIR/log.txt"
# The X11 builds start EmulationStation on display :0, which a script run
# from it does not always inherit.
if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ] && [ -S /tmp/.X11-unix/X0 ]; then
export DISPLAY=:0
fi
# shellcheck source=../shared/tui.txt
source "$RB_DIR/tui.txt"
: > "$RB_LOG"
rb_select_python
# A launcher's own arguments are not install.py's (muOS passes the app folder).
# shellcheck disable=SC2119
rb_launch
status=$?
rb_release_python
exit $status
+20
View File
@@ -0,0 +1,20 @@
{
"actions_player1": [
{"trigger": "a", "type": "key", "target": ["KEY_ENTER"]},
{"trigger": "b", "type": "key", "target": ["KEY_ESC"]},
{"trigger": "x", "type": "key", "target": ["KEY_SPACE"]},
{"trigger": "y", "type": "key", "target": ["KEY_TAB"]},
{"trigger": "start", "type": "key", "target": ["KEY_ENTER"]},
{"trigger": "select", "type": "key", "target": ["KEY_ESC"]},
{"trigger": "pageup", "type": "key", "target": ["KEY_PAGEUP"]},
{"trigger": "pagedown", "type": "key", "target": ["KEY_PAGEDOWN"]},
{"trigger": "up", "type": "key", "target": ["KEY_UP"]},
{"trigger": "down", "type": "key", "target": ["KEY_DOWN"]},
{"trigger": "left", "type": "key", "target": ["KEY_LEFT"]},
{"trigger": "right", "type": "key", "target": ["KEY_RIGHT"]},
{"trigger": "joystick1up", "type": "key", "target": ["KEY_UP"]},
{"trigger": "joystick1down", "type": "key", "target": ["KEY_DOWN"]},
{"trigger": "joystick1left", "type": "key", "target": ["KEY_LEFT"]},
{"trigger": "joystick1right", "type": "key", "target": ["KEY_RIGHT"]}
]
}
+16
View File
@@ -0,0 +1,16 @@
# RetroBIOS for muOS
Copy `RetroBIOS.muxapp` to the card, open it from muOS's archive manager to
install it, then start **RetroBIOS** from **Applications**.
It checks the BIOS folder (`/run/muos/storage/bios`) against the files its
emulators read and installs the missing ones: downloaded with a network
connection, or from a pack copied to the root of the card without one.
It runs in muxterm, muOS's terminal. muxterm already sends the D-pad as
arrows, Start as Enter, B as Backspace (back) and Y as Space (tick). With
PortMaster installed, gptokeyb2 adds A (choose), X (tick) and L1/R1 (page).
Menu closes it.
Not tried on a device yet: the icon, and whether muxterm and gptokeyb2 share
the pad as described.
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# HELP: Checks the BIOS folder and installs the missing BIOS files
# ICON: retrobios
# GRID: RetroBIOS
# RetroBIOS for muOS, from Applications: the installer's full-screen
# interface in muxterm, the terminal muOS's own Terminal core runs, with
# PortMaster's gptokeyb2 adding the keys muxterm leaves unmapped. Without
# muxterm it runs on the console with the full mapping.
MUOS="${RB_MUOS:-/opt/muos}"
# shellcheck source=/dev/null
. "$MUOS/script/var/retro.sh"
SETUP_APP "retrobios" ""
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
ROM_MOUNT="$(GET_VAR "device" "storage/rom/mount")"
PM_DIR="$ROM_MOUNT/MUOS/PortMaster"
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"
# shellcheck source=../shared/tui.txt
source "$APP_DIR/tui.txt"
: > "$RB_LOG"
if [ -x "$PM_DIR/gptokeyb2" ]; then
# As muOS's own launchers start it (script/launch/ext-xroar.sh).
ln -sf "$PM_DIR/libinterpose.aarch64.so" "/usr/lib/libinterpose.aarch64.so" >/dev/null 2>&1
RB_MAPPER="$PM_DIR/gptokeyb2"
fi
if [ -x "$MUXTERM" ]; then
RB_ROUTES="app"
RB_TERMAPP=("$MUXTERM" --gl --)
RB_GPTK="$APP_DIR/muxterm.gptk"
else
RB_ROUTES="vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}"
RB_GPTK="$APP_DIR/retrobios.gptk"
fi
rb_select_python
# A launcher's own arguments are not install.py's (muOS passes the app folder).
# shellcheck disable=SC2119
rb_launch
status=$?
rb_release_python
exit $status
+10
View File
@@ -0,0 +1,10 @@
# RetroBIOS inside muxterm (gptokeyb2). muxterm already sends the D-pad as
# arrows, Start as Enter, B as Backspace and Y as Space to the program it
# runs (MustardOS/terminal input.c and osk.c), so mapping those here too
# would press each key twice. Only what muxterm leaves unmapped is added.
[controls]
overlay = clear
a = enter
x = space
l1 = pageup
r1 = pagedown
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Turn install.py --events into tab-separated records for the launcher.
"""Turn install.py --events into tab-separated records for the launchers.
The launcher is bash and draws through PortMaster's dialog FIFO, so it reads
one record per line with `IFS=$'\\t' read`. Text fields are cleaned for that
FIFO: PortMasterDialog sends its arguments through an unquoted
`echo $(printf ...)`, where a tab or newline splits a field and `*`, `?` or
`[` expand as a file pattern.
The launchers are bash, so they read one record per line with
`IFS=$'\\t' read`: tui.txt reads the system and its BIOS folder to name the
pack an offline run needs, and the PortMaster screens draw through
PortMaster's dialog FIFO. Text fields are cleaned for that FIFO:
PortMasterDialog sends its arguments through an unquoted `echo $(printf ...)`,
where a tab or newline splits a field and `*`, `?` or `[` expand as a file
pattern.
rbevents.py status EVENTS records of a --check run
rbevents.py follow SUMMARY an install run on stdin: progress
+31
View File
@@ -0,0 +1,31 @@
# RetroBIOS full-screen interface: the menu keys dArkOS, AmberELEC and the
# PortMaster dialog ports give gptokeyb. A chooses, B goes back, X ticks a
# system, Y opens the options, L1 and R1 page through a list. Start + Select
# closes the interface (gptokeyb kill mode).
back = esc
start = enter
guide = \"
a = enter
b = esc
x = space
y = tab
l1 = pageup
l2 = \"
l3 = \"
r1 = pagedown
r2 = \"
r3 = \"
up = up
up = repeat
down = down
down = repeat
left = left
right = right
left_analog_up = up
left_analog_down = down
left_analog_left = left
left_analog_right = right
right_analog_up = \"
right_analog_down = \"
right_analog_left = \"
right_analog_right = \"
+567
View File
@@ -0,0 +1,567 @@
#!/bin/bash
# shellcheck shell=bash
# RetroBIOS on a handheld: runs install.py --tui on a terminal the player can
# see, with the controller mapped to the keys the interface reads.
#
# Sourced by every launcher (the PortMaster port, the Batocera and Knulli
# port, the muOS app, the Tools entry), each of which sets the inputs below
# and calls rb_select_python, then rb_launch. Run as `bash tui.txt here`, it
# is the part that runs inside a terminal app the launcher opened.
#
# Inputs, all optional:
# RB_ROUTES where to look for a terminal, in order: here (this
# script's own stdin and stdout), app (foot or xterm in the
# graphical session), vt (a Linux console). Default "app vt".
# RB_CONSOLE the console of the vt route (default /dev/tty0)
# RB_VT_SWITCH a console number to switch to when a graphical session
# covers the current one (Batocera, Knulli)
# RB_MAPPER the command that starts gptokeyb, kill flag included
# ("$GPTOKEYB" in a PortMaster port), empty for none
# 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
RB_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
RB_DIR="${RB_DIR:-$(dirname "$RB_LIB")}"
RB_INSTALL="${RB_INSTALL:-$RB_DIR/install.py}"
RB_EVENTS="${RB_EVENTS:-$RB_DIR/rbevents.py}"
RB_GPTK="${RB_GPTK:-$RB_DIR/retrobios.gptk}"
RB_STATE="${RB_STATE:-$RB_DIR/run}"
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}"
RB_VT_ACTIVE="${RB_VT_ACTIVE:-/sys/class/tty/tty0/active}"
RB_PROBE_URL="${RETROBIOS_BASE_URL:-https://raw.githubusercontent.com/Abdess/retrobios/}"
RB_RUNTIME="python_3.11"
RB_RELEASES="github.com/Abdess/retrobios/releases/latest"
RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer"
RB_PYTHON="${RB_PYTHON:-}"
RB_PYTHONHOME="${RB_PYTHONHOME:-}"
RB_PYTHON_PROBLEM="${RB_PYTHON_PROBLEM:-}"
RB_MOUNTED=""
RB_MAPPER_PID=""
RB_TTY=""
RB_STTY=""
RB_FONT_BACK=""
RB_VT_BACK=""
RB_ROUTE=""
RB_TERMAPP=()
rb_arch() {
uname -m
}
rb_py() {
if [ -n "$RB_PYTHONHOME" ]; then
PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@"
else
"$RB_PYTHON" "$@"
fi
}
# --- Python ---------------------------------------------------------------
rb_python_ok() {
"$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1
}
# The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11
# runtime on aarch64, mounted as PortMaster's own Python ports do. A launcher
# that can download the runtime defines rb_fetch_runtime. Sets
# RB_PYTHON_PROBLEM when neither can run install.py.
rb_select_python() {
if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then
RB_PYTHON="python3"
return 0
fi
if [ "$(rb_arch)" != "aarch64" ]; then
RB_PYTHON_PROBLEM="old"
return 1
fi
rb_runtime
}
rb_runtime() {
local python_dir="$HOME/$RB_RUNTIME"
local squashfs="${controlfolder:-/nonexistent}/libs/$RB_RUNTIME.squashfs"
if [ ! -f "$squashfs" ]; then
if ! declare -F rb_fetch_runtime >/dev/null; then
RB_PYTHON_PROBLEM="runtime_missing"
return 1
fi
rb_fetch_runtime "$squashfs" || return 1
fi
mkdir -p "$python_dir"
if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then
$ESUDO umount "$python_dir" >/dev/null 2>&1
fi
if ! $ESUDO mount "$squashfs" "$python_dir"; then
RB_PYTHON_PROBLEM="runtime_failed"
return 1
fi
RB_MOUNTED="$python_dir"
RB_PYTHON="$python_dir/bin/python3"
RB_PYTHONHOME="$python_dir"
if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then
RB_PYTHON=""
RB_PYTHON_PROBLEM="runtime_failed"
return 1
fi
return 0
}
rb_release_python() {
if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then
$ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1
fi
RB_MOUNTED=""
}
rb_python_text() {
local found
found=$(python3 --version 2>&1 | awk '{print $2}')
case "$RB_PYTHON_PROBLEM" in
old) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch). $RB_PC_ROUTE" ;;
runtime_offline) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download. $RB_PC_ROUTE" ;;
runtime_missing) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}. The RetroBIOS port from PortMaster downloads PortMaster's $RB_RUNTIME runtime, which this entry then uses too. $RB_PC_ROUTE" ;;
*) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run. $RB_PC_ROUTE" ;;
esac
}
# --- Network and packs ----------------------------------------------------
# Whether the file list can be reached. Only a failure to resolve or to
# connect counts as offline: a certificate refused by an old curl still means
# a network, and install.py names that problem itself.
rb_online() {
local code
if command -v curl >/dev/null 2>&1; then
curl -sSI --max-time 8 "$RB_PROBE_URL" >/dev/null 2>&1
code=$?
case "$code" in
5|6|7|28) return 1 ;;
*) return 0 ;;
esac
fi
if command -v wget >/dev/null 2>&1; then
wget -q --spider -T 8 "$RB_PROBE_URL" >/dev/null 2>&1
[ $? -ne 4 ]
return
fi
# Neither tool: a default route is the best guess there is.
awk 'NR > 1 && $2 == "00000000" { found = 1 } END { exit !found }' "$RB_ROUTE_TABLE" 2>/dev/null
}
# The system and BIOS folder install.py finds here, read without a network:
# detection is local, and the run stops where the file list would download.
rb_detect() {
RB_PLATFORM=""
RB_PLATFORM_NAME=""
RB_BIOS_DIR=""
mkdir -p "$RB_STATE"
rb_py "$RB_INSTALL" --check --events > "$RB_STATE/detect.jsonl" 2>> "$RB_LOG"
local kind a b c
while IFS=$'\t' read -r kind a b c; do
if [ "$kind" = "platform" ]; then
RB_PLATFORM="$a"
RB_PLATFORM_NAME="$b"
RB_BIOS_DIR="$c"
[ "$RB_BIOS_DIR" = "-" ] && RB_BIOS_DIR=""
break
fi
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).
rb_find_pack() {
local dir file base stem first="" want=""
if [ -n "${RB_PLATFORM_NAME:-}" ]; then
want="_${RB_PLATFORM_NAME// /_}_"
fi
local IFS=":"
for dir in $RB_PACK_PATH; do
[ -d "$dir" ] || continue
for file in "$dir"/*_BIOS_Pack.zip "$dir"/*_BIOS_Pack.part1of*.zip; do
[ -f "$file" ] || continue
base="${file##*/}"
stem="_${base%%_BIOS_Pack*}_"
if [ -n "$want" ] && [[ "$stem" == *"$want"* ]]; then
printf '%s\n' "$file"
return 0
fi
[ -z "$first" ] && first="$file"
done
done
[ -n "$first" ] && printf '%s\n' "$first"
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
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)"
fi
printf '%s\n' "No network connection. To install without one, download $pack from $RB_RELEASES on a computer, copy it into $dir on the SD card, then start RetroBIOS again. Or connect to Wi-Fi and start again."
}
# --- Terminal -------------------------------------------------------------
rb_graphical() {
[ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${DISPLAY:-}" ] \
|| pgrep -x 'sway|weston|labwc|cage|gamescope|Xorg|X' >/dev/null 2>&1
}
# A terminal app the graphical session can show, full screen. A launcher
# that knows its own (muOS's muxterm) sets RB_TERMAPP after sourcing this.
rb_terminal_app() {
if [ "${#RB_TERMAPP[@]}" -gt 0 ]; then
return 0
fi
if [ -z "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then
return 1
fi
if command -v foot >/dev/null 2>&1; then
RB_TERMAPP=(foot -F --)
elif command -v xterm >/dev/null 2>&1; then
RB_TERMAPP=(xterm -fullscreen -e)
else
return 1
fi
}
# Sets RB_ROUTE to the first of RB_ROUTES that can show text: here, app,
# vt, or none.
rb_route() {
local route
RB_ROUTE="none"
for route in $RB_ROUTES; do
case "$route" in
here)
if [ -t 0 ] && [ -t 1 ]; then
RB_ROUTE="here"
return 0
fi
;;
app)
if rb_terminal_app; then
RB_ROUTE="app"
return 0
fi
;;
vt)
# A compositor or X server holds the console it runs on: text
# written there is never seen, so the console needs a switch.
if rb_graphical; then
if [ -n "${RB_VT_SWITCH:-}" ] && command -v chvt >/dev/null 2>&1 \
&& [ -n "$(rb_active_vt)" ]; then
RB_ROUTE="vt"
return 0
fi
elif [ -c "$RB_CONSOLE" ]; then
RB_ROUTE="vt"
return 0
fi
;;
esac
done
return 1
}
# The largest Terminus font that keeps 56 columns and 20 rows, the way
# dArkOS's own tools pick one per screen (Lat7-Terminus16 on 480x320,
# TerminusBold22x11 on 640x480, 32x16 on 1024x768). Only changed when the
# font in place can be put back: saved with setfont -O, or the
# Lat7-Terminus20x10 ArkOS tools restore.
rb_font_set() {
command -v setfont >/dev/null 2>&1 || return 0
local size width height spec fh fw name file="" fallback
size=$(cat "$RB_FB" 2>/dev/null) || return 0
width="${size%%,*}"
height="${size##*,}"
case "$width$height" in
''|*[!0-9]*) return 0 ;;
esac
for spec in "32 16 Lat7-TerminusBold32x16" "28 14 Lat7-TerminusBold28x14" \
"22 11 Lat7-TerminusBold22x11" "20 10 Lat7-TerminusBold20x10" \
"16 8 Lat7-Terminus16"; do
read -r fh fw name <<< "$spec"
if [ $((width / fw)) -ge 56 ] && [ $((height / fh)) -ge 20 ] \
&& [ -f "$RB_FONTS/$name.psf.gz" ]; then
file="$RB_FONTS/$name.psf.gz"
break
fi
done
if [ -z "$file" ] && [ $((width / 8)) -ge 40 ] && [ $((height / 16)) -ge 12 ] \
&& [ -f "$RB_FONTS/Lat7-Terminus16.psf.gz" ]; then
file="$RB_FONTS/Lat7-Terminus16.psf.gz"
fi
[ -n "$file" ] || return 0
if $ESUDO setfont -C "$RB_TTY" -O "$RB_STATE/font.psf" "$file" >> "$RB_LOG" 2>&1 \
&& [ -s "$RB_STATE/font.psf" ]; then
RB_FONT_BACK="$RB_STATE/font.psf"
return 0
fi
fallback="$RB_FONTS/Lat7-Terminus20x10.psf.gz"
if [ -f "$fallback" ] && $ESUDO setfont -C "$RB_TTY" "$file" >> "$RB_LOG" 2>&1; then
RB_FONT_BACK="$fallback"
fi
}
rb_font_restore() {
if [ -n "$RB_FONT_BACK" ]; then
$ESUDO setfont -C "$RB_TTY" "$RB_FONT_BACK" >> "$RB_LOG" 2>&1
fi
RB_FONT_BACK=""
}
# The number of the console in front, which a switch has to come back to.
rb_active_vt() {
local active
active=$(cat "$RB_VT_ACTIVE" 2>/dev/null)
active="${active#tty}"
case "$active" in
''|*[!0-9]*) active=$(fgconsole 2>/dev/null) ;;
esac
case "$active" in
''|*[!0-9]*) return 1 ;;
esac
printf '%s\n' "$active"
}
rb_vt_enter() {
RB_TTY="$RB_CONSOLE"
if rb_graphical && [ -n "${RB_VT_SWITCH:-}" ]; then
RB_VT_BACK=$(rb_active_vt)
RB_TTY="${RB_VT_PREFIX:-/dev/tty}$RB_VT_SWITCH"
$ESUDO chvt "$RB_VT_SWITCH" >> "$RB_LOG" 2>&1
fi
if [ ! -r "$RB_TTY" ] || [ ! -w "$RB_TTY" ]; then
$ESUDO chmod 666 "$RB_TTY" >> "$RB_LOG" 2>&1
fi
RB_STTY=$(stty -g < "$RB_TTY" 2>/dev/null)
printf '\033c' > "$RB_TTY"
rb_font_set
}
rb_vt_leave() {
[ -n "$RB_TTY" ] || return 0
rb_font_restore
printf '\033c' > "$RB_TTY" 2>/dev/null
if [ -n "$RB_STTY" ]; then
stty "$RB_STTY" < "$RB_TTY" 2>/dev/null
fi
if [ -n "$RB_VT_BACK" ]; then
$ESUDO chvt "$RB_VT_BACK" >> "$RB_LOG" 2>&1
fi
RB_TTY=""
RB_STTY=""
RB_VT_BACK=""
}
# gptokeyb on a name only this installer's processes carry: kill mode runs
# pkill -f on it, and the path of install.py is in the command line of the
# interface and of every run it starts, never in python3's alone.
rb_mapper_start() {
[ -n "${RB_MAPPER:-}" ] || return 0
# shellcheck disable=SC2086 # RB_MAPPER is a command and its flags, as $GPTOKEYB is
$RB_MAPPER "$RB_INSTALL" -c "$RB_GPTK" >> "$RB_LOG" 2>&1 &
RB_MAPPER_PID=$!
}
rb_mapper_stop() {
[ -n "$RB_MAPPER_PID" ] || return 0
$ESUDO kill "$RB_MAPPER_PID" >/dev/null 2>&1
wait "$RB_MAPPER_PID" 2>/dev/null
RB_MAPPER_PID=""
}
# A screen of text on the current terminal, closed by any button.
rb_message() {
local cols
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' "Press A or Enter to close."
read -r -s -n 1 -t "${RB_MESSAGE_WAIT:-300}" _
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
# card when there is no network. The status also goes to RB_STATE/status,
# since a terminal app does not always pass its child's on.
rb_here() {
local status pack args=()
mkdir -p "$RB_STATE"
rm -f "$RB_STATE/status"
case "${TERM:-}" in
''|dumb|unknown) export TERM=xterm ;;
esac
if [ -z "$RB_PYTHON" ]; then
rb_message "$(rb_python_text)"
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
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")
if [ -n "$RB_PLATFORM" ]; then
args+=(--platform "$RB_PLATFORM")
fi
fi
rb_py "$RB_INSTALL" --tui "${args[@]}" "$@" 2> "$RB_STATE/tui.err"
status=$?
cat "$RB_STATE/tui.err" >> "$RB_LOG" 2>/dev/null
if [ "$status" = "2" ] && grep -q -- "--tui cannot open" "$RB_STATE/tui.err"; then
status=125
if [ "${RB_FALLBACK:-0}" != "1" ]; then
rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG."
fi
elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then
rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG."
fi
printf '%s\n' "$status" > "$RB_STATE/status"
return "$status"
}
rb_cleanup() {
rb_vt_leave
rb_mapper_stop
}
# The whole session: the terminal, the mapper, the interface, then the
# console, its font and the mapper put back. Returns the interface's status,
# 125 when no terminal could show it.
rb_launch() {
local status
mkdir -p "$RB_STATE"
if ! rb_route; then
printf '%s\n' "RetroBIOS: no terminal to show the interface on (routes: $RB_ROUTES)" >> "$RB_LOG"
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
case "$RB_ROUTE" in
here)
rb_here "$@"
status=$?
;;
app)
"${RB_TERMAPP[@]}" bash "$RB_LIB" here "$@" 2>> "$RB_LOG"
status=$(cat "$RB_STATE/status" 2>/dev/null)
;;
vt)
rb_vt_enter
export TERM=linux
# shellcheck disable=SC2094 # a console is read and written at once by design
rb_here "$@" < "$RB_TTY" > "$RB_TTY" 2>> "$RB_LOG"
status=$?
;;
esac
rb_cleanup
trap - TERM HUP
return "${status:-1}"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
case "${1:-}" in
here)
shift
rb_here "$@"
exit $?
;;
*)
printf '%s\n' "usage: bash tui.txt here [install.py options]" >&2
exit 2
;;
esac
fi
+20
View File
@@ -0,0 +1,20 @@
# RetroBIOS in the Tools menu
Copy `RetroBIOS.sh` and the `retrobios` folder next to the other tools:
| System | Folder | Menu |
|--------|--------|------|
| ArkOS, dArkOS | `tools` on the EASYROMS partition (`/roms/tools`) | Options, Tools |
| ROCKNIX, JELOS | `/storage/.config/modules` | Tools |
| AmberELEC | `/storage/.config/distribution/modules` | Tools |
It checks the BIOS folder against the files its emulators read and installs
the missing ones: downloaded with a network connection, or from a pack copied
into the `retrobios` folder without one.
Controls: D-pad to move, A or Start to choose, B or Select to go back, X to
tick a system, Y for the options, L1 and R1 to page, Start + Select to quit.
ArkOS has Python 3.7, older than the installer needs: RetroBIOS uses the
Python runtime the RetroBIOS PortMaster port downloads, so start that port
once first.
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# RetroBIOS from the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS and
# AmberELEC: checks the BIOS folder and downloads the files it lacks, in the
# installer's full-screen interface, each firmware the way its own tools do.
#
# ArkOS, dArkOS console 1 with a Terminus font, /opt/inttools/gptokeyb
# (dArkOS_Tools/Wifi.sh)
# AmberELEC /dev/console, TERM=linux, the system gptokeyb
# (modules/SetTime.sh)
# ROCKNIX, JELOS the foot or weston terminal the Tools menu opens, then a
# graphical terminal or the console, with gptokeyb
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="${RB_ROOT:-}"
RB_DIR="$HERE/retrobios"
RB_LOG="$RB_DIR/log.txt"
ESUDO=""
OS_NAME=""
if [ -f "$ROOT/etc/os-release" ]; then
OS_NAME=$(awk -F= '$1 == "NAME" { gsub(/"/, "", $2); print $2 }' "$ROOT/etc/os-release")
fi
rb_gptokeyb() {
local candidate
for candidate in "$@"; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
command -v gptokeyb 2>/dev/null
}
if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then
# ArkOS and dArkOS run EmulationStation as a user with sudo.
if sudo -n true >/dev/null 2>&1; then
ESUDO="sudo"
fi
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" ] \
&& export SDL_GAMECONTROLLERCONFIG_FILE="$ROOT/opt/inttools/gamecontrollerdb.txt"
elif [ "$OS_NAME" = "AmberELEC" ]; then
RB_ROUTES="vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/console}"
RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms"
controlfolder="/storage/roms/ports/PortMaster"
mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb")
[ -f /storage/.config/SDL-GameControllerDB/gamecontrollerdb.txt ] \
&& export SDL_GAMECONTROLLERCONFIG_FILE="/storage/.config/SDL-GameControllerDB/gamecontrollerdb.txt"
else
# ROCKNIX, JELOS and the other systems built like them: the Tools menu
# runs this inside foot on sway builds, so its own terminal comes first.
RB_ROUTES="here app vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}"
RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms"
controlfolder="/storage/roms/ports/PortMaster"
mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb")
fi
# Kill mode on the installer's own path: Start + Select closes the
# interface, never another program.
RB_MAPPER=""
[ -n "$mapper" ] && RB_MAPPER="$ESUDO $mapper -1"
# shellcheck source=../shared/tui.txt
source "$RB_DIR/tui.txt"
: > "$RB_LOG"
rb_select_python
# A launcher's own arguments are not install.py's (muOS passes the app folder).
# shellcheck disable=SC2119
rb_launch
status=$?
rb_release_python
exit $status
+100 -36
View File
@@ -1,14 +1,25 @@
#!/usr/bin/env python3
"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip.
"""Build the handheld launchers in ports/ into their archives in dist/.
The archive has the layout PortMaster's own release build gives a port
RetroBIOS.zip the PortMaster port (ports/RetroBIOS)
RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera)
RetroBIOS.muxapp muOS, from Applications (ports/muos)
RetroBIOS-tools.zip the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS
and AmberELEC (ports/tools)
Each carries ports/shared (tui.txt, which runs the full-screen interface,
rbevents.py and the controller mapping) beside its own launcher. The
PortMaster archive has the layout PortMaster's own release build gives a port
(PortMaster-New tools/build_release.py): the launch script at the top, and
one folder holding port.json, gameinfo.xml, the README as <name>.md, the
licenses and the port's files. install.py is copied from the repository
root, and only when it is the installer the bootstraps pin, so the port
runs the same bytes as the one-line command of the same commit. Members are
written as the packs write theirs: fixed timestamps, sorted names, the
executable bit read from the source, so the same tree gives the same zip.
licenses and the port's files. A muOS application archive is extracted into
the applications folder, so its top folder is the application.
install.py is copied from the repository root, and only when it is the
installer the bootstraps pin, so every launcher runs the same bytes as the
one-line command of the same commit. Members are written as the packs write
theirs: fixed timestamps, sorted names, the executable bit read from the
source, so the same tree gives the same archives.
Usage:
python scripts/build_port.py
@@ -27,10 +38,14 @@ from pathlib import Path
from artifacts import ArtifactLockBusy, artifact_lock
REPO_ROOT = Path(__file__).resolve().parent.parent
PORT_SOURCE = REPO_ROOT / "ports" / "RetroBIOS"
PORT_DIR = "retrobios"
PORT_SCRIPT = "RetroBIOS.sh"
PORT_ZIP = "RetroBIOS.zip"
BATOCERA_ZIP = "RetroBIOS-batocera.zip"
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)
_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE)
@@ -42,66 +57,115 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str:
return found.group(1)
def port_members(root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
"""(name in the archive, source file) for every member, sorted by name."""
source = root / "ports" / "RetroBIOS"
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]:
"""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"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_DIR}/port.json", source / "port.json"),
(f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"),
(f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"),
(f"{PORT_DIR}/install.py", root / "install.py"),
(f"{PORT_DIR}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
(f"{PORT_DIR}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
(f"{folder}/install.py", root / "install.py"),
(f"{folder}/tui.txt", shared / "tui.txt"),
(f"{folder}/rbevents.py", shared / "rbevents.py"),
(f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
(f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
]
members += [
(f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path)
for path in sorted((source / PORT_DIR).rglob("*"))
if path.is_file() and "__pycache__" not in path.parts
]
names = [name for name, _path in members]
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."""
ports = root / "ports"
if name == PORT_ZIP:
source = ports / "RetroBIOS"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_DIR}/port.json", source / "port.json"),
(f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"),
(f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"),
*_engine(PORT_DIR, root),
]
members += [
(f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path)
for path in sorted((source / PORT_DIR).rglob("*"))
if path.is_file() and "__pycache__" not in path.parts
]
elif name == BATOCERA_ZIP:
source = ports / "batocera"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_SCRIPT}.keys", source / f"{PORT_SCRIPT}.keys"),
(f"{PORT_DIR}/README.md", source / "README.md"),
# evmapy reads RetroBIOS.sh.keys: no gptokeyb there.
*_engine(PORT_DIR, root, mapping=False),
]
elif name == MUOS_APP:
source = ports / "muos"
members = [
("RetroBIOS/mux_launch.sh", source / "mux_launch.sh"),
("RetroBIOS/muxterm.gptk", source / "muxterm.gptk"),
("RetroBIOS/README.md", source / "README.md"),
*_engine("RetroBIOS", root),
]
elif name == TOOLS_ZIP:
source = ports / "tools"
members = [
(PORT_SCRIPT, source / PORT_SCRIPT),
(f"{PORT_DIR}/README.md", source / "README.md"),
*_engine(PORT_DIR, root),
]
else:
raise ValueError(f"no launcher archive is named {name}")
names = [member for member, _path in members]
if len(set(names)) != len(names):
raise ValueError("two port members share a name")
raise ValueError(f"two members of {name} share a name")
return sorted(members)
def build_port(output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the port archive to *output* and return it."""
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)
actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest()
if actual != pinned:
raise ValueError(
f"install.py is {actual[:12]}, install.sh pins {pinned[:12]}: "
"re-pin the bootstraps before building the port"
"re-pin the bootstraps before building the launchers"
)
# The packs' member writer: imported here, as it loads the whole
# pack builder, which a caller reading PORT_ZIP does not need.
# pack builder, which a caller reading ARCHIVES does not need.
from generate_pack import _add_pack_member # noqa: PLC0415
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 name, path in port_members(root):
_add_pack_member(zf, str(path), name)
for member, path in members:
_add_pack_member(zf, str(path), member)
scratch.replace(output)
return output
def build_port(output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the PortMaster port archive to *output* and return it."""
return build_archive(PORT_ZIP, output, root)
def main() -> int:
parser = argparse.ArgumentParser(
allow_abbrev=False, description="Build the RetroBIOS PortMaster port"
allow_abbrev=False, description="Build the RetroBIOS handheld launchers"
)
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist",
help="where RetroBIOS.zip is written (default: dist/)")
help="where the archives are written (default: dist/)")
args = parser.parse_args()
try:
with artifact_lock(str(args.output_dir)):
path = build_port(args.output_dir / PORT_ZIP)
paths = [build_archive(name, args.output_dir / name) for name in ARCHIVES]
except (ArtifactLockBusy, OSError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
digest = hashlib.sha256(path.read_bytes()).hexdigest()
print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})")
for path in paths:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})")
return 0
+6 -6
View File
@@ -3,8 +3,8 @@
Run on the draft before publishing it, and on the published release at any
time. Every pack asset release.json names must be on the release at the sha256
SHA256SUMS.txt lists for it, as must the handheld port (RetroBIOS.zip) when
the list names it, the parts of a pack must add up to the download
SHA256SUMS.txt lists for it, as must each handheld launcher (RetroBIOS.zip
and the others build_port.py writes) the list names, the parts of a pack must add up to the download
size the README prints, and nothing else may be attached. An upload that
failed in the loop, or a part re-uploaded from an older build, otherwise
publishes a release that `sha256sum --check` rejects for every user.
@@ -27,7 +27,7 @@ from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, os.path.dirname(__file__))
from build_port import PORT_ZIP
from build_port import ARCHIVES
from check_release_assets import digests_of
from largefiles import LARGE_FILES_REPO
from release_record import RECORD, load_record
@@ -93,9 +93,9 @@ def compare_release(
findings.append(Finding("extra", name, "attached but neither recorded nor listed"))
for name in sorted(set(recorded) - set(sums)):
findings.append(Finding("unlisted", name, f"absent from {SUMS}"))
# The handheld port is a release file but no pack: release.json records
# packs, and the checksum list alone vouches for the port.
for name in sorted(set(sums) - set(recorded) - {PORT_ZIP}):
# The handheld launchers are release files but no packs: release.json
# records packs, and the checksum list alone vouches for the launchers.
for name in sorted(set(sums) - set(recorded) - set(ARCHIVES)):
findings.append(Finding("unrecorded", name, f"listed in {SUMS}, absent from release.json"))
wanted = {**sums, **(local_files or {})}
+835
View File
@@ -0,0 +1,835 @@
"""The handheld launchers: their scripts, controller mappings and archives,
and what each one does on a simulated device.
A device is simulated with a pseudo-terminal standing for its console, a
fake install.py that records how it was started, and fakes for what the
firmware provides (gptokeyb, setfont, chvt, curl, foot, xterm, muxterm,
PortMaster's control.txt and muOS's retro.sh), all on PATH or in a folder
the launcher reads. What only a device shows (the text reaching the screen,
the pad reaching the console, a compositor covering it) is not exercised.
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import os
import pty
import re
import shutil
import signal
import struct
import subprocess
import sys
import tempfile
import termios
import unittest
import zipfile
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PORTS = REPO_ROOT / "ports"
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_port # noqa: E402
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
install = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(install)
SCRIPTS = [
PORTS / "shared" / "tui.txt",
PORTS / "RetroBIOS" / "RetroBIOS.sh",
PORTS / "RetroBIOS" / "retrobios" / "screens.txt",
PORTS / "batocera" / "RetroBIOS.sh",
PORTS / "muos" / "mux_launch.sh",
PORTS / "tools" / "RetroBIOS.sh",
]
# What each button does in the interface (wiki/installer.md, the full-screen
# interface): the convergent menu mapping of handheld_runtime.md.
ROLES = {
"a": "enter", "start": "enter", "b": "esc", "back": "esc", "x": "space",
"y": "tab", "l1": "pageup", "r1": "pagedown",
"up": "up", "down": "down", "left": "left", "right": "right",
}
# What a Linux console or a terminal sends for each key gptokeyb and evmapy
# emit, and the key the interface reads from it.
SEQUENCES = {
"enter": "\r", "esc": "\x1b", "space": " ", "tab": "\t",
"pageup": "\x1b[5~", "pagedown": "\x1b[6~",
"up": "\x1b[A", "down": "\x1b[B", "left": "\x1b[D", "right": "\x1b[C",
}
INTERFACE_KEYS = {
"enter": "enter", "esc": "esc", "space": "char", "tab": "tab",
"pageup": "pgup", "pagedown": "pgdn",
"up": "up", "down": "down", "left": "left", "right": "right",
}
# gptokeyb's char_to_keycode (PortsMaster/gptokeyb src/util.cpp) names.
GPTOKEYB_NAMES = set(SEQUENCES) | {"backspace", '\\"'}
def _tempdir(test: unittest.TestCase) -> Path:
scratch = REPO_ROOT / "tmp"
scratch.mkdir(exist_ok=True)
path = Path(tempfile.mkdtemp(dir=str(scratch)))
test.addCleanup(shutil.rmtree, path, True)
return path
def read_gptk(path: Path) -> dict[str, list[str]]:
"""key -> values, as gptokeyb's parseConfigFile reads `key = value`."""
mapping: dict[str, list[str]] = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or line.startswith("["):
continue
key, sep, value = line.partition(" = ")
assert sep, line
mapping.setdefault(key, []).append(value.split("#")[0].strip())
return mapping
class EveryScriptParses(unittest.TestCase):
def test_bash_n(self):
for script in SCRIPTS:
with self.subTest(script=str(script.relative_to(REPO_ROOT))):
proc = subprocess.run(["bash", "-n", str(script)], capture_output=True,
text=True, check=False)
self.assertEqual(proc.returncode, 0, proc.stderr)
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)],
capture_output=True, text=True, check=False)
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
def test_launchers_are_executable(self):
for script in (PORTS / "RetroBIOS" / "RetroBIOS.sh", PORTS / "batocera" / "RetroBIOS.sh",
PORTS / "muos" / "mux_launch.sh", PORTS / "tools" / "RetroBIOS.sh"):
with self.subTest(script=str(script.relative_to(REPO_ROOT))):
self.assertTrue(script.stat().st_mode & 0o111)
self.assertTrue(script.read_text().startswith("#!/bin/bash\n"))
def test_muos_headers(self):
"""muOS's frontend reads the HELP, ICON and GRID lines of an app
(MustardOS internal share/application/*/mux_launch.sh)."""
head = (PORTS / "muos" / "mux_launch.sh").read_text().splitlines()[1:4]
self.assertEqual([line.split(":")[0] for line in head], ["# HELP", "# ICON", "# GRID"])
self.assertTrue(all(line.split(":", 1)[1].strip() for line in head))
class ControllerMappings(unittest.TestCase):
def _decodes(self, key: str) -> str:
decoder = install.KeyDecoder()
keys = decoder.feed(SEQUENCES[key]) + decoder.flush()
self.assertEqual(len(keys), 1, key)
return keys[0].name
def test_gptk_maps_every_button_the_interface_reads(self):
mapping = read_gptk(PORTS / "shared" / "retrobios.gptk")
for button, key in ROLES.items():
with self.subTest(button=button):
self.assertEqual(mapping[button][0], key)
self.assertEqual(self._decodes(key), INTERFACE_KEYS[key])
for values in mapping.values():
self.assertIn(values[0], GPTOKEYB_NAMES | {"repeat"})
self.assertTrue(set(values[1:]) <= {"repeat"})
self.assertEqual(mapping["up"], ["up", "repeat"])
self.assertEqual(mapping["down"], ["down", "repeat"])
self.assertEqual(mapping["guide"], ['\\"'])
def test_evmapy_keys_map_the_same_buttons(self):
"""Batocera's evmapy names L1 and R1 pageup and pagedown, and Select
select (batocera-pygame evmapy.keys)."""
data = json.loads((PORTS / "batocera" / "RetroBIOS.sh.keys").read_text())
actions = {a["trigger"]: a for a in data["actions_player1"]}
triggers = {"a": "a", "start": "start", "b": "b", "back": "select", "x": "x",
"y": "y", "l1": "pageup", "r1": "pagedown", "up": "up",
"down": "down", "left": "left", "right": "right"}
for button, trigger in triggers.items():
with self.subTest(button=button):
action = actions[trigger]
self.assertEqual(action["type"], "key")
self.assertEqual(action["target"], [f"KEY_{ROLES[button].upper()}"])
for stick in ("up", "down", "left", "right"):
self.assertEqual(actions[f"joystick1{stick}"]["target"], [f"KEY_{stick.upper()}"])
self.assertEqual(len(actions), len(data["actions_player1"]))
def test_muxterm_mapping_adds_only_what_muxterm_leaves(self):
"""muxterm writes the D-pad, Start, B and Y to the program itself
(MustardOS/terminal input.c, osk.c): mapping them again would press
each twice."""
text = (PORTS / "muos" / "muxterm.gptk").read_text()
self.assertIn("[controls]\noverlay = clear\n", text)
mapping = read_gptk(PORTS / "muos" / "muxterm.gptk")
mapping.pop("overlay")
self.assertEqual(mapping, {"a": ["enter"], "x": ["space"],
"l1": ["pageup"], "r1": ["pagedown"]})
class TheArchives(unittest.TestCase):
LAYOUT = {
build_port.PORT_ZIP: ("RetroBIOS.sh", "retrobios"),
build_port.BATOCERA_ZIP: ("RetroBIOS.sh", "retrobios"),
build_port.MUOS_APP: (None, "RetroBIOS"),
build_port.TOOLS_ZIP: ("RetroBIOS.sh", "retrobios"),
}
def setUp(self):
self.out = _tempdir(self)
def test_every_release_archive_is_built(self):
self.assertEqual(set(build_port.ARCHIVES), set(self.LAYOUT))
def test_layout(self):
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:
names = zf.namelist()
modes = {n: zf.getinfo(n).external_attr >> 16 for n in names}
self.assertEqual(names, sorted(names))
top = [n for n in names if "/" not in n]
self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {folder})
for member in ("install.py", "tui.txt", "rbevents.py",
"licenses/LICENSE-retrobios.txt"):
self.assertIn(f"{folder}/{member}", names)
launcher = script or f"{folder}/mux_launch.sh"
self.assertTrue(modes[launcher] & 0o111, launcher)
if name == build_port.BATOCERA_ZIP:
self.assertEqual(top, ["RetroBIOS.sh", "RetroBIOS.sh.keys"])
self.assertNotIn("retrobios/retrobios.gptk", names)
elif script:
self.assertEqual(top, [script])
self.assertIn(f"{folder}/retrobios.gptk", names)
else:
self.assertEqual(top, [])
self.assertIn("RetroBIOS/muxterm.gptk", names)
self.assertIn("RetroBIOS/retrobios.gptk", names)
def test_each_carries_the_pinned_installer(self):
pinned = build_port.pinned_installer_sha256()
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:
bundled = hashlib.sha256(zf.read(f"{folder}/install.py")).hexdigest()
self.assertEqual(bundled, pinned)
def test_reproducible(self):
for name in build_port.ARCHIVES:
with self.subTest(archive=name):
first = build_port.build_archive(name, self.out / f"a-{name}").read_bytes()
second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes()
self.assertEqual(first, second)
def test_an_unpinned_installer_is_refused(self):
root = _tempdir(self)
for name in ("install.py", "install.sh", "LICENSE", "NOTICE"):
shutil.copy(REPO_ROOT / name, root / name)
shutil.copytree(PORTS, root / "ports")
with (root / "install.py").open("a") as fh:
fh.write("\n")
for name in build_port.ARCHIVES:
with self.subTest(archive=name), self.assertRaises(ValueError):
build_port.build_archive(name, self.out / name, root)
def test_the_release_process_attaches_each(self):
text = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8")
for name in build_port.ARCHIVES:
self.assertIn(name, text)
FAKE_INSTALL = r'''
import json, os, sys
calls = os.environ["FAKE_CALLS"]
def tty(fd):
try:
return os.ttyname(fd)
except OSError:
return ""
with open(calls, "a") as fh:
fh.write(json.dumps({"argv0": sys.argv[0], "args": sys.argv[1:], "stdin": tty(0),
"stdout": tty(1), "term": os.environ.get("TERM", ""),
"pythonhome": os.environ.get("PYTHONHOME", "")}) + "\n")
if "--check" in sys.argv:
print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS",
"platforms": [{"platform": "retroarch", "name": "RetroArch",
"bios_dir": "/roms/bios"}]}))
print(json.dumps({"event": "error", "stage": "manifest", "reason": "network",
"platform": "retroarch", "message": "unreachable"}))
print(json.dumps({"event": "end", "code": 1}))
sys.exit(1)
if os.environ.get("FAKE_REFUSE"):
print("install.py: error: --tui cannot open the full-screen interface here: "
"the terminal is 30x10, smaller than 40x12", file=sys.stderr)
sys.exit(2)
if os.environ.get("FAKE_RAW") and os.isatty(0):
import tty as _tty
_tty.setraw(0)
if os.environ.get("FAKE_ERROR"):
print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr)
sys.exit(int(os.environ.get("FAKE_CODE", "0")))
'''
# Records its arguments and stays up until it is told to stop, as gptokeyb
# does, and records that it was.
FAKE_MAPPER = '''#!/bin/sh
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; kill $child 2>/dev/null; exit 0' TERM
sleep 30 &
child=$!
wait $child
'''
FAKE_RECORDER = '''#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
'''
FAKE_SETFONT = '''#!/bin/sh
printf '%s\\n' "setfont $*" >> "$FAKE_STATE/tools.log"
while [ $# -gt 0 ]; do
[ "$1" = "-O" ] && { [ -n "$FAKE_NO_SAVE" ] && exit 1; printf 'font' > "$2"; }
shift
done
'''
# A terminal app: records how it was started, then runs its command.
FAKE_TERMAPP = '''#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
while [ $# -gt 0 ]; do
case "$1" in
--|-e) shift; break ;;
*) shift ;;
esac
done
exec "$@"
'''
class Device:
"""A simulated handheld: fake tools on PATH and a pty as its console."""
def __init__(self, test: unittest.TestCase, **env: str):
self.test = test
self.root = _tempdir(test)
self.state = self.root / "state"
self.state.mkdir()
self.bin = self.root / "bin"
self.bin.mkdir()
master, slave = pty.openpty()
test.addCleanup(os.close, master)
test.addCleanup(os.close, slave)
termios.tcsetattr(slave, termios.TCSANOW, termios.tcgetattr(slave))
import fcntl # noqa: PLC0415
# Wide enough that no path in a message is folded.
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 250, 0, 0))
self.master, self.slave = master, slave
self.console = os.ttyname(slave)
self.before = termios.tcgetattr(slave)
(self.bin / "python3").symlink_to(shutil.which("python3.8") or sys.executable)
self.tool("gptokeyb", FAKE_MAPPER)
self.tool("setfont", FAKE_SETFONT)
self.tool("chvt", FAKE_RECORDER)
# A probe that cannot connect is offline (curl exit 7).
self.tool("curl", '#!/bin/sh\nexit "${FAKE_CURL:-0}"\n')
self.tool("pgrep", '#!/bin/sh\nexit "${FAKE_PGREP:-1}"\n')
fonts = self.root / "fonts"
fonts.mkdir()
for name in ("Lat7-TerminusBold32x16", "Lat7-TerminusBold22x11",
"Lat7-TerminusBold20x10", "Lat7-Terminus16"):
(fonts / f"{name}.psf.gz").write_bytes(b"")
(self.root / "fb").write_text("640,480\n")
environ = {k: v for k, v in os.environ.items()
if k not in ("DISPLAY", "WAYLAND_DISPLAY", "RETROBIOS_BASE_URL")}
self.env = {
**environ,
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
"HOME": str(self.root),
"FAKE_STATE": str(self.state),
"FAKE_CALLS": str(self.state / "calls.jsonl"),
"RB_FONTS": str(fonts),
"RB_FB": str(self.root / "fb"),
"RB_MESSAGE_WAIT": "1",
"TERM": "dumb",
**env,
}
def tool(self, name: str, text: str) -> Path:
path = self.bin / name
# python3 starts as a link to the real interpreter: never write through it.
if path.is_symlink() or path.exists():
path.unlink()
path.write_text(text)
path.chmod(0o755)
return path
def unpack(self, archive: str, into: Path) -> Path:
"""The launcher archive extracted where the firmware keeps it, with
the fake installer in place of install.py."""
into.mkdir(parents=True, exist_ok=True)
built = build_port.build_archive(archive, self.root / "dist" / archive)
with zipfile.ZipFile(built) as zf:
zf.extractall(into)
for info in zf.infolist():
mode = info.external_attr >> 16
if mode:
(into / info.filename).chmod(mode & 0o777)
for found in into.rglob("install.py"):
found.write_text(FAKE_INSTALL)
return into
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,
stderr=subprocess.PIPE, timeout=120, check=False)
self.screen = self._drain()
return proc
def _drain(self) -> str:
chunks = []
os.set_blocking(self.master, False)
while True:
try:
chunk = os.read(self.master, 65536)
except (BlockingIOError, OSError):
break
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", "replace")
def calls(self) -> list[dict]:
path = self.state / "calls.jsonl"
if not path.exists():
return []
return [json.loads(line) for line in path.read_text().splitlines()]
def tui_calls(self) -> list[dict]:
return [c for c in self.calls() if "--tui" in c["args"]]
def tools(self) -> list[str]:
path = self.state / "tools.log"
return path.read_text().splitlines() if path.exists() else []
def mapper_args(self) -> list[str]:
path = self.state / "mapper.args"
return path.read_text().splitlines() if path.exists() else []
def assert_mapper_stopped(self) -> None:
"""The launcher stopped gptokeyb itself, before it exited."""
pid = int((self.state / "mapper.pid").read_text())
stopped = (self.state / "mapper.stopped").exists()
if not stopped:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.test.assertTrue(stopped, "gptokeyb was left running")
def assert_console_restored(self) -> None:
self.test.assertEqual(termios.tcgetattr(self.slave), self.before)
CONTROL_TXT = '''
directory="{roms}"
ESUDO=""
ESUDOKILL="-1"
CUR_TTY="{console}"
GPTOKEYB="$controlfolder/gptokeyb $ESUDOKILL"
get_controls() {{ sdl_controllerconfig="fake"; }}
'''
DIALOG_TXT = '''
PM_PID=""
PM_DONE="$FAKE_STATE/pm_done"
trace() { printf '%s\\n' "$*" >> "$FAKE_STATE/dialog.log"; }
PortMasterIPCheck() { echo 192.168.0.1; }
PortMasterDialogInit() { trace "INIT $*"; }
PortMasterDialogExit() { trace EXIT; }
PortMasterDialog() { trace "CMD $*"; }
PortMasterDialogResult() { trace "ASK $*"; echo quit; }
'''
class ThePortMasterPort(unittest.TestCase):
"""RetroBIOS.sh from the PortMaster archive, on a fake PortMaster."""
def device(self, **env: str) -> Device:
device = Device(self, **env)
roms = device.root / "roms"
self.gamedir = device.unpack(build_port.PORT_ZIP, roms / "ports") / "retrobios"
control = device.root / "xdg" / "PortMaster"
control.mkdir(parents=True)
(control / "control.txt").write_text(
CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=device.console))
(control / "PortMasterDialog.txt").write_text(DIALOG_TXT)
shutil.copy(device.bin / "gptokeyb", control / "gptokeyb")
device.env["XDG_DATA_HOME"] = str(device.root / "xdg")
self.script = str(roms / "ports" / "RetroBIOS.sh")
return device
def dialog(self, device: Device) -> list[str]:
path = device.state / "dialog.log"
return path.read_text().splitlines() if path.exists() else []
def test_online_on_the_console(self):
device = self.device()
proc = device.run(["bash", self.script], FAKE_RAW="1", FAKE_CODE="0")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
self.assertEqual(call["term"], "linux")
# Kill mode on the installer's own path, which the interface's command
# line carries: never python3.
installer = str(self.gamedir / "install.py")
self.assertEqual(device.mapper_args(),
["-1", installer, "-c", str(self.gamedir / "retrobios.gptk")])
self.assertEqual(call["argv0"], installer)
device.assert_mapper_stopped()
device.assert_console_restored()
self.assertIn("\x1bc", device.screen)
saved = str(self.gamedir / "run" / "font.psf")
fonts = device.env["RB_FONTS"]
self.assertEqual(device.tools(), [
f"setfont -C {device.console} -O {saved} {fonts}/Lat7-TerminusBold22x11.psf.gz",
f"setfont -C {device.console} {saved}",
])
self.assertEqual(self.dialog(device), [])
def test_the_interface_status_is_the_port_status(self):
device = self.device()
proc = device.run(["bash", self.script], FAKE_CODE="3", FAKE_ERROR="the folder is read-only")
self.assertEqual(proc.returncode, 3)
self.assertIn("Error: the folder is read-only", device.screen)
self.assertIn("log.txt", device.screen)
def test_offline_installs_from_a_pack_on_the_card(self):
device = self.device(FAKE_CURL="7")
roms = device.root / "roms"
(roms / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
"--platform", "retroarch"])
def test_offline_prefers_the_pack_of_this_system(self):
device = self.device(FAKE_CURL="6")
(self.gamedir / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
pack = device.root / "roms" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.part1of2.zip"
pack.write_bytes(b"")
device.run(["bash", self.script])
[call] = device.tui_calls()
self.assertEqual(call["args"][1:3], ["--from-pack", str(pack)])
def test_offline_without_a_pack_says_where_to_put_one(self):
device = self.device(FAKE_CURL="7")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("No network connection", screen)
self.assertIn("RetroArch", screen)
self.assertIn(str(self.gamedir), screen)
self.assertIn("github.com/Abdess/retrobios/releases/latest", screen)
device.assert_mapper_stopped()
device.assert_console_restored()
def test_a_certificate_failure_is_not_offline(self):
# curl 60: the network answered, install.py reports the certificate.
device = self.device(FAKE_CURL="60")
device.run(["bash", self.script])
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
def test_an_old_python_is_told_on_the_console(self):
device = self.device()
device.tool("python3", '#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n')
device.tool("uname", "#!/bin/sh\necho armv7l\n")
proc = device.run(["bash", self.script])
self.assertEqual(proc.returncode, 1)
screen = " ".join(device.screen.split())
self.assertIn("Python 3.8 or newer is needed", screen)
self.assertIn("3.7.3", screen)
self.assertIn("--dest", screen)
self.assertEqual(device.calls(), [])
def test_a_refused_console_falls_back_to_portmaster_screens(self):
device = self.device()
device.run(["bash", self.script], FAKE_REFUSE="1")
self.assertEqual(len(device.tui_calls()), 1)
dialog = self.dialog(device)
self.assertEqual(dialog[0], "INIT no-check")
self.assertIn("ASK selection_list --register=notice --want-description --want-cancel",
dialog)
device.assert_mapper_stopped()
device.assert_console_restored()
def test_under_a_compositor_without_a_terminal_app_the_screens_open(self):
device = self.device(FAKE_PGREP="0")
device.run(["bash", self.script])
self.assertEqual(device.tui_calls(), [])
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)
proc = device.run(["bash", self.script], FAKE_CODE="4")
self.assertEqual(proc.returncode, 4)
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(), [f"foot -F -- bash {self.gamedir}/tui.txt here"])
self.assertEqual(device.mapper_args()[1], str(self.gamedir / "install.py"))
device.assert_mapper_stopped()
class TheBatoceraPort(unittest.TestCase):
def device(self, **env: str) -> tuple[Device, Path]:
device = Device(self, **env)
ports = device.unpack(build_port.BATOCERA_ZIP, device.root / "userdata" / "roms" / "ports")
return device, ports
def test_xterm_in_the_x11_session(self):
device, ports = self.device(DISPLAY=":0")
device.tool("xterm", FAKE_TERMAPP)
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(),
[f"xterm -fullscreen -e bash {ports}/retrobios/tui.txt here"])
# evmapy reads RetroBIOS.sh.keys: no gptokeyb is started.
self.assertEqual(device.mapper_args(), [])
def test_console_switch_when_no_terminal_app(self):
device, ports = self.device(FAKE_PGREP="0")
(device.root / "active").write_text("tty2\n")
number = re.fullmatch(r"/dev/pts/(\d+)", device.console)
self.assertIsNotNone(number)
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")],
RB_VT_ACTIVE=str(device.root / "active"),
RB_VT_PREFIX="/dev/pts/", RB_VT_SWITCH=number.group(1))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
chvt = [line for line in device.tools() if line.startswith("chvt")]
self.assertEqual(chvt, [f"chvt {number.group(1)}", "chvt 2"])
device.assert_console_restored()
class TheMuosApp(unittest.TestCase):
def device(self, muxterm: bool) -> tuple[Device, Path]:
device = Device(self)
apps = device.unpack(build_port.MUOS_APP, device.root / "mmc" / "MUOS" / "application")
muos = device.root / "opt-muos"
(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'
'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")
pm = device.root / "mmc" / "MUOS" / "PortMaster"
pm.mkdir(parents=True)
shutil.copy(device.bin / "gptokeyb", pm / "gptokeyb2")
device.env["RB_MUOS"] = str(muos)
return device, apps / "RetroBIOS"
def test_in_muxterm(self):
device, app = self.device(muxterm=True)
# muOS starts an app as <app>/mux_launch.sh <app>.
proc = device.run([str(app / "mux_launch.sh"), str(app)])
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(), ["SETUP_APP retrobios",
f"muxterm --gl -- bash {app}/tui.txt here"])
self.assertEqual(device.mapper_args(),
[str(app / "install.py"), "-c", str(app / "muxterm.gptk")])
device.assert_mapper_stopped()
def test_on_the_console_without_muxterm(self):
device, app = self.device(muxterm=False)
proc = device.run([str(app / "mux_launch.sh"), str(app)], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
self.assertEqual(device.mapper_args()[2], str(app / "retrobios.gptk"))
device.assert_console_restored()
def test_offline_reads_a_pack_at_the_card_root(self):
device, app = self.device(muxterm=True)
pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
[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]:
device = Device(self, **env)
tools = device.unpack(build_port.TOOLS_ZIP, device.root / "tools")
device.env["RB_ROOT"] = str(device.root / "fs")
return device, tools
def test_arkos_console_one(self):
device, tools = self.device()
fs = device.root / "fs"
(fs / "opt" / "system").mkdir(parents=True)
(fs / "opt" / "inttools").mkdir(parents=True)
shutil.copy(device.bin / "gptokeyb", fs / "opt" / "inttools" / "gptokeyb")
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual((call["stdin"], call["term"]), (device.console, "linux"))
installer = str(tools / "retrobios" / "install.py")
self.assertEqual(device.mapper_args(),
["-1", installer, "-c", str(tools / "retrobios" / "retrobios.gptk")])
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"
etc.mkdir(parents=True)
(etc / "os-release").write_text('NAME="AmberELEC"\n')
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["stdin"], device.console)
self.assertEqual(device.mapper_args()[:2], ["-1", str(tools / "retrobios" / "install.py")])
def test_rocknix_runs_in_the_terminal_tools_opens(self):
device, tools = self.device()
etc = device.root / "fs" / "etc"
etc.mkdir(parents=True)
(etc / "os-release").write_text('NAME="ROCKNIX"\n')
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
self.assertEqual(call["term"], "xterm")
# The terminal app owns its window: no console reset, no font.
self.assertNotIn("\x1bc", device.screen)
self.assertEqual([t for t in device.tools() if t.startswith("setfont")], [])
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -399,6 +399,20 @@ class Flow(unittest.TestCase):
press(state, "enter")
self.assertEqual(state.exit_code, 0)
def test_every_run_reads_the_folders_given_with_from(self):
"""--tui --from OLD: the check and the install both copy from OLD."""
args = _args(**{"from": [Path("old-card"), Path("backup.zip")],
"from_pack": [Path("pack.zip")]})
flags = install._tui_passthrough(args)
self.assertIn(("--from", str(Path("old-card").absolute())), flags)
self.assertIn(("--from", str(Path("backup.zip").absolute())), flags)
self.assertIn(("--from-pack", str(Path("pack.zip").absolute())), flags)
state = checked_state()
state.passthrough = flags
for run in ("check", "install"):
self.assertIn(f"--from={Path('old-card').absolute()}",
install.tui_argv(state, run, "py", "i.py"))
def test_the_check_runs_with_the_command_line_choices(self):
state = fresh_state()
state.passthrough = [("--target", "switch")]
+49 -31
View File
@@ -1,9 +1,10 @@
"""The PortMaster port: its archive, its event reader and its screens.
"""The PortMaster port: its archive, its event reader and its fallback screens.
The screens are driven here with PortMasterDialog's functions replaced by
stubs that record each command and answer from a script, and with a fake
install.py that writes canned --events streams. What a device adds (pugwash
drawing, the pad, mount, the network) is not exercised.
drawing, the pad, mount, the network) is not exercised. The full-screen
route of the port, and the other launchers, are in test_handheld_launchers.
"""
from __future__ import annotations
@@ -12,6 +13,7 @@ import importlib.util
import io
import json
import os
import re
import shutil
import subprocess
import sys
@@ -22,6 +24,7 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PORT = REPO_ROOT / "ports" / "RetroBIOS"
SHARED = REPO_ROOT / "ports" / "shared"
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import build_port # noqa: E402
@@ -29,8 +32,7 @@ import build_port # noqa: E402
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
install = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(install)
_spec = importlib.util.spec_from_file_location(
"rbevents", PORT / "retrobios" / "rbevents.py")
_spec = importlib.util.spec_from_file_location("rbevents", SHARED / "rbevents.py")
rbevents = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(rbevents)
@@ -46,13 +48,6 @@ def _tempdir(test: unittest.TestCase) -> Path:
class EveryScriptParses(unittest.TestCase):
def test_bash_n(self):
for script in SCRIPTS:
with self.subTest(script=script.name):
proc = subprocess.run(["bash", "-n", str(script)], capture_output=True,
text=True, check=False)
self.assertEqual(proc.returncode, 0, proc.stderr)
def test_only_dialog_functions_the_oldest_releases_have(self):
"""PortMaster 2024.02.26 and 8.5.23 define these in PortMasterDialog.txt,
and have no funcs.txt: pm_message and the rest would be undefined."""
@@ -62,7 +57,6 @@ class EveryScriptParses(unittest.TestCase):
"messages_begin", "message", "messages_end", "progress",
"progress_clear", "check_runtime"}
text = "\n".join(path.read_text(encoding="utf-8") for path in SCRIPTS)
import re # noqa: PLC0415
used = set(re.findall(r"\b(PortMasterDialog\w*|PortMasterIPCheck)\b", text))
self.assertLessEqual(used - {"PortMasterDialog.txt"}, known)
self.assertNotRegex(text, r"\bpm_message\b|\bpm_platform_helper\b")
@@ -70,6 +64,15 @@ class EveryScriptParses(unittest.TestCase):
self.assertLessEqual(sent, commands)
self.assertNotIn("register_clear_info", text)
def test_pugwash_applies_the_button_layout(self):
"""Under no-harbour pugwash skips apply_button_mapping, so A and B
land swapped on the devices PortMaster fixes (PortMaster-GUI
pugwash/main.py, app.py): every start is no-check."""
text = (PORT / "retrobios" / "screens.txt").read_text(encoding="utf-8")
inits = re.findall(r'PortMasterDialogInit "([\w-]+)"', text)
self.assertTrue(inits)
self.assertEqual(set(inits), {"no-check"})
class ThePortArchive(unittest.TestCase):
def setUp(self):
@@ -90,7 +93,8 @@ class ThePortArchive(unittest.TestCase):
self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {"retrobios"})
self.assertEqual([n for n in names if n.endswith(".sh")], ["RetroBIOS.sh"])
for member in ("retrobios/install.py", "retrobios/rbevents.py",
"retrobios/screens.txt", "retrobios/gameinfo.xml",
"retrobios/screens.txt", "retrobios/tui.txt",
"retrobios/retrobios.gptk", "retrobios/gameinfo.xml",
"retrobios/retrobios.md", "retrobios/licenses/LICENSE-retrobios.txt"):
self.assertIn(member, names)
self.assertEqual(port["version"], 4)
@@ -106,21 +110,6 @@ class ThePortArchive(unittest.TestCase):
install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8")
self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh)
def test_an_unpinned_installer_is_refused(self):
root = _tempdir(self)
for name in ("install.py", "install.sh", "LICENSE", "NOTICE"):
shutil.copy(REPO_ROOT / name, root / name)
shutil.copytree(PORT, root / "ports" / "RetroBIOS")
with (root / "install.py").open("a") as fh:
fh.write("\n")
with self.assertRaises(ValueError):
build_port.build_port(self.out / "x.zip", root)
def test_reproducible(self):
first = self._build("a.zip").read_bytes()
second = self._build("b.zip").read_bytes()
self.assertEqual(first, second)
class EventRecords(unittest.TestCase):
"""rbevents turns --events into fields the FIFO carries intact."""
@@ -277,10 +266,12 @@ PortMasterDialogResult() {
*) echo "$answer" ;;
esac
}
source "$RB_SHARED/tui.txt"
source "$GAMEDIR/screens.txt"
if [ -n "$FAKE_ARCH" ]; then
rb_arch() { echo "$FAKE_ARCH"; }
fi
rb_select_python
rb_main
trace STATUS "$?"
'''
@@ -289,7 +280,7 @@ trace STATUS "$?"
class TheScreens(unittest.TestCase):
"""Which screens the port shows, scenario by scenario."""
def run_port(self, scenario: str, answers: list[str], **env: str) -> list[tuple]:
def run_port(self, scenario: str, answers: list[str], before=None, **env: str) -> list[tuple]:
work = _tempdir(self)
(work / "install.py").write_text(FAKE_INSTALL)
(work / "driver.sh").write_text(DRIVER)
@@ -299,12 +290,21 @@ class TheScreens(unittest.TestCase):
# The oldest Python install.py supports, where it is installed: the
# event reader runs on whatever the device has.
(bin_dir / "python3").symlink_to(shutil.which("python3.8") or sys.executable)
# The network probe: 0 answers, 7 cannot connect.
(bin_dir / "curl").write_text('#!/bin/sh\nexit "${FAKE_CURL:-0}"\n')
(bin_dir / "curl").chmod(0o755)
(work / "packs").mkdir()
if before is not None:
before(work)
self.work = work
full_env = {
**os.environ,
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"HOME": str(work),
"GAMEDIR": str(PORT / "retrobios"),
"RB_SHARED": str(SHARED),
"RB_EVENTS": str(SHARED / "rbevents.py"),
"RB_PACK_PATH": str(work / "packs"),
"controlfolder": str(work / "PortMaster"),
"ESUDO": "",
"RB_STATE": str(work / "run"),
@@ -367,7 +367,7 @@ class TheScreens(unittest.TestCase):
self.assertEqual(self.lists(shown), [("menu", ["status", "check", "quit"])])
menu = shown[[s[0] for s in shown].index("list")][2]
self.assertEqual(menu["status"][0], "Fake OS 1.0: all 3 BIOS files present")
self.assertEqual(shown[0], ("INIT", "no-harbour"))
self.assertEqual(shown[0], ("INIT", "no-check"))
self.assertEqual(shown[-2:], [("EXIT",), ("STATUS", "0")])
self.assertEqual(self.calls(), ["--check --events"])
@@ -417,8 +417,26 @@ class TheScreens(unittest.TestCase):
self.assertEqual(self.lists(shown), [("notice", ["info", "check", "quit"])])
notice = shown[[s[0] for s in shown].index("list")][2]
self.assertEqual(notice["info"][0], "No connection")
self.assertIn("Connect to Wi-Fi", notice["info"][1])
self.assertIn("connect to Wi-Fi", notice["info"][1])
self.assertIn("/roms/bios", notice["info"][1])
# Where a pack goes, and which one.
self.assertIn(f"copy it into {self.work / 'packs'} on the SD card", notice["info"][1])
self.assertIn("whose name contains RetroArch", notice["info"][1])
def test_offline_with_a_pack_on_the_card(self):
pack = None
def place(work: Path) -> None:
nonlocal pack
pack = work / "packs" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
shown = self.run_port("missing", ["update", "quit"], FAKE_CURL="7", before=place)
self.assertEqual(self.calls()[:2], [
f"--check --events --from-pack {pack}",
f"--events --from-pack {pack} --platform retroarch",
])
self.assertEqual(shown[-1], ("STATUS", "0"))
def test_detection_failure(self):
shown = self.run_port("nodetect", ["check", "quit"])
+65 -51
View File
@@ -936,67 +936,81 @@ elsewhere. A custom BIOS directory is still named directly:
python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/system
```
## On a handheld: the RetroBIOS port
## On a handheld { #on-a-handheld-the-retrobios-port }
On a Linux handheld that runs [PortMaster](https://portmaster.games/) (ArkOS,
ROCKNIX, AmberELEC, muOS, KNULLI and the others it supports), the installer
also runs from the device itself, as a port with gamepad screens. The screens
are PortMaster's own, so the port carries no graphics code.
RetroBIOS also runs on the handheld itself, in the
[full-screen interface](#the-full-screen-interface), with the controller.
Take the file for your system from the
[latest release](https://github.com/Abdess/retrobios/releases/latest), copy
it to the SD card, then start RetroBIOS from the menu.
**Getting it.** Two ways:
| System | File | Copy it to | Start it from |
|--------|------|------------|---------------|
| Any system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, muOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/mmc/MUOS/PortMaster/autoinstall/` on muOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once |
| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list |
| muOS | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications |
| ArkOS, dArkOS | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools |
| ROCKNIX, JELOS | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools |
| AmberELEC | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools |
- From PortMaster's catalog, once the port is accepted there: open PortMaster,
find RetroBIOS under the Other genre, install.
- `RetroBIOS.zip`, copied into PortMaster's autoinstall folder. The release
process attaches it to each [release](https://github.com/Abdess/retrobios/releases/latest)
cut after the port was added. Releases cut before carry none: when the
latest one lists no `RetroBIOS.zip`, build it from a clone (below). Starting PortMaster then installs it, with or without a
network connection. PortMaster's FAQ names the folder: `/roms/ports/PortMaster/autoinstall/`
on AmberELEC and ROCKNIX, `/roms/tools/PortMaster/autoinstall/` on ArkOS,
`/mmc/MUOS/PortMaster/autoinstall/` on muOS,
`/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI.
A release cut before these files existed carries none of them. From a clone
of the repository, `python scripts/build_port.py` writes all four into
`dist/`.
From a clone of the repository, `python scripts/build_port.py` builds the
same archive into `dist/RetroBIOS.zip`.
| Button | Action |
|--------|--------|
| D-pad | Move |
| A, Start | Choose, continue |
| B, Select | Back, and quit on the first screen |
| X | Tick or untick a system |
| Y | Options |
| L1, R1 | Page up, page down |
| Start + Select | Quit at once (PortMaster port, Tools) |
**What it shows.** RetroBIOS appears among the ports. Started, it runs
`install.py --check --events` and opens on the result: the system's name,
how many BIOS files are present, missing, or present with a hash that does
not match. The menu
offers to update everything missing, to choose systems (each listed with what
it lacks, selected or cleared with A), to check again, or to quit. An update
shows a progress bar, one step per file, then what was installed, what failed
and why, and what the collection cannot supply. Every list has its own Back or
Quit entry, since older PortMaster releases do not read B there.
On muOS the interface runs in muxterm, which already reads Start, B, Y (tick)
and the D-pad. A, X, L1 and R1 come from PortMaster's gptokeyb2 when
PortMaster is installed, and Menu quits.
**What it needs.**
**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 the 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.
- A network connection: the list of files to check comes from the internet,
as do the files. Without one the port says so and offers to check again.
- Python 3.8 or newer. The port uses the system's `python3` when it is recent
enough, else PortMaster's `python_3.11` runtime on aarch64 devices, which it
downloads through PortMaster on first use. An armhf device with an older
Python is told so, with the computer route below.
- A BIOS folder the installer detects (see [Platform detection](#platform-detection)).
On a system it does not know yet, the port says it found no BIOS 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.
**The computer route.** With the SD card mounted on a computer, the one-line
command fills it, naming the platform and the card's BIOS folder:
`--platform <platform> --dest <BIOS folder on the card>`. The port's screens
for an unknown system or an old Python name this route.
**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
OpenSSL 1.1, so on an image with OpenSSL 3 only it may not download (not
tried on a device). The BIOS folder must be one the installer detects, see
[Platform detection](#platform-detection).
**Which installer runs.** The archive carries `install.py`, the file the
one-line command of the same commit downloads: the build refuses any other
(`scripts/build_port.py` compares it with the SHA-256 `install.sh` pins). A
port that downloaded the installer at each start against a pinned SHA-256
would stop working at the next change to `install.py`, since the pin is fixed
when the port is built. The file lists come from `main`, as for the one-line
command. A newer port version carries a newer installer. When a list is in a
format the carried installer does not read, the port says to update it.
**Where no terminal shows.** The PortMaster port draws on the console, or in
foot or xterm under a desktop session. Where neither can, it opens
PortMaster's own screens instead: the same check, update and choice of
systems, one list at a time. On Batocera and Knulli the visible terminal is
not verified on a device yet: RetroBIOS tries xterm or foot, then console 4,
and `retrobios/log.txt` names the route it took.
The launcher (`ports/RetroBIOS/RetroBIOS.sh`) and its screens
(`retrobios/screens.txt`) read the installer through
[Machine-readable events](#machine-readable-events) only.
**From a computer.** Onion, MinUI, NextUI, Allium, GarlicOS and the Switch
have no Python 3 to run the installer. Put the card in a computer and run the
[one-line command](#one-line) there with
`--platform <platform> --dest <BIOS folder on the card>`. This works for
every system above too.
**Which installer runs.** Each file carries `install.py`, the one the
one-line command of the same commit downloads: `scripts/build_port.py`
refuses any other (it compares it with the SHA-256 `install.sh` pins). The
file lists come from `main`. The launchers in `ports/` start it with `--tui`,
and read it through [Machine-readable events](#machine-readable-events) for
the rest.
## Without a network (offline install) { #without-a-network-offline-install }
+8 -5
View File
@@ -218,16 +218,19 @@ python scripts/pipeline.py --include-archived
# which install.py --check reads to name a part missing from a folder.
python scripts/split_pack.py dist/
# 2b. The handheld port, RetroBIOS.zip, built from ports/RetroBIOS with the
# installer the bootstraps pin. It is a release file, listed in the
# checksums below, and check_release.py holds it to them.
# 2b. The handheld launchers, each carrying the installer the bootstraps pin:
# RetroBIOS.zip (the PortMaster port), RetroBIOS-batocera.zip (Batocera
# and Knulli Ports), RetroBIOS.muxapp (muOS Applications) and
# RetroBIOS-tools.zip (the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS,
# AmberELEC). They are release files, listed in the checksums below, and
# check_release.py compares each asset with its line there.
python scripts/build_port.py
# 3. Checksums of the files as published, then sign the list. The checksums
# answer corruption, and the signature answers a rewritten release, which is
# the one thing a checksum published beside its own artifacts cannot
# answer.
(cd dist && sha256sum *.zip > SHA256SUMS.txt)
(cd dist && sha256sum *.zip RetroBIOS.muxapp > SHA256SUMS.txt)
ssh-keygen -Y sign -f ~/.ssh/retrobios_signing -n file dist/SHA256SUMS.txt
# 4. Record what each pack holds, read from the archives just checked:
@@ -249,7 +252,7 @@ python scripts/release_notes.py --whats-new tmp/whats-new.md --output tmp/notes.
# A public release with half its assets is a broken download for everyone
# during the whole upload.
gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file tmp/notes.md
for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip; do
for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip dist/RetroBIOS.muxapp; do
[ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber
done
# A count does not see a failed upload beside a stray file, nor a part
+33 -11
View File
@@ -55,7 +55,7 @@ Every module in `tests/`, by the area it guards.
| Slots and regions | synthetic + profiles | `test_region`, `test_region_agnostic_scan`, `test_region_groups`, `test_region_held`, `test_region_platform_index`, `test_region_required_only`, `test_slot`, `test_slots`, `test_slots_shipped_file` |
| Hardware targets and systems | synthetic + target files | `test_extract_targets`, `test_lakka_targets`, `test_libretro_slugs`, `test_system_ids`, `test_system_selection`, `test_target_names`, `test_target_scrapers`, `test_target_systems`, `test_target_verify` |
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r4_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
@@ -262,18 +262,40 @@ python scripts/generate_pack.py --all --verify-packs --output-dir dist/
Integrated as pipeline step 6/8 (runs after consistency check, before
README generation). Requires packs in `dist/`, skip with `--skip-packs`.
### test_handheld_launchers.py
The launchers in `ports/`: the PortMaster port, the Batocera and Knulli
port, the muOS app and the Tools entry, all built on `ports/shared/tui.txt`.
`bash -n` and `shellcheck` on every script (skipped where shellcheck is not
installed), the gptokeyb and evmapy mappings decoded by the interface's own
key decoder, and the four archives `scripts/build_port.py` writes (layout,
reproducibility, the bundled `install.py` equal to the one `install.sh`
pins).
Each launcher then runs from its extracted archive on a simulated device: a
pseudo-terminal stands for the console, and fakes stand for `install.py`,
gptokeyb, setfont, chvt, curl, foot, xterm, muxterm, PortMaster's
`control.txt` and muOS's `retro.sh`. The checks: gptokeyb starts in kill mode
on the installer's path and is stopped, the console's settings and font are
put back, an offline run picks the pack of its system from the card or says
where to put one, a refused console falls back to PortMaster's screens, and
each firmware's terminal is the one used. What reaches the screen and the
pad on a real device is not exercised.
```bash
python -m unittest tests.test_handheld_launchers -v
```
### test_port.py
The PortMaster port in `ports/RetroBIOS/`. `bash -n` on its scripts, the
archive `scripts/build_port.py` writes (layout, reproducibility, the bundled
`install.py` equal to the one `install.sh` pins), and the event reader. The
screens run in bash with PortMasterDialog's functions replaced by stubs that
record each command and answer from a script, against a fake `install.py`
whose event streams follow `EVENT_KEYS`: all files present, some missing
(update everything, choose systems), no network, no BIOS folder detected,
Python too old, and an answer that arrives after the 30-second wait of the
older PortMaster releases. Drawing, the pad, `mount` and the network belong
to a device and are not exercised.
The PortMaster port's fallback screens, drawn by pugwash, and its event
reader. The screens run in bash with PortMasterDialog's functions replaced by
stubs that record each command and answer from a script, against a fake
`install.py` whose event streams follow `EVENT_KEYS`: all files present, some
missing (update everything, choose systems), no network with and without a
pack on the card, no BIOS folder detected, Python too old, and an answer that
arrives after the 30-second wait of the older PortMaster releases. Drawing,
the pad, `mount` and the network belong to a device and are not exercised.
```bash
python -m unittest tests.test_port -v