chore: merge main into the engine round

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 19:07:44 +02:00
commit b34a6058f7
47 files changed
+2731 -442

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
emulator: "00_example"
type: libretro
type: test
core_classification: pure_libretro
source: "https://github.com/libretro/libretro-core-info"
profiled_date: "2026-03-22"
+1 -1
View File
@@ -11,7 +11,7 @@
$ErrorActionPreference = "Stop"
$defaultInstallUrl = "https://raw.githubusercontent.com/Abdess/retrobios/main/install.py"
$defaultInstallSha256 = "7bc94c44f3696a0b777e5c280754633105a06756932ee52547f0a9c775a895b9"
$defaultInstallSha256 = "240faa427aaa602deca1a3180f770c6f335095b1cf1d5bb28fc060eb55f54917"
$maximumInstallerBytes = 2MB
$installerExit = 0
$installer = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.py" } else { $null }
+535 -80
View File
File diff suppressed because it is too large. Load diff
+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="7bc94c44f3696a0b777e5c280754633105a06756932ee52547f0a9c775a895b9"
DEFAULT_INSTALL_SHA256="240faa427aaa602deca1a3180f770c6f335095b1cf1d5bb28fc060eb55f54917"
MAX_INSTALLER_BYTES=2097152
cleanup() {
+24 -6
View File
@@ -28,8 +28,10 @@ GAMEDIR="/$directory/ports/retrobios"
cd "$GAMEDIR" || exit 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
# writing from its own offset would overwrite their lines. The previous run's
# is kept, for a player who starts again before reading it.
[ -s "$GAMEDIR/log.txt" ] && mv -f "$GAMEDIR/log.txt" "$GAMEDIR/log.prev.txt" 2>/dev/null
{ : > "$GAMEDIR/log.txt"; } 2>/dev/null && 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
@@ -48,17 +50,33 @@ 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.
# ArkOS and dArkOS, as control.txt tells them apart (its plymouth theme):
# their two card folders are offered to each other, and the destination is
# the one RetroArch reads. control.txt names the card PortMaster runs from
# roms2 whenever fstab mounts a second card, even when RetroArch still reads
# /roms/bios, so RetroArch's own setting decides, as in the Tools entry.
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"
RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \
"${RB_RETROARCH_CFG:-/home/ark/.config/retroarch/retroarch.cfg}" 2>/dev/null)
RB_BIOS_DEST="${RB_BIOS_DEST:-/$directory/bios}"
fi
# shellcheck source=../shared/tui.txt
source "$GAMEDIR/tui.txt"
# shellcheck source=retrobios/screens.txt
source "$GAMEDIR/screens.txt"
rb_open_log keep
# muOS: PortMaster's mod_muOS.txt points CUR_TTY at /dev/null, so there is no
# console to draw on. muxterm, the terminal muOS's Terminal core runs, shows
# the interface instead. It is told to leave the pad alone (an SDL hint) so
# that gptokeyb sends every button once, with this port's mapping: muxterm
# sends Y as Space, where the interface reads Y as Options. The hint allows
# one vendor and product id, 0001:0001, which no pad carries.
RB_MUXTERM="${RB_MUXTERM:-/opt/muos/frontend/muxterm}"
if [ -x "$RB_MUXTERM" ] && [ -n "$RB_MAPPER" ]; then
RB_TERMAPP=(env SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001 "$RB_MUXTERM" --gl --)
fi
export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig"
+3 -2
View File
@@ -17,7 +17,8 @@ options, L1 and R1 to page. B on the first screen closes it.
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
there, and switches back. When console 4 does not come to the front within
3 s, it switches back and stops. Which of the two shows up on a given build
is not verified. If nothing shows, `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.
+10 -3
View File
@@ -16,16 +16,23 @@ RB_ROUTES="app vt"
RB_VT_SWITCH="${RB_VT_SWITCH:-4}"
RB_PACK_PATH="$RB_DIR:$HERE:/userdata"
RB_LOG="$RB_DIR/log.txt"
RB_PAD="evmapy"
# 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
# from it does not always inherit, with the cookie S31emulationstation
# exports: xterm is refused the display without it.
if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ] \
&& [ -S "${RB_X_SOCKET:-/tmp/.X11-unix/X0}" ]; then
export DISPLAY=:0
fi
RB_XAUTH="${RB_XAUTH:-/var/lib/.Xauthority}"
if [ -n "${DISPLAY:-}" ] && [ -z "${XAUTHORITY:-}" ] && [ -f "$RB_XAUTH" ]; then
export XAUTHORITY="$RB_XAUTH"
fi
# shellcheck source=../shared/tui.txt
source "$RB_DIR/tui.txt"
: > "$RB_LOG"
rb_open_log fresh
rb_select_python
# A launcher's own arguments are not install.py's (muOS passes the app folder).
# shellcheck disable=SC2119
+8 -6
View File
@@ -7,10 +7,12 @@ 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.
It runs in muxterm, muOS's terminal. With PortMaster installed, gptokeyb2
turns every button into a key and muxterm is told to leave the pad alone, so
each button is sent once: 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. Hold
Select, then press Start to quit. Without PortMaster, muxterm reads the pad
itself (Y then ticks a system) and Menu closes it.
Not tried on a device yet: the icon, and whether muxterm and gptokeyb2 share
the pad as described.
Not tried on a device yet: the icon, and whether muxterm honours SDL's
controller allow-list as SDL 2.30 does.
+21 -9
View File
@@ -5,8 +5,8 @@
# 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.
# PortMaster's gptokeyb2 turning the buttons into keys. Without muxterm it
# runs on the console.
MUOS="${RB_MUOS:-/opt/muos}"
# shellcheck source=/dev/null
@@ -19,12 +19,12 @@ 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_PACK_PATH="$APP_DIR:$ROM_MOUNT:$SD2_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
@@ -33,21 +33,33 @@ controlfolder="$PM_DIR"
# shellcheck source=../shared/tui.txt
source "$APP_DIR/tui.txt"
: > "$RB_LOG"
rb_open_log fresh
RB_GPTK="$APP_DIR/retrobios.gptk"
if [ -x "$PM_DIR/gptokeyb2" ]; then
# As muOS's own launchers start it (script/launch/ext-xroar.sh).
# As muOS's own launchers start it (script/launch/ext-xroar.sh). gptokeyb2
# kills only with SIGKILL: by process name by default, which never names
# this run, and quits all the same, or with -X by command line, which is
# what reaches the Start + Select sentinel (gptokeyb2 src/util.c).
ln -sf "$PM_DIR/libinterpose.aarch64.so" "/usr/lib/libinterpose.aarch64.so" >/dev/null 2>&1
RB_MAPPER="$PM_DIR/gptokeyb2"
RB_MAPPER="$PM_DIR/gptokeyb2 -X"
fi
if [ -x "$MUXTERM" ]; then
RB_ROUTES="app"
RB_TERMAPP=("$MUXTERM" --gl --)
RB_GPTK="$APP_DIR/muxterm.gptk"
if [ -n "${RB_MAPPER:-}" ]; then
# muxterm reads the pad itself and sends Y as Space (MustardOS/terminal
# input.c), where the interface reads Y as Options. The SDL hint allows
# only vendor and product 0001:0001, which no pad carries, so muxterm
# leaves the pad to gptokeyb2 and every button is sent once.
RB_TERMAPP=(env SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001 "$MUXTERM" --gl --)
else
# No gptokeyb2: muxterm's own buttons are all there is.
RB_TERMAPP=("$MUXTERM" --gl --)
RB_PAD="muxterm"
fi
else
RB_ROUTES="vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}"
RB_GPTK="$APP_DIR/retrobios.gptk"
fi
rb_select_python
-10
View File
@@ -1,10 +0,0 @@
# 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
+479 -101
View File
@@ -5,18 +5,23 @@
#
# 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.
# and calls rb_open_log, 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".
# A route that fails to show the interface hands over to the
# next one.
# 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_MAPPER_NEEDED 0 when the terminal reads the pad itself, so the
# interface still has buttons without the mapper
# 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
@@ -25,6 +30,10 @@
# 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
# RB_PAD the input route, exported to the interface as
# RETROBIOS_PAD: gptokeyb (retrobios.gptk, the default when
# RB_MAPPER is set), muxterm (muxterm's own keys), evmapy
# (RetroBIOS.sh.keys), miyoo (buttons already keys)
# ESUDO sudo where the launcher needs it, as PortMaster sets it
RB_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
@@ -44,33 +53,141 @@ 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/}"
# Seconds: the interface's time to write its result once asked to stop, the
# wait for a button when the mapper is gone, the offline detection run.
RB_STOP_GRACE="${RB_STOP_GRACE:-10}"
RB_DEAD_WAIT="${RB_DEAD_WAIT:-10}"
RB_DETECT_LIMIT="${RB_DETECT_LIMIT:-20}"
RB_MAPPER_NEEDED="${RB_MAPPER_NEEDED:-1}"
RB_PAD="${RB_PAD:-}"
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_MAPPER_PID="${RB_MAPPER_PID:-}"
RB_STOP_PID="${RB_STOP_PID:-}"
RB_STOP_TOKEN="${RB_STOP_TOKEN:-}"
RB_STATE_PROBLEM=""
RB_MOUNTED=""
RB_MAPPER_PID=""
RB_UI_PID=""
RB_APP_PID=""
RB_STOPPED=""
RB_TTY=""
RB_STTY=""
RB_FONT_BACK=""
RB_VT_BACK=""
RB_ROUTE=""
RB_ROUTE_FAILED=""
RB_NAP_FD=""
RB_KEY=""
RB_TERMAPP=()
RB_PYCMD=()
rb_arch() {
uname -m
}
rb_py() {
rb_say() {
printf '%s\n' "RetroBIOS: $*" >> "$RB_LOG" 2>/dev/null
}
# The interpreter as one command, so it can be started in the background and
# signalled itself: a function run with & is a subshell whose python child a
# kill does not reach.
rb_pycmd() {
if [ -n "$RB_PYTHONHOME" ]; then
PYTHONHOME="$RB_PYTHONHOME" "$RB_PYTHON" "$@"
RB_PYCMD=(env "PYTHONHOME=$RB_PYTHONHOME" "$RB_PYTHON")
else
"$RB_PYTHON" "$@"
RB_PYCMD=("$RB_PYTHON")
fi
}
rb_py() {
rb_pycmd
"${RB_PYCMD[@]}" "$@"
}
# A pause without a child process, in fractions of a second, which busybox
# sleep does not always take: a read that times out on a pipe this shell
# holds both ends of.
rb_nap() {
if [ -z "$RB_NAP_FD" ]; then
exec {RB_NAP_FD}<> <(:) || RB_NAP_FD="none"
fi
if [ "$RB_NAP_FD" = "none" ]; then
sleep 1
else
read -r -t "$1" -u "$RB_NAP_FD" _
fi
return 0
}
# Whether a process runs. /proc, not kill -0: the mapper may run as root
# under sudo, and kill -0 on it fails for a user. A zombie is not running.
rb_alive() {
local stat
[ -n "${1:-}" ] || return 1
{ read -r stat < "/proc/$1/stat"; } 2>/dev/null || return 1
stat="${stat##*) }"
[ "${stat%% *}" != "Z" ]
}
# Runs a command for at most $1 seconds: status 124 when it was stopped.
rb_bounded() {
local limit="$1" pid waited=0
shift
"$@" &
pid=$!
while rb_alive "$pid"; do
if [ "$waited" -ge $((limit * 4)) ]; then
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
fi
rb_nap 0.25
waited=$((waited + 1))
done
wait "$pid"
}
# --- State and log --------------------------------------------------------
rb_can_write() {
mkdir -p "$1" 2>/dev/null || return 1
{ printf 'x' > "$1/.probe"; } 2>/dev/null || return 1
rm -f "$1/.probe"
}
# The folder the run keeps its state in, and its log, started afresh with the
# previous one kept as log.prev.txt. A read-only or full card moves both to
# XDG_RUNTIME_DIR or /tmp: the interface then runs and says itself what it
# cannot write. "fresh" starts the log, "keep" leaves it as the launcher
# opened it.
rb_open_log() {
local spare="${XDG_RUNTIME_DIR:-/tmp}/retrobios" moved=""
if ! rb_can_write "$RB_STATE" || ! { : >> "$RB_LOG"; } 2>/dev/null; then
moved="$RB_DIR"
RB_STATE="$spare/run"
RB_LOG="$spare/log.txt"
if ! rb_can_write "$RB_STATE"; then
RB_STATE_PROBLEM="RetroBIOS cannot write to $moved or to $spare: the SD card may be read-only or full."
return 1
fi
fi
if [ "${1:-}" != "keep" ] || [ -n "$moved" ]; then
if [ -s "$RB_LOG" ]; then
mv -f "$RB_LOG" "${RB_LOG%.txt}.prev.txt" 2>/dev/null
fi
: > "$RB_LOG"
fi
if [ -n "$moved" ]; then
rb_say "$moved cannot be written (read-only or full card): the log and the run's files are in $spare"
fi
return 0
}
# --- Python ---------------------------------------------------------------
rb_python_ok() {
@@ -144,20 +261,22 @@ rb_python_text() {
# 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.
# a network, and install.py names that problem itself. Bounded as a whole:
# GNU wget retries a timeout 20 times.
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
rb_bounded 8 curl -sSI --connect-timeout 5 --max-time 7 "$RB_PROBE_URL" >/dev/null 2>&1
code=$?
case "$code" in
5|6|7|28) return 1 ;;
5|6|7|28|124) 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 ]
rb_bounded 8 wget -q --spider -T 7 "$RB_PROBE_URL" >/dev/null 2>&1
code=$?
[ "$code" -ne 4 ] && [ "$code" -ne 124 ]
return
fi
# Neither tool: a default route is the best guess there is.
@@ -166,12 +285,18 @@ rb_online() {
# 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.
# That download is sent to a loopback port nothing listens on, so it fails
# at once instead of retrying three times 30 s on a network already found
# missing.
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"
rb_pycmd
RETROBIOS_BASE_URL="http://127.0.0.1:9" rb_bounded "$RB_DETECT_LIMIT" \
"${RB_PYCMD[@]}" "$RB_INSTALL" --check --events > "$RB_STATE/detect.jsonl" 2>> "$RB_LOG"
[ $? = 124 ] && rb_say "the detection run took over $RB_DETECT_LIMIT s and was stopped"
local kind a b c
while IFS=$'\t' read -r kind a b c; do
if [ "$kind" = "platform" ]; then
@@ -184,9 +309,10 @@ rb_detect() {
done < <(rb_py "$RB_EVENTS" status "$RB_STATE/detect.jsonl" 2>> "$RB_LOG")
}
# The first pack in RB_PACK_PATH, one whose name spells this system first:
# install.py reads the platform from the same spelling (Name_With_Underscores
# between two underscores).
# The first pack in RB_PACK_PATH whose name spells this system: install.py
# reads the platform from the same spelling (Name_With_Underscores between
# two underscores) and refuses a pack named for another. Any pack only when
# the system is not known, since install.py then reads it from the name.
rb_find_pack() {
local dir file base stem first="" want=""
if [ -n "${RB_PLATFORM_NAME:-}" ]; then
@@ -203,13 +329,27 @@ rb_find_pack() {
printf '%s\n' "$file"
return 0
fi
[ -z "$first" ] && first="$file"
[ -z "$want" ] && [ -z "$first" ] && first="$file"
done
done
[ -n "$first" ] && printf '%s\n' "$first"
return 0
}
# How the release pack that serves this system is named: the platforms of its
# group, as packs.txt lists them. build_port.py writes packs.txt from the
# registry's release_pack platforms, so a system with no line has no pack.
rb_pack_label() {
[ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] || return 1
awk -F'\t' -v p="$RB_PLATFORM" '$1 == p { print $2; found = 1; exit } END { exit !found }' \
"$RB_DIR/packs.txt"
}
# Whether the system is known to have no release pack.
rb_no_pack_serves() {
[ -n "${RB_PLATFORM:-}" ] && [ -f "$RB_DIR/packs.txt" ] && ! rb_pack_label > /dev/null
}
rb_same_dir() {
[ -n "$1" ] && [ -n "$2" ] || return 1
[ "$(stat -L -c '%d:%i' "$1" 2>/dev/null)" = "$(stat -L -c '%d:%i' "$2" 2>/dev/null)" ]
@@ -235,8 +375,14 @@ rb_other_card() {
}
rb_pack_text() {
local dir="${RB_PACK_PATH%%:*}" pack
if [ -n "${RB_PLATFORM_NAME:-}" ]; then
local dir="${RB_PACK_PATH%%:*}" pack label
if rb_no_pack_serves; then
printf '%s\n' "No network connection, and the releases carry no BIOS pack for ${RB_PLATFORM_NAME:-$RB_PLATFORM}: connect to Wi-Fi and start RetroBIOS again. $RB_PC_ROUTE"
return 0
fi
if label=$(rb_pack_label); then
pack="the $label BIOS pack (a file whose name starts with ${label}_ and ends in _BIOS_Pack.zip)"
elif [ -n "${RB_PLATFORM_NAME:-}" ]; then
pack="the BIOS pack whose name contains ${RB_PLATFORM_NAME// /_}"
else
pack="the BIOS pack for this system (a file whose name ends in _BIOS_Pack.zip)"
@@ -269,44 +415,50 @@ rb_terminal_app() {
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
# A console a terminal can be read from. PortMaster's mod_muOS.txt sets
# CUR_TTY to /dev/null, a character device that is no console at all. A
# console this user cannot open yet (rb_vt_enter opens it up) is judged by
# its name.
rb_is_console() {
[ -c "$1" ] || return 1
if [ -r "$1" ] && [ -w "$1" ]; then
{ [ -t 0 ]; } < "$1" 2>/dev/null
return
fi
case "$1" in
/dev/tty[0-9]*|/dev/console|/dev/pts/*) return 0 ;;
esac
return 1
}
# Whether a route can show text here. A compositor or X server holds the
# console it runs on: text written there is never seen, so the console needs
# a switch.
rb_route_ready() {
case "$1" in
here)
[ -t 0 ] && [ -t 1 ]
;;
app)
rb_terminal_app
;;
vt)
if rb_graphical; then
[ -n "${RB_VT_SWITCH:-}" ] && command -v chvt >/dev/null 2>&1 \
&& [ -n "$(rb_active_vt)" ]
elif rb_is_console "$RB_CONSOLE"; then
return 0
else
rb_say "$RB_CONSOLE is not a console: no route on it"
return 1
fi
;;
*)
return 1
;;
esac
}
# 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
@@ -368,12 +520,34 @@ rb_active_vt() {
printf '%s\n' "$active"
}
# chvt, and whether the console really came to the front. chvt waits for the
# switch (VT_WAITACTIVE): a compositor that keeps the display would hold it
# forever, so it gets 3 s.
rb_chvt() {
local pid waited=0
$ESUDO chvt "$1" >> "$RB_LOG" 2>&1 &
pid=$!
while [ "$waited" -lt 12 ]; do
[ "$(rb_active_vt)" = "$1" ] && return 0
rb_nap 0.25
waited=$((waited + 1))
done
$ESUDO kill "$pid" >/dev/null 2>&1
return 1
}
rb_vt_enter() {
RB_TTY="$RB_CONSOLE"
if rb_graphical && [ -n "${RB_VT_SWITCH:-}" ]; then
RB_VT_BACK=$(rb_active_vt)
if ! rb_chvt "$RB_VT_SWITCH"; then
rb_say "console $RB_VT_SWITCH did not come to the front (console ${RB_VT_BACK:-?} still is)"
[ -n "$RB_VT_BACK" ] && rb_chvt "$RB_VT_BACK"
RB_TTY=""
RB_VT_BACK=""
return 1
fi
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
@@ -391,32 +565,98 @@ rb_vt_leave() {
stty "$RB_STTY" < "$RB_TTY" 2>/dev/null
fi
if [ -n "$RB_VT_BACK" ]; then
$ESUDO chvt "$RB_VT_BACK" >> "$RB_LOG" 2>&1
rb_chvt "$RB_VT_BACK"
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.
# --- Controller and Start + Select ----------------------------------------
# Start + Select is the mapper's kill mode: gptokeyb runs pkill -f on its
# target, then pkill -9 3 s later, and gptokeyb2 -X runs kill -9 at once.
# Either would reach the interface's engine too, whose command line carries
# the installer's path, and SIGKILL leaves no time to say what an install
# wrote. So the target is a sentinel, a process this launcher owns whose
# command line alone carries the token: when it dies, the launcher asks the
# interface to stop, and the interface gives a running install its 2 s.
rb_stop_start() {
RB_STOP_TOKEN="retrobios-stop-${$}x"
bash -c 'exec {f}<> <(:); while :; do read -r -t 3600 -u "$f" _; done' \
"$RB_STOP_TOKEN" > /dev/null 2>&1 &
RB_STOP_PID=$!
}
rb_stop_requested() {
[ -n "$RB_STOP_PID" ] && ! rb_alive "$RB_STOP_PID"
}
rb_stop_end() {
[ -n "$RB_STOP_PID" ] || return 0
kill "$RB_STOP_PID" >/dev/null 2>&1
RB_STOP_PID=""
}
# The mapper, checked half a second after it starts: where it is the only
# input, a mapper that exits at once leaves an interface nothing can move.
rb_mapper_start() {
[ -n "${RB_MAPPER:-}" ] || return 0
[ -n "$RB_MAPPER_PID" ] && return 0
rb_stop_start
# 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 "$RB_STOP_TOKEN" -c "$RB_GPTK" >> "$RB_LOG" 2>&1 &
RB_MAPPER_PID=$!
rb_nap 0.5
if ! rb_alive "$RB_MAPPER_PID"; then
rb_say "the controller mapper ($RB_MAPPER) stopped as soon as it started"
fi
}
# The mapper was started, is needed, and is no longer running.
rb_mapper_gone() {
[ -n "${RB_MAPPER:-}" ] && [ -n "$RB_MAPPER_PID" ] && [ "$RB_MAPPER_NEEDED" != "0" ] \
&& ! rb_alive "$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
if [ -n "$RB_MAPPER_PID" ]; then
$ESUDO kill "$RB_MAPPER_PID" >/dev/null 2>&1
wait "$RB_MAPPER_PID" 2>/dev/null
fi
RB_MAPPER_PID=""
rb_stop_end
}
# A screen of text on the current terminal, closed by any button.
rb_message() {
rb_mapper_text() {
printf '%s\n' "The controller cannot be read: gptokeyb, which turns the buttons into keys, stopped as soon as it started. What it said is in $RB_LOG. $RB_PC_ROUTE"
}
# --- Messages -------------------------------------------------------------
# One key into RB_KEY. False when none came: the wait ran out, Start + Select
# was pressed, or the mapper is gone and only a keyboard is left, which gets
# RB_DEAD_WAIT seconds.
rb_read_key() {
local limit="${RB_MESSAGE_WAIT:-300}" waited=0 code
RB_KEY=""
while [ "$waited" -lt "$limit" ]; do
rb_stop_requested && return 1
if rb_mapper_gone && [ "$limit" -gt "$RB_DEAD_WAIT" ]; then
limit="$RB_DEAD_WAIT"
[ "$waited" -lt "$limit" ] || return 1
fi
IFS= read -r -s -n 1 -t 1 RB_KEY
code=$?
[ "$code" = 0 ] && return 0
# The end of the input, not a timeout.
[ "$code" -gt 128 ] || return 1
waited=$((waited + 1))
done
return 1
}
rb_screen() {
local cols
cols=$(stty size 2>/dev/null | awk '{print $2}')
case "$cols" in
@@ -426,60 +666,133 @@ rb_message() {
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 '\n%s\n' "$2"
}
# A screen of text on the current terminal, closed by any button.
rb_message() {
rb_screen "$1" "Press A or Enter to close."
rb_read_key
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
rb_screen "$1" "A or Enter: yes. B or Esc: no."
if ! rb_read_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" = " " ]
[ -z "$RB_KEY" ] || [ "$RB_KEY" = " " ]
}
# --- The run --------------------------------------------------------------
# The interface itself, watched: Start + Select or a mapper gone (nothing
# moves it any more) asks it to stop with SIGTERM, then SIGKILL after
# RB_STOP_GRACE seconds. Its stdin is named, since a background command in a
# script reads /dev/null otherwise.
rb_interface() {
local status naps=0
rb_pycmd
# A Ctrl-C typed while the terminal still sends signals reaches the
# interface on its own: this shell keeps watching.
trap ':' INT
"${RB_PYCMD[@]}" "$RB_INSTALL" --tui "$@" <&0 2> "$RB_STATE/tui.err" &
RB_UI_PID=$!
while rb_alive "$RB_UI_PID"; do
if [ -z "$RB_STOPPED" ]; then
if rb_stop_requested; then
RB_STOPPED="start_select"
rb_say "Start + Select: the interface is asked to stop"
elif rb_mapper_gone; then
RB_STOPPED="mapper"
rb_say "the controller mapper stopped: the interface is asked to stop"
fi
[ -n "$RB_STOPPED" ] && kill -TERM "$RB_UI_PID" 2>/dev/null
else
naps=$((naps + 1))
if [ "$naps" -ge $((RB_STOP_GRACE * 4)) ]; then
rb_say "the interface did not stop within $RB_STOP_GRACE s"
kill -KILL "$RB_UI_PID" 2>/dev/null
rb_kill_engines
fi
fi
rb_nap 0.25
done
wait "$RB_UI_PID"
status=$?
trap - INT
RB_UI_PID=""
return "$status"
}
# An engine the interface started survives it when the interface is killed:
# it runs in its own session.
rb_kill_engines() {
command -v pkill >/dev/null 2>&1 || return 0
pkill -KILL -f "$RB_INSTALL" >/dev/null 2>&1
}
rb_ui_stop() {
local naps=0
rb_alive "$RB_UI_PID" || return 0
kill -TERM "$RB_UI_PID" 2>/dev/null
while rb_alive "$RB_UI_PID" && [ "$naps" -lt $((RB_STOP_GRACE * 4)) ]; do
rb_nap 0.25
naps=$((naps + 1))
done
if rb_alive "$RB_UI_PID"; then
kill -KILL "$RB_UI_PID" 2>/dev/null
rb_kill_engines
fi
RB_UI_PID=""
}
# 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=()
local status pack args=() other
mkdir -p "$RB_STATE"
rm -f "$RB_STATE/status"
case "${TERM:-}" in
''|dumb|unknown) export TERM=xterm ;;
esac
if rb_mapper_gone; then
rb_message "$(rb_mapper_text)"
printf '1\n' > "$RB_STATE/status"
return 1
fi
if [ -z "$RB_PYTHON" ]; then
rb_message "$(rb_python_text)"
printf '1\n' > "$RB_STATE/status"
return 1
fi
local other
printf '\033[2J\033[HRetroBIOS\n\nChecking the connection...\n'
if ! rb_online; then
printf 'No connection. Looking for the BIOS folder and a pack on the card...\n'
# Detection also names the BIOS folder the other card must not be.
rb_detect
if rb_no_pack_serves; then
# Copying from the other card needs a file list too: nothing to ask.
rb_message "$(rb_pack_text)"
printf '1\n' > "$RB_STATE/status"
return 1
fi
other=$(rb_other_card)
if [ -n "$other" ] && rb_ask "Copy the BIOS files from your other card? $other holds files. RetroBIOS copies the ones this system needs, each checked by its hash, and leaves that card as it is."; then
args=(--from "$other")
fi
pack=$(rb_find_pack)
if rb_stop_requested; then
printf '143\n' > "$RB_STATE/status"
return 143
fi
if [ -z "$pack" ]; then
if [ "${#args[@]}" -gt 0 ]; then
# --from needs the list of files, which comes from the network or a pack.
@@ -495,7 +808,7 @@ rb_here() {
args+=(--platform "$RB_PLATFORM")
fi
fi
rb_py "$RB_INSTALL" --tui "${args[@]}" "$@" 2> "$RB_STATE/tui.err"
rb_interface "${args[@]}" "$@"
status=$?
cat "$RB_STATE/tui.err" >> "$RB_LOG" 2>/dev/null
if [ "$status" = "2" ] && grep -q -- "--tui cannot open" "$RB_STATE/tui.err"; then
@@ -503,59 +816,124 @@ rb_here() {
if [ "${RB_FALLBACK:-0}" != "1" ]; then
rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG."
fi
elif [ -n "$RB_STOPPED" ] || [ "$status" -ge 128 ]; then
# Ended by Start + Select or a signal: the player asked to leave, and no
# button may be left to close a message.
:
elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then
rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG."
# The first line of the farewell: the error screen it closes was read.
rb_message "$(head -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_ui_stop
if rb_alive "$RB_APP_PID"; then
kill -TERM "$RB_APP_PID" 2>/dev/null
wait "$RB_APP_PID" 2>/dev/null
fi
RB_APP_PID=""
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
# One route: sets RB_ROUTE_FAILED when it could not show the interface, so
# the next route is tried.
rb_route_run() {
local status code
RB_ROUTE_FAILED=""
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)
rm -f "$RB_STATE/status"
"${RB_TERMAPP[@]}" bash "$RB_LIB" here "$@" 2>> "$RB_LOG" &
RB_APP_PID=$!
wait "$RB_APP_PID"
code=$?
RB_APP_PID=""
if [ -f "$RB_STATE/status" ]; then
status=$(cat "$RB_STATE/status")
else
# foot or xterm refused to open (no display, no XAUTHORITY): the
# interface never ran.
rb_say "${RB_TERMAPP[0]} ended with status $code before the interface ran"
RB_ROUTE_FAILED=1
return 1
fi
;;
vt)
rb_vt_enter
if ! rb_vt_enter; then
RB_ROUTE_FAILED=1
return 1
fi
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=$?
rb_vt_leave
;;
esac
return "${status:-1}"
}
# 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="" route
RB_STOPPED=""
if [ -n "$RB_STATE_PROBLEM" ]; then
printf '%s\n' "$RB_STATE_PROBLEM" >&2
if rb_is_console "$RB_CONSOLE"; then
printf '\r\n%s\r\n' "$RB_STATE_PROBLEM" > "$RB_CONSOLE" 2>/dev/null
fi
return 1
fi
mkdir -p "$RB_STATE"
export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK
export RB_OTHER_CARDS RB_BIOS_DEST RB_STOP_GRACE RB_DEAD_WAIT RB_DETECT_LIMIT
export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE
# The interface names the buttons of this route in its hints.
if [ -z "$RB_PAD" ] && [ -n "${RB_MAPPER:-}" ]; then
RB_PAD="gptokeyb"
fi
if [ -n "$RB_PAD" ]; then
export RETROBIOS_PAD="$RB_PAD"
fi
trap 'rb_cleanup; exit 143' TERM HUP
for route in $RB_ROUTES; do
rb_route_ready "$route" || continue
rb_mapper_start
export RB_MAPPER RB_MAPPER_PID RB_MAPPER_NEEDED RB_STOP_PID
RB_ROUTE="$route"
rb_say "showing the interface by the $route route"
rb_route_run "$@"
status=$?
[ -z "$RB_ROUTE_FAILED" ] && break
status=""
done
rb_cleanup
trap - TERM HUP
return "${status:-1}"
if [ -z "$status" ]; then
RB_ROUTE="none"
rb_say "no terminal to show the interface on (routes: $RB_ROUTES)"
return 125
fi
return "$status"
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
case "${1:-}" in
here)
shift
# The terminal app closed or the launcher signalled: the interface
# gets its time to stop.
trap 'rb_ui_stop; exit 143' TERM HUP
rb_here "$@"
exit $?
;;
+2 -1
View File
@@ -13,7 +13,8 @@ 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.
tick a system, Y for the options, L1 and R1 to page. To quit, hold Select, then press Start: Start pressed
first would also choose the line in focus.
ArkOS has Python 3.7, older than the installer needs: RetroBIOS uses the
Python runtime the RetroBIOS PortMaster port downloads, so start that port
+24 -4
View File
@@ -15,6 +15,8 @@ ROOT="${RB_ROOT:-}"
RB_DIR="$HERE/retrobios"
RB_LOG="$RB_DIR/log.txt"
ESUDO=""
MAPPER_SUDO=""
kill_flag="-1"
OS_NAME=""
if [ -f "$ROOT/etc/os-release" ]; then
OS_NAME=$(awk -F= '$1 == "NAME" { gsub(/"/, "", $2); print $2 }' "$ROOT/etc/os-release")
@@ -49,6 +51,10 @@ if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then
mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb")
[ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \
&& export SDL_GAMECONTROLLERCONFIG_FILE="$ROOT/opt/inttools/gamecontrollerdb.txt"
# sudo's env_reset drops the controller list exported above, and a pad
# SDL does not know then sends nothing: PortMaster keeps it the same way
# on ArkOS (control.txt, ESUDO).
[ -n "$ESUDO" ] && MAPPER_SUDO="sudo --preserve-env=SDL_GAMECONTROLLERCONFIG_FILE"
elif [ "$OS_NAME" = "AmberELEC" ]; then
RB_ROUTES="vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/console}"
@@ -65,15 +71,29 @@ else
RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms"
controlfolder="/storage/roms/ports/PortMaster"
mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb")
# ROCKNIX's own tools load the pad's controller list first (fex-emu's
# Start FEXConfig.sh: control-gen_init.sh, control.ini, get_controls), and
# start the GPTOKEYB it names. control.ini also sets ESUDO and
# controlfolder, which this launcher keeps as they were.
control="$ROOT/storage/.config/gptokeyb/control.ini"
command -v control-gen_init.sh >/dev/null 2>&1 && control-gen_init.sh >/dev/null 2>&1
if [ -f "$control" ]; then
saved_esudo="$ESUDO" saved_folder="$controlfolder"
# shellcheck source=/dev/null
source "$control"
declare -F get_controls >/dev/null && get_controls
# It carries its own kill flag (-sudokill).
[ -n "${GPTOKEYB:-}" ] && mapper="$GPTOKEYB" kill_flag=""
ESUDO="$saved_esudo" controlfolder="$saved_folder"
fi
fi
# Kill mode on the installer's own path: Start + Select closes the
# interface, never another program.
# Kill mode: Start + Select closes the interface, never another program.
RB_MAPPER=""
[ -n "$mapper" ] && RB_MAPPER="$ESUDO $mapper -1"
[ -n "$mapper" ] && RB_MAPPER="${MAPPER_SUDO:-$ESUDO} $mapper $kill_flag"
# shellcheck source=../shared/tui.txt
source "$RB_DIR/tui.txt"
: > "$RB_LOG"
rb_open_log fresh
rb_select_python
# A launcher's own arguments are not install.py's (muOS passes the app folder).
# shellcheck disable=SC2119
+16
View File
@@ -912,6 +912,22 @@
"type": "string",
"description": "Why source_commit is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag: comparing it to HEAD says nothing and recaling would repoint its refs at code the build it documents never had."
},
"data_root": {
"type": "object",
"minProperties": 1,
"propertyNames": {"enum": ["android", "ios", "windows", "macos", "linux"]},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["path", "source_ref"],
"properties": {
"path": {"type": "string", "minLength": 1, "description": "The folder as the user finds it on that OS, with the environment variable or home prefix the code expands (%APPDATA%, ~, Android/data/<package>)."},
"source_ref": {"type": "string", "minLength": 1},
"note": {"type": "string"}
}
},
"description": "Per operating system, the folder the app reads its user data from, under which standalone_path destinations sit. Only the systems the app runs on are keyed."
},
"upstream_frozen": {
"type": "string",
"description": "Why upstream_commit is held on purpose rather than followed: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is judged against its own revision and a bump leaves it, the way pin_frozen holds source_commit."
+50 -7
View File
@@ -46,6 +46,8 @@ MUOS_APP = "RetroBIOS.muxapp"
TOOLS_ZIP = "RetroBIOS-tools.zip"
# Every archive a release attaches, in the order the release notes list them.
ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP)
# Which release pack serves each platform, read by tui.txt offline.
PACKS_TABLE = "packs.txt"
_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE)
@@ -57,7 +59,7 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str:
return found.group(1)
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]:
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path | None]]:
"""What every launcher runs: the installer, the interface launcher, the
event reader, the licenses, and the gptokeyb mapping where one is read."""
shared = root / "ports" / "shared"
@@ -67,14 +69,17 @@ def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Pa
(f"{folder}/rbevents.py", shared / "rbevents.py"),
(f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
(f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
# Written by build_archive from the registry.
(f"{folder}/{PACKS_TABLE}", None),
]
if mapping:
members.append((f"{folder}/retrobios.gptk", shared / "retrobios.gptk"))
return members
def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
"""(name in the archive, source file) for every member of *name*, sorted."""
def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path | None]]:
"""(name in the archive, source file) for every member of *name*, sorted.
The source is None for the packs table build_archive writes."""
ports = root / "ports"
if name == PORT_ZIP:
source = ports / "RetroBIOS"
@@ -103,7 +108,6 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]
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),
]
@@ -122,6 +126,40 @@ def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]
return sorted(members)
def release_pack_labels(root: Path = REPO_ROOT) -> dict[str, str]:
"""Platform -> the platform part of the release pack that serves it.
Only the platforms the registry marks release_pack have a pack, grouped
as the release builds them (pipeline.py --include-archived). install.py
refuses a pack whose name does not spell the platform, so a platform
outside these groups has no pack to copy, whatever its file list.
"""
from common import ( # noqa: PLC0415
group_identical_platforms,
list_release_pack_platforms,
load_platform_config,
)
from generate_pack import _name_part, _platform_pack_stem # noqa: PLC0415
platforms_dir = str(root / "platforms")
pool = list_release_pack_platforms(platforms_dir, include_archived=True)
labels: dict[str, str] = {}
for group, representative in group_identical_platforms(pool, platforms_dir):
stem = _platform_pack_stem(group, representative, platforms_dir)
config = load_platform_config(representative, platforms_dir)
version = config.get("version", config.get("dat_version", ""))
tag = f"_{_name_part(str(version))}" if version else ""
label = stem[: -len(tag)] if tag and stem.endswith(tag) else stem
for platform in group:
labels[platform] = label
return dict(sorted(labels.items()))
def _packs_table(root: Path) -> bytes:
return "".join(f"{platform}\t{label}\n"
for platform, label in release_pack_labels(root).items()).encode()
def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path:
"""Write the launcher archive *name* to *output* and return it."""
pinned = pinned_installer_sha256(root)
@@ -138,9 +176,14 @@ def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path:
members = archive_members(name, root)
output.parent.mkdir(parents=True, exist_ok=True)
scratch = output.with_name(f".{output.name}.tmp")
with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for member, path in members:
_add_pack_member(zf, str(path), member)
table = output.with_name(f".{output.name}.{PACKS_TABLE}")
table.write_bytes(_packs_table(root))
try:
with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for member, path in members:
_add_pack_member(zf, str(path or table), member)
finally:
table.unlink()
scratch.replace(output)
return output
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: Confirm
1 file to download, 16.0 KB
0 in place, 1 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system
0 in place, 1 missing, 0 wrong copies
Free space: 50.0 GB
> Install
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: Confirm
1 file to download, 16.0 KB
BIOS folder: ~/.co.../retroarch/system
0 in place, 1 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB
> Install
Back
A/Enter Install B/Esc Back ? Help
+2 -2
View File
@@ -1,9 +1,8 @@
RetroBIOS: Confirm
1 file to download, 16.0 KB
BIOS folder: ~/.co.../retroarch/system
0 in place, 1 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB
> Install
@@ -12,4 +11,5 @@
A/Enter Install B/Esc Back ? Help
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: Confirm
1 file to download, 16.0 KB
0 in place, 1 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system
0 in place, 1 missing, 0 wrong copies
Free space: 50.0 GB
> Install
+1 -1
View File
@@ -3,7 +3,7 @@
Each row is an option of the command line, named on the right.
> Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data
Game data: kept --no-game-data
Installed cores only: no (1 found) --installed-cores
Cores: all --core
Parallel downloads: 8 --jobs
+1 -1
View File
@@ -1,7 +1,7 @@
RetroBIOS: Options
> Preferred regions: North America, Ja
Leave out game data: no
Game data: kept
Installed cores only: no (1 found)
Cores: all
Parallel downloads: 8
+1 -1
View File
@@ -1,7 +1,7 @@
RetroBIOS: Options
> Preferred regions: North America, Ja
Leave out game data: no
Game data: kept
Installed cores only: no (1 found)
Cores: all
Parallel downloads: 8
+1 -1
View File
@@ -3,7 +3,7 @@
Each row is an option of the command line, named on the right.
> Preferred regions: North America, Japan --region
Leave out game data: no --no-game-data
Game data: kept --no-game-data
Installed cores only: no (1 found) --installed-cores
Cores: all --core
Parallel downloads: 8 --jobs
+2 -2
View File
@@ -1,11 +1,10 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
1 in place, 3 missing, 1 wrong copy
BIOS folder: ~/.config/retroarch/system
1 in place, 3 missing, 1 wrong copy
Free space: 50.0 GB
They are replaced, and the old copies are kept in a backup folder.
2 files of the list are not in the collection.
> Install
Choose systems
@@ -35,6 +34,7 @@
A/Enter Install B/Esc Back Y/o Options ? Help
+2 -2
View File
@@ -1,9 +1,9 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.co.../retroarch/system
1 in place, 3 missing, 1 wrong copy
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB
> Install
Choose systems
Copy from your old card or a folder
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
BIOS folder: ~/.co.../retroarch/system
1 in place, 3 missing, 1 wrong copy
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB
They are replaced, and the old copies
are kept in a backup folder.
> Install
Choose systems
Copy from your old card or a folder
+2 -2
View File
@@ -1,11 +1,10 @@
RetroBIOS: RetroArch
4 files to download, 1.0 MB
1 in place, 3 missing, 1 wrong copy
BIOS folder: ~/.config/retroarch/system
1 in place, 3 missing, 1 wrong copy
Free space: 50.0 GB
They are replaced, and the old copies are kept in a backup folder.
2 files of the list are not in the collection.
> Install
Choose systems
@@ -21,4 +20,5 @@
A/Enter Install B/Esc Back Y/o Options ? Help
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: RetroArch
Everything is in place
5 in place, 0 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system
5 in place, 0 missing, 0 wrong copies
Free space: 50.0 GB
> Close
+2 -2
View File
@@ -1,12 +1,12 @@
RetroBIOS: RetroArch
Everything is in place
BIOS folder: ~/.co.../retroarch/system
5 in place, 0 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB
> Close
Options
A Close B Back Y Options ? Help
+2 -2
View File
@@ -1,9 +1,8 @@
RetroBIOS: RetroArch
Everything is in place
BIOS folder: ~/.co.../retroarch/system
5 in place, 0 missing, 0 wrong copies
BIOS folder:
~/.config/retroarch/system
Free space: 50.0 GB
> Close
@@ -12,4 +11,5 @@
A Close B Back Y Options ? Help
+1 -1
View File
@@ -1,8 +1,8 @@
RetroBIOS: RetroArch
Everything is in place
5 in place, 0 missing, 0 wrong copies
BIOS folder: ~/.config/retroarch/system
5 in place, 0 missing, 0 wrong copies
Free space: 50.0 GB
> Close
+1 -1
View File
@@ -6,7 +6,6 @@
The files it replaced are kept in /home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
2 files of the list are not in the collection, 1 of them needed to start an emulator.
Same choice without questions:
python3 install.py --platform retroarch --dest /home/user/.config/retroarch/system
Details: /tmp/retrobios-x/install.log
@@ -35,6 +34,7 @@
A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -8,5 +8,5 @@
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
To put them back: python3 install.py
1-4 of 9
1-4 of 8
A/Enter Exit Up/Down Scroll
+1 -1
View File
@@ -11,5 +11,5 @@
--restore
/home/user/.config/retroarch/
retrobios-backup/2026-10-23_1200
1-5 of 9
1-5 of 8
A/Enter Exit Up/Down Scroll
+2 -2
View File
@@ -7,8 +7,6 @@
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
To put them back: python3 install.py --restore
/home/user/.config/retroarch/retrobios-backup/2026-10-23_1200
2 files of the list are not in the collection, 1 of them needed to start an
emulator.
Same choice without questions:
python3 install.py --platform retroarch --dest
/home/user/.config/retroarch/system
@@ -21,4 +19,6 @@
A/Enter Exit Up/Down Scroll
+16 -13
View File
@@ -699,13 +699,14 @@ class FirstInstallWalkthrough(unittest.TestCase):
step3 = _section(self.run, "The file index and the download")
step4 = _section(self.run, "The end of the run")
step5 = _section(self.run, "A later check")
# First install names the three endings a run prints, and no count:
# a figure from one setup read as the size of every install.
# First install names the three endings the full screen shows as
# its title, and no count: a figure from one setup read as the size
# of every install.
source = (ROOT / "install.py").read_text(encoding="utf-8")
for ending in ("Done.", "Finished with errors.", "Nothing was installed."):
for ending in ("Done", "Finished with errors", "Nothing installed"):
with self.subTest(ending=ending):
self.assertIn(f"`{ending}`", _section(self.text, "5. Read the last line"))
self.assertIn(f'"\\n{ending} ', source)
self.assertIn(f"**{ending}**", _section(self.text, "5. Read the result"))
self.assertIn(f'title = "{ending}"', source)
self.assertNotRegex(self.text, r"Done\. \d+ files")
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups()
self.assertIn(f"0/{count} present", step3)
@@ -726,15 +727,17 @@ class FirstInstallWalkthrough(unittest.TestCase):
self.assertIn(f"all {required} belong", step4)
self.assertIn(f"other {total - required} are optional", step4)
def test_no_platform_detected_reads_like_the_installer(self):
source = (ROOT / "install.py").read_text(encoding="utf-8")
branch = source.split('print(" No supported platform detected.")', 1)[1][:1600]
self.assertIn("if not interactive or listing:", branch)
self.assertIn("_prompt_manual_platform(os_type)", branch)
def test_nothing_found_reads_like_the_installer(self):
# The one-liner hands the terminal to the installer, which opens the
# full screen: its first screen says the heading and offers the row.
import install
self.assertIn('"$INSTALLER" "$@" </dev/tty', (ROOT / "install.sh").read_text(encoding="utf-8"))
section = _section(self.text, "No supported platform detected.")
self.assertIn("a menu follows", section)
self.assertIn("--no-input", section)
state = install.TuiState()
screen = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("Nothing was found here.", screen)
section = _section(self.text, "Nothing was found here.")
self.assertIn(f"**{install.tui_rows(state)[-1].label}**", section)
self.assertNotIn("cannot stop and ask", section)
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -353,7 +353,8 @@ class TheInterfaceReadsAFoundEvent(unittest.TestCase):
state, _before = self.receive("check")
self.assertEqual(state.found, [self.FOUND])
state, _before = self.receive("install")
self.assertEqual(state.local_dests, {"scph5501.bin"})
self.assertEqual(list(state.local_dests), ["scph5501.bin"])
self.assertEqual(state.local_dests["scph5501.bin"][0], self.FOUND["folder"])
def test_a_dry_run_ignores_it(self):
for run in ("confirm", "preview"):
+535 -17
View File
@@ -28,7 +28,7 @@ import threading
import time
import unittest
import warnings
from pathlib import Path
from pathlib import Path, PureWindowsPath
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -656,9 +656,10 @@ class Flow(unittest.TestCase):
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
self.assertEqual((state.screen, effects), ("setup", []))
self.assertEqual(state.setups, [("minui", card, "MinUI SD card")])
label = f"MinUI SD card ({card.name})"
self.assertEqual(state.setups, [("minui", card, label)])
self.assertEqual([row.label for row in install.tui_rows(state)],
["MinUI SD card", "Another setup or folder"])
[label, "Another setup or folder"])
def test_an_error_offers_another_try(self):
state = fresh_state()
@@ -717,9 +718,11 @@ class PlanWarnings(unittest.TestCase):
def test_a_folder_that_cannot_take_the_files_says_so_first(self):
state = checked_state({"free": 1024, "writable": False, "cores": []})
text = " ".join(install.tui_view(state, 120, 40))
self.assertIn("Not enough free space for this download", text)
self.assertIn("You cannot write to this folder: choose another one, or run the "
"installer with sudo.", text)
self.assertIn("Not enough free space: 1.0 MB needed.", text)
self.assertIn("You cannot write to this folder: choose another one.", text)
# sudo changes the home folder: the advice names this one.
self.assertIn("Or run it as root, this folder named: sudo python3 install.py "
f"--platform retroarch --dest {SYSTEM_DIR}", " ".join(text.split()))
rows = install.tui_rows(state)
self.assertEqual([row.action for row in rows[:2]], ["folder", "install"])
self.assertEqual(state.focus.get("plan", 0), 0)
@@ -880,6 +883,7 @@ def golden_states() -> dict:
installing.run, installing.running = "install", True
installing.download = {"event": "download", "files": 4, "bytes": 1064964}
installing.samples, installing.now = [(100.0, 12672), (104.0, 540672)], 104.0
installing.network = 540672
installing.progress = {"files_done": 2, "files_total": 4, "bytes_done": 540672,
"bytes_total": 1064964}
installing.finished = [("gba_bios.bin", True, ""), ("scph5500.bin", False, "HTTP 404")]
@@ -1100,6 +1104,10 @@ class SmallFixes(unittest.TestCase):
install.tui_receive(state, ("event", state.run_id, {
"event": "progress", "files_done": 0, "files_total": 2,
"bytes_done": done, "bytes_total": 1000 * mega}))
# The partial copy resumed is counted local by the engine.
install.tui_receive(state, ("event", state.run_id, {
"event": "transfer", "bytes_local": 500 * mega,
"bytes_network": done - 500 * mega}))
text = "\n".join(install.tui_view(state, 80, 24))
self.assertIn("1.0 MB/s, 8:18 left", text)
@@ -1248,8 +1256,9 @@ class CleanupSurvivesABrokenTerminal(unittest.TestCase):
code = install.run_tui(_args())
self.assertIs(sys.stdout, tty)
self.assertEqual(code, 1)
# The crash keeps its log, and nothing else of the run.
self.assertEqual(sorted(path.name for path in work.iterdir()), ["error.log"])
# The crash keeps its log, marked kept, and nothing else of the run.
self.assertEqual(sorted(path.name for path in work.iterdir()),
["error.log", "kept", "owner"])
self.assertEqual(termios.tcgetattr(slave), before)
for number, handler in handlers.items():
self.assertEqual(signal.getsignal(number), handler)
@@ -1384,7 +1393,7 @@ class AuditRoundTwo(unittest.TestCase):
_title, lines = install.tui_outcome(state)
self.assertEqual([line for line in lines if "failed" in line],
["Nothing was installed: 50 files failed.",
" 50 files failed (folder not writable), the first f0.bin"])
" 50 files failed, all for one reason: folder not writable."])
def test_p2_6_a_folder_inside_a_card_offers_its_root(self):
card = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
@@ -1444,7 +1453,12 @@ class AuditRoundTwo(unittest.TestCase):
("retroarch", SYSTEM_DIR, "RetroArch"),
("emudeck", HOME / "Emulation/bios", "EmuDeck")])
self.assertEqual([label for _p, _f, label in setups],
["RetroArch (Flatpak)", "RetroArch (retroarch/system)", "EmuDeck"])
["RetroArch (Flatpak)", "RetroArch (installed)", "EmuDeck"])
# Two that no package names keep the end of their folder.
twice = install.tell_apart([("retroarch", SYSTEM_DIR, "RetroArch"),
("retroarch", HOME / "ra2/system", "RetroArch")])
self.assertEqual([label for _p, _f, label in twice],
["RetroArch (retroarch/system)", "RetroArch (ra2/system)"])
state = fresh_state()
state.setups = setups
self.assertIn("RetroArch (Flatpak)", "\n".join(install.tui_view(state, 40, 15)))
@@ -1634,7 +1648,8 @@ class CopyFromAnOldCard(unittest.TestCase):
self.recheck(state, [found("scph5500.bin", str(old)), found("gba_bios.bin", str(old))])
self.assertEqual(state.screen, "plan")
text = "".join("".join(install.tui_view(state, 120, 40)).split())
self.assertIn("".join(f"2 found in {old}, 2 to download (512.0 KB)".split()), text)
# The folder by its name: the whole path pushed the BIOS folder off.
self.assertIn("".join(f"2 found in {old.name}, 2 to download (512.0 KB)".split()), text)
# A second folder from the options: --from twice.
press(state, "o")
state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state))
@@ -1655,11 +1670,15 @@ class CopyFromAnOldCard(unittest.TestCase):
def test_left_takes_the_last_folder_back(self):
state = checked_state()
state.from_folders = [Path("/media/old")]
state.checked_flags = install.tui_flags(state)
state.stack.append("options")
state.focus["options"] = next(i for i, r in enumerate(install.tui_rows(state))
if r.action == "from")
self.assertEqual(press(state, "left"), [("run", "check")])
self.assertEqual(state.from_folders, [])
# Said, and the screen stays: Back to the plan counts again.
self.assertEqual(press(state, "left"), [])
self.assertEqual((state.screen, state.from_folders), ("options", []))
self.assertIn("No longer copying from old.", "\n".join(install.tui_view(state, 80, 24)))
self.assertEqual(press(state, "esc"), [("run", "check")])
def test_the_command_line_folders_are_kept(self):
with mock.patch.object(install, "detect_platforms", return_value=[]), \
@@ -1699,10 +1718,17 @@ class CopyFromAnOldCard(unittest.TestCase):
(10.0, {"event": "downloaded", "dest": "big.bin", "ok": True, "reason": "",
"size": 200 * mega, "files_done": 1, "files_total": 2,
"bytes_done": 200 * mega, "bytes_total": 300 * mega}),
# The engine says which bytes came from this machine.
(10.0, {"event": "transfer", "bytes_local": 200 * mega,
"bytes_network": 0}),
(11.0, {"event": "progress", "files_done": 1, "files_total": 2,
"bytes_done": 201 * mega, "bytes_total": 300 * mega}),
(11.0, {"event": "transfer", "bytes_local": 200 * mega,
"bytes_network": mega}),
(12.0, {"event": "progress", "files_done": 1, "files_total": 2,
"bytes_done": 202 * mega, "bytes_total": 300 * mega})]
"bytes_done": 202 * mega, "bytes_total": 300 * mega}),
(12.0, {"event": "transfer", "bytes_local": 200 * mega,
"bytes_network": 2 * mega})]
for when, event in events:
state.now = when
install.tui_receive(state, ("event", state.run_id, event))
@@ -1710,6 +1736,495 @@ class CopyFromAnOldCard(unittest.TestCase):
self.assertIn("1.0 MB/s, 1:38 left", text)
class AuditRoundThree(unittest.TestCase):
"""tui_audit_2.md: its P1 and P2 findings, each held by a test."""
CARDS = {
"muOS": ({"MUOS/": ""}, "retroarch", "MUOS/bios"),
"Onion": ({".tmp_update/onionVersion/version.txt": "4.3.1\n"}, "retroarch", "BIOS"),
"spruceOS": ({"spruce/": ""}, "retroarch", "BIOS"),
"KNULLI": ({"system/batocera.conf": "a=1\nsystem.hostname=KNULLI\n"}, "knulli",
"bios"),
"ROCKNIX": ({".config/system/configs/system.cfg": "root.password=rocknix\n"},
"rocknix", "roms/bios"),
}
def card(self, files: dict) -> Path:
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
for name, text in files.items():
path = root / name
if name.endswith("/"):
path.mkdir(parents=True, exist_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return root
# P1-1: a handheld's card mounted on a computer.
def test_p1_1_each_card_by_its_own_files(self):
for name, (files, plat, folder) in self.CARDS.items():
with self.subTest(card=name):
root = self.card(files)
self.assertEqual(install.card_system(root), (plat, root / folder, name))
def test_p1_1_a_card_nothing_names_is_left_alone(self):
self.assertIsNone(install.card_system(self.card({"roms/": "", "BIOS/": ""})))
# Batocera's share and a KNULLI board seeded with its name read alike.
self.assertIsNone(install.card_system(self.card({
"system/batocera.conf": "system.hostname=BATOCERA\n"})))
def test_p1_1_the_card_rules_are_the_device_rules(self):
# The folder and the marker come from the rule the device reads, so
# they are never written twice.
rules = {rule.name: rule for rule in install._card_rules()}
self.assertLessEqual({"muOS", "Onion", "spruceOS", "CrossMix-OS", "Allium", "Koriki",
"KNULLI", "ROCKNIX"}, set(rules))
device = {rule.name: rule for rule in install._EMBEDDED_RULES
if rule.values and rule.values[0].startswith(install._CARD_MOUNTS)}
for name, rule in device.items():
if rule.bios and rule.manifest:
with self.subTest(system=name):
self.assertTrue(rule.bios.endswith("/" + rules[name].bios))
self.assertEqual(install.DEFAULT_DESTS["knulli"], Path("/userdata") / "bios")
self.assertEqual(install.DEFAULT_DESTS["rocknix"], Path("/storage") / "roms/bios")
def test_p1_1_the_first_screen_offers_the_card_folder(self):
root = self.card(self.CARDS["muOS"][0])
with mock.patch.object(install, "_mounted_cards", return_value=[root]), \
mock.patch.object(install, "detect_platforms", return_value=[]), \
mock.patch.object(install, "detect_frontends", return_value=[]):
state, effects = install.tui_start(_args(), "linux")
label = f"muOS SD card ({root.name})"
self.assertEqual(state.setups, [("retroarch", root / "MUOS" / "bios", label)])
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertIn(f"> {label}", text)
self.assertIn(f"BIOS folder: {root / 'MUOS' / 'bios'}", text)
(root / "MUOS" / "bios").mkdir()
self.assertEqual(press(state, "enter"), [("fetch", "retroarch")])
self.assertEqual(state.dest, root / "MUOS" / "bios")
def test_p1_1_a_drive_letter_names_the_card(self):
self.assertEqual(install._mount_name(PureWindowsPath("E:\\")), "E:\\")
self.assertEqual(install._mount_name(Path("/media/me/MUOS")), "MUOS")
def test_p1_1_on_the_device_its_card_is_not_offered_twice(self):
root = self.card(self.CARDS["muOS"][0])
bound = Path("/run/muos/storage/bios")
with mock.patch.object(install, "_mounted_cards", return_value=[root]), \
mock.patch.object(install, "detect_platforms", return_value=[("retroarch",
bound)]), \
mock.patch.object(install, "detect_frontends", return_value=[]), \
mock.patch.dict(install._DETECTED_OS, {("retroarch", bound): "muOS"}):
state, _effects = install.tui_start(_args(), "linux")
self.assertEqual([setup[1] for setup in state.setups], [bound])
def test_p1_1_browsing_to_the_card_root_uses_its_folder(self):
root = self.card(self.CARDS["KNULLI"][0])
with mock.patch.object(install, "_mounted_cards", return_value=[root]):
rows, _error = install._browse_rows(REPO_ROOT / "tests")
self.assertEqual(rows[2].label, f"{root} (KNULLI card)")
state = fresh_state()
install._choose(state, "knulli", root)
self.assertEqual(state.dest, root / "bios")
# Another platform's list keeps the folder it was given.
install._choose(state, "batocera", root)
self.assertEqual(state.dest, root)
def test_p1_1_the_command_line_writes_where_muos_reads(self):
root = self.card(self.CARDS["muOS"][0])
with FakeRepository() as repo:
proc = install_run("--platform", "muos", "--dest", str(root), "--yes",
env=repo.env())
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
self.assertIn(f"muOS card: RetroArch files, in {root / 'MUOS' / 'bios'}", proc.stdout)
self.assertEqual(sorted(path.name for path in root.iterdir()), ["MUOS"])
self.assertTrue((root / "MUOS" / "bios" / "scph5501.bin").is_file())
def test_p1_1_a_folder_that_is_no_muos_card_is_refused(self):
root = self.card({"roms/": ""})
proc = install_run("--platform", "muos", "--dest", str(root), "--yes")
self.assertEqual(proc.returncode, 1)
self.assertIn(f"{root} is not a muOS card: it holds no MUOS.", proc.stderr)
self.assertIn("--dest <card>/MUOS/bios", proc.stderr)
self.assertEqual(list(root.iterdir()), [root / "roms"])
# Its BIOS folder named outright is taken as given.
self.assertEqual(install.card_dest("muos", "retroarch", root / "MUOS" / "bios"),
("retroarch", root / "MUOS" / "bios"))
# On the device, the folder muOS binds it to.
bound = Path("/run/muos/storage/bios")
self.assertEqual(install.card_dest("muos", "retroarch", bound), ("retroarch", bound))
# A list named by its own name says which list, not which card.
self.assertEqual(install.card_dest("retroarch", "retroarch", root),
("retroarch", root))
# P1-2: Space is X on PortMaster and Y on muxterm.
def test_p1_2_space_on_install_opens_the_confirm_screen(self):
state = checked_state()
self.assertEqual(install.tui_rows(state)[0].action, "install")
self.assertEqual(press(state, " "), [("run", "confirm")])
self.assertEqual(state.screen, "confirm")
# The same plan comes back: the screen still waits for A.
install.tui_receive(state, ("event", state.run_id, dict(CHECK)))
self.assertEqual(install.tui_receive(state, ("ended", state.run_id, 0, [])), [])
self.assertEqual(state.screen, "confirm")
self.assertEqual(install.tui_rows(state)[0].action, "install")
self.assertEqual(press(state, " "), [])
self.assertEqual(state.screen, "confirm")
self.assertEqual(press(state, "enter"), [("run", "install")])
def test_p1_2_space_never_closes_or_quits(self):
for action in ("quit", "close"):
with self.subTest(row=action):
state = checked_state()
if action == "close":
state.check = {**CHECK, "missing": 0, "wrong_hash": 0, "bytes_needed": 0}
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, r in enumerate(rows) if r.action == action)
self.assertEqual(press(state, " "), [])
self.assertIsNone(state.exit_code)
state = checked_state()
state.focus["plan"] = next(i for i, r in enumerate(install.tui_rows(state))
if r.action == "options")
press(state, " ")
self.assertEqual(state.screen, "options")
def test_p1_2_enter_still_installs_in_three_presses(self):
state = checked_state()
self.assertEqual(press(state, "enter"), [("run", "install")])
self.assertEqual(state.screen, "installing")
# P2-1: a card too small for the download.
def test_p2_1_another_folder_comes_first_when_space_is_short(self):
state = checked_state({"free": 1024, "writable": True, "cores": []})
self.assertEqual([row.label for row in install.tui_rows(state)[:2]],
["Choose another folder", "Install anyway"])
self.assertEqual(press(state, "enter"), [])
self.assertEqual(state.screen, "folder")
FULL_TAIL = [
" Checking existing files...",
" 0/50 present (0 verified, 0 wrong hash)",
" Downloading 50 files (845.7 KB)...",
"Not enough space on the drive holding /media/me/MUOS/MUOS/bios.",
" Needed: 1.2 GB. Free: 300.0 MB.",
" Free up 920.0 MB, or install to another drive:",
" python3 install.py --platform retroarch --dest '<folder on another drive>'",
]
def test_p2_1_a_full_card_is_said_in_words(self):
root = self.card(self.CARDS["muOS"][0])
state = checked_state()
state.dest, state.log = root / "MUOS" / "bios", "/tmp/retrobios-tui-x/install.log"
state.stack.append("result")
state.code, state.tail = 2, list(self.FULL_TAIL)
with mock.patch.object(install, "_mounted_cards", return_value=[root]):
title, lines = install.tui_outcome(state)
self.assertEqual(title, "Not enough space")
self.assertEqual(lines[0], "The card is full: 1.2 GB needed, 300.0 MB free.")
self.assertIn("Free some space, or run again and choose another folder.", lines)
text = "\n".join(lines)
for raw in ("Checking existing files", "Downloading", "stopped with code", "Details:"):
self.assertNotIn(raw, text)
self.assertFalse(install.tui_keeps_log(state))
def test_p2_1_advice_never_names_the_interface_list(self):
if hasattr(os, "geteuid") and os.geteuid() == 0:
self.skipTest("root writes to a read-only folder")
folder = self.card({})
folder.chmod(0o555)
self.addCleanup(folder.chmod, 0o755)
with FakeRepository() as repo:
listed = repo.root / "install" / "retroarch.json"
proc = install_run("--platform", "retroarch", "--dest", str(folder),
f"--manifest={listed}", "--yes",
env={**repo.env(), install.TUI_LIST_ENV: "1"})
self.assertIn("sudo", proc.stdout + proc.stderr)
self.assertNotIn("--manifest", proc.stdout + proc.stderr)
# P2-2: the BIOS folder line on a handheld screen.
def test_p2_2_the_plan_keeps_the_folder_at_handheld_sizes(self):
zipped = "/home/me/a/long/path/to/the/Downloads/PSX BIOS.zip"
for cols, rows in ((40, 12), (40, 15)):
with self.subTest(size=f"{cols}x{rows}"):
state = checked_state()
state.dest = Path("/home/someone/with/a/long/path/.config/retroarch/system")
state.found = [found("scph5500.bin", zipped)]
state.cols, state.rows = cols, rows
lines = [line.strip() for line in install.tui_view(state, cols, rows)]
folder = next(line for line in lines if line.startswith("BIOS folder: "))
self.assertTrue(folder.endswith("retroarch/system"), folder)
self.assertIn("...", folder)
self.assertIn("1 found in PSX BIOS.zip,", " ".join(lines))
self.assertNotIn("/home/me/a/long", " ".join(lines))
# P2-3: where the files came from.
def test_p2_3_the_result_says_what_came_from_the_card(self):
state = checked_state()
state.stack.append("result")
state.code, state.download = 0, {"files": 5, "bytes": 10}
state.local_dests = {"scph5500.bin": ("/media/me/OLD", ""),
"scph5501.bin": ("/media/me/OLD", ""),
"gba_bios.bin": (str(SYSTEM_DIR), "one folder too deep")}
state.finished = [("scph5500.bin", True, ""), ("scph5501.bin", True, ""),
("gba_bios.bin", True, ""), ("pcsx/extra.bin", True, ""),
("fc/disksys.rom", False, "HTTP 404")]
state.summary = {"installed": 4, "up_to_date": 0, "failed": [
{"dest": "fc/disksys.rom", "reason": "HTTP 404"}]}
_title, lines = install.tui_outcome(state)
self.assertEqual(lines[1], "2 files copied from OLD, 1 fixed from copies "
"already in the BIOS folder, 1 downloaded, 1 still missing.")
# The line mode's own sentence, from the same counts.
self.assertEqual(lines[1], install.sources_line([("OLD", 2)], 1, 1, 1))
def test_p2_3_a_run_that_copied_nothing_adds_no_line(self):
state = install.TuiState()
state.summary, state.finished = {"installed": 2, "failed": []}, [("a", True, "")]
self.assertEqual(install._tui_sources(state, 2, 0), "")
# P2-4 and P2-5: the systems list.
def systems_state(self) -> install.TuiState:
state = checked_state()
state.systems = [*state.systems,
{"system": "other", "name": "other", "files": 2, "present": 0,
"missing": 2, "wrong_hash": 0, "bytes_needed": 300},
{"system": "3do", "name": "3do", "files": 3, "present": 0,
"missing": 3, "wrong_hash": 0, "bytes_needed": 600}]
state.files = [*state.files,
{"event": "file", "dest": "panafz1.bin", "systems": ["3do"],
"size": 100, "state": "missing"},
{"event": "file", "dest": "mame/hash/a.xml", "systems": ["3do", "mac"],
"size": 200, "state": "missing"},
{"event": "file", "dest": "mame/hash/b.xml", "systems": ["3do", "mac"],
"size": 300, "state": "missing"}]
state.ticked |= {"other", "3do"}
state.stack.append("systems")
return state
def test_p2_4_the_other_row_is_named_and_explained(self):
state = self.systems_state()
rows = install.tui_rows(state)
other = next(i for i, row in enumerate(rows) if row.value == "other")
self.assertEqual(rows[other].label, "[x] Other systems and game engines")
state.focus["systems"] = other
text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
self.assertIn("Other systems and game engines: what cores load for systems this "
"setup does not list", text)
def test_p2_5_shared_files_are_counted_apart(self):
state = self.systems_state()
rows = install.tui_rows(state)
three = next(i for i, row in enumerate(rows) if row.value == "3do")
self.assertEqual(rows[three].right, "1 file, 100 B + 2 shared")
state.focus["systems"] = three
text = " ".join(" ".join(install.tui_view(state, 80, 24)).split())
self.assertIn("2 of these files are shared with other systems, counted once", text)
# The line mode counts the same files apart.
self.assertIn("and 2 files, 500 B shared with other systems", install._system_sized([
{"dest": "panafz1.bin", "size": 100, "systems": ["3do"]},
{"dest": "a.xml", "size": 200, "systems": ["3do", "mac"]},
{"dest": "b.xml", "size": 300, "systems": ["3do", "mac"]}]))
narrow = install.tui_view(state, 40, 15)
self.assertTrue(any("100 B + 2 shared" in line for line in narrow), narrow)
# P2-6: a file that cannot be supplied, by name, where it matters.
def unavailable_state(self) -> install.TuiState:
state = checked_state()
state.manifest = {**MANIFEST, "system_titles": {"nintendo-gba": "Game Boy Advance"},
"omitted_files": [
{"dest": "gba_extra.bin", "system": "nintendo-gba",
"required": True, "reason": "not_found"},
{"dest": "optional.bin", "system": "nintendo-gba",
"required": False, "reason": "not_found"}]}
state.stack.append("result")
state.code, state.download = 0, {"files": 1, "bytes": 1}
state.summary = {"installed": 1, "up_to_date": 0, "failed": []}
return state
def test_p2_6_the_missing_file_is_named_with_its_system(self):
state = self.unavailable_state()
_title, lines = install.tui_outcome(state)
self.assertIn("Not available here, and needed to start its emulator: gba_extra.bin "
"(Game Boy Advance). Your own copy goes in the BIOS folder.", lines)
self.assertNotIn("collection", " ".join(lines))
def test_p2_6_it_is_left_out_when_it_does_not_concern_the_run(self):
state = self.unavailable_state()
state.ticked = {"sony-playstation"}
self.assertEqual(install._unavailable_lines(state), [])
state = self.unavailable_state()
state.summary = {"installed": 1, "failed": [], "interrupted": True}
self.assertFalse(any("Not available" in line for line in install.tui_outcome(state)[1]))
# P3: small fixes the audit listed.
def fetch_failure(self, error: BaseException, env: dict | None = None) -> tuple:
inbox = __import__("queue").Queue()
with mock.patch.object(install, "_fetch_json", side_effect=error), \
mock.patch.dict(os.environ, env or {}):
for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"):
if name not in (env or {}):
os.environ.pop(name, None)
install._tui_fetch("retroarch", inbox, io.StringIO())
_kind, _plat, title, lines, _code = inbox.get_nowait()
return title, lines
def test_p3_the_clock_names_the_row_to_choose(self):
import ssl
error = urllib_error(ssl.SSLCertVerificationError("certificate is not yet valid"))
words = install.network_words(error, today=install.datetime.date(1980, 1, 1))
with mock.patch.object(install, "network_words", return_value=words):
_title, lines = self.fetch_failure(error)
self.assertTrue(lines[0].endswith("then choose Try again."), lines[0])
def test_p3_a_proxy_in_the_way_is_named_not_shown(self):
error = urllib_error(ConnectionRefusedError(111, "Connection refused"))
_title, lines = self.fetch_failure(
error, {"HTTPS_PROXY": "http://me:secret@10.0.0.1:3128"})
self.assertIn("HTTPS_PROXY is set, so the connection goes through a proxy: check "
"that it answers.", lines)
self.assertNotIn("secret", " ".join(lines))
_title, lines = self.fetch_failure(error)
self.assertFalse(any("PROXY" in line for line in lines))
def test_p3_a_list_that_is_no_json_is_said_in_words(self):
try:
install.json.loads("<html>")
except install.json.JSONDecodeError as cause:
error = ValueError(f"invalid file list: {cause}")
error.__cause__ = cause
title, lines = self.fetch_failure(error)
self.assertEqual(title, "The file list is not valid")
self.assertNotIn("Expecting value", " ".join(lines))
self.assertIn("a Wi-Fi sign-in page or a cut download looks like this.", lines[0])
def test_p3_the_preview_counts_its_files_only(self):
state = checked_state()
state.stack += ["options", "preview"]
state.dry = dict(CHECK)
state.dry_files = [{**FILES[0], "dest": f"f{n}.bin"} for n in range(40)]
state.rows = 24
lines = [line.strip() for line in install.tui_view(state, 80, 24)]
self.assertIn("1-18 of 40", lines)
def test_p3_game_data_reads_without_a_double_negative(self):
state = checked_state()
label = next(row.label for row in install._option_rows(state)
if row.action == "game_data")
self.assertEqual(label, "Game data: kept")
state.no_game_data = True
label = next(row.label for row in install._option_rows(state)
if row.action == "game_data")
self.assertEqual(label, "Game data: left out, firmware only")
def test_p3_the_plan_says_which_config_named_the_folder(self):
config = HOME / ".config/retroarch/retroarch.cfg"
with mock.patch.dict(install._RETROARCH_CONFIGS, {SYSTEM_DIR: (config, config.parent)}):
text = "\n".join(install.tui_view(checked_state(), 80, 24))
self.assertIn("Named by ~/.config/retroarch/retroarch.cfg", text)
# The engine's own events (main, after the second audit).
def test_the_speed_is_the_network_bytes(self):
state = checked_state()
state.stack.append("installing")
state.run, state.running = "install", True
install.tui_receive(state, ("event", state.run_id,
{"event": "download", "files": 4, "bytes": 4 << 20}))
for now, done, network in ((100.0, 1 << 20, 0), (102.0, 3 << 20, 1 << 20)):
state.now = now
install.tui_receive(state, ("event", state.run_id, {
"event": "progress", "files_done": 1, "files_total": 4, "bytes_done": done,
"bytes_total": 4 << 20}))
install.tui_receive(state, ("event", state.run_id, {
"event": "transfer", "bytes_local": done - network, "bytes_network": network}))
text = "\n".join(install.tui_view(state, 80, 24))
# 1 MB from the network in 2 s, whatever came from the old card.
self.assertIn("512.0 KB/s, 0:02 left", text)
def test_the_plan_says_what_the_scan_left_out(self):
state = checked_state()
state.from_folders = [Path("E:\\")]
install._start_check(state)
events = [{"event": "scanned", "folder": "E:\\", "files": 200000, "truncated": True,
"other_drives": ["E:\\D"]},
{"event": "unreadable", "platform": "retroarch", "folder": "E:\\",
"path": "E:\\bios\\scph5501.bin", "member": "", "reason": "Permission denied"},
{"event": "unreadable", "platform": "retroarch", "folder": "E:\\",
"path": "E:\\backup.zip", "member": "gba_bios.bin",
"reason": "it is encrypted, a password is needed"}]
for event in [*FILES, *SYSTEMS, *events, CHECK]:
install.tui_receive(state, ("event", state.run_id, event))
with mock.patch.object(install, "_tui_folder_facts", return_value={
"free": 50 * 1024 ** 3, "writable": True, "cores": []}):
install.tui_receive(state, ("ended", state.run_id, 1, []))
text = " ".join(" ".join(install.tui_view(state, 120, 40)).split())
self.assertIn("Read the first 200,000 files of E:\\.", text)
self.assertIn("E:\\D is another drive: add it to copy from it too.", text)
self.assertIn("2 files on your old card could not be read.", text)
rows = install.tui_rows(state)
state.focus["plan"] = next(i for i, row in enumerate(rows) if row.action == "unreadable")
press(state, "enter")
self.assertEqual(state.screen, "unreadable")
self.assertEqual(install._pager_items(state), [
"E:\\bios\\scph5501.bin: Permission denied",
"E:\\backup.zip, gba_bios.bin: it is encrypted, a password is needed"])
def test_the_interface_does_not_index_the_folders_itself(self):
missing = REPO_ROOT / "tmp" / "no-such-old-card"
err = io.StringIO()
with mock.patch.object(install, "FolderIndex", side_effect=AssertionError("indexed")), \
mock.patch.object(install, "detect_os", return_value="linux"), \
mock.patch.object(install, "tui_start", side_effect=AssertionError("opened")), \
mock.patch.object(sys, "stderr", err):
self.assertEqual(install.run_tui(_args(**{"from": [missing]})), 1)
self.assertIn(f"Error: {missing} does not exist: --from names the folder", err.getvalue())
# Work folders: only a kill no handler sees leaves one, and the next
# run takes it away.
def test_work_folders_left_behind_are_pruned(self):
import subprocess
root = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp"))
self.addCleanup(shutil.rmtree, root, True)
gone = subprocess.Popen([sys.executable, "-c", "pass"])
gone.wait()
now = time.time()
week = install.TUI_KEEP_SECONDS
def folder(name: str, pid: int | None, kept: bool, age: float) -> Path:
path = root / f"{install.TUI_WORK_PREFIX}{name}"
path.mkdir()
if pid is not None:
(path / install.TUI_OWNER).write_text(str(pid))
if kept:
(path / install.TUI_KEPT).write_text("")
os.utime(path, (now - age, now - age))
return path
killed = folder("killed", gone.pid, False, 60)
running = folder("running", os.getpid(), False, 2 * week)
failed = folder("failed", gone.pid, True, 60)
failed_old = folder("failed-old", gone.pid, True, 2 * week)
unknown = folder("unknown", None, False, 60)
unknown_old = folder("unknown-old", None, False, 2 * week)
other = root / "something-else"
other.mkdir()
removed = install._prune_work_folders(root, now)
self.assertEqual(sorted(removed), sorted([killed, failed_old, unknown_old]))
self.assertEqual(sorted(path.name for path in root.iterdir()),
sorted(path.name for path in (running, failed, unknown, other)))
class Crash(unittest.TestCase):
"""An unexpected error ends on one sentence and a log, never a traceback."""
@@ -2030,11 +2545,14 @@ class EndToEnd(unittest.TestCase):
term.send("\r")
screen = term.wait("found in")
# The folder wraps at a slash: compared without the spaces.
self.assertIn("".join(f"3 found in {old}, 2 to download (8 B)".split()),
"".join(screen.split()))
self.assertIn("3 found in oldcard, 2 to download (8 B)", screen)
# Checked again, the plan opens on Install.
term.send("\r")
term.wait("files installed.")
screen = term.wait("files installed.")
# Where each came from, as the line mode says it.
self.assertIn("5 files installed.", screen)
self.assertIn("3 files copied from", screen)
self.assertIn("2 downloaded.", screen)
term.send("\r")
self.assertEqual(term.close(), 0)
self.assertEqual(len(repo.installed()), 5)
+26
View File
@@ -96,3 +96,29 @@ class SemanticManifestValidation(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
try:
import jsonschema
except ImportError:
jsonschema = None
@unittest.skipIf(jsonschema is None, "jsonschema unavailable")
class DataRootIsKeyedBySystem(unittest.TestCase):
"""AetherSX2 gave Android/data/xyz.aethersx2.android only in its notes:
the site could not print the folder, nor which OS tabs apply."""
def _errors(self, value):
schema = json.loads((REPO_ROOT / "schemas/emulator.schema.json").read_text(encoding="utf-8"))
validator = jsonschema.Draft202012Validator(schema["properties"]["data_root"])
return list(validator.iter_errors(value))
def test_a_system_with_its_path_and_reference(self):
self.assertEqual(self._errors({"android": {
"path": "Android/data/xyz.aethersx2.android/files", "source_ref": "a.kt:1"}}), [])
def test_an_unknown_system_or_a_path_without_reference(self):
self.assertTrue(self._errors({"amiga": {"path": "x", "source_ref": "y"}}))
self.assertTrue(self._errors({"windows": {"path": "%APPDATA%/X"}}))
self.assertTrue(self._errors({}))
+10 -4
View File
@@ -125,15 +125,21 @@ class FirstInstallEnds(unittest.TestCase):
"""C7, B15: the page said `Done.` may list failures, and sent a wrong
folder to a section with no answer for it."""
def test_the_wrong_folder_answer_is_dest(self):
section = _section(_wiki("first-install.md"), "2. Check the folder it found")
self.assertIn("--dest", section)
def test_the_wrong_folder_answer_is_on_the_screen(self):
# The one-liner opens the full screen: its first screen has the
# answer, a row the page names as the interface spells it.
import install
section = _section(_wiki("first-install.md"), "2. Check the setup it found")
state = install.TuiState()
row = install.tui_rows(state)[-1].label
self.assertIn(f"**{row}**", section)
self.assertIn("<your setup>", section)
self.assertNotIn("Batocera", section)
def test_the_diagram_follows_the_steps(self):
text = _wiki("first-install.md")
self.assertLess(text.index("## 5. Read the last line"), text.index("<!-- image: install-flow -->"))
self.assertLess(text.index("## 5. Read the result"), text.index("<!-- image: install-flow -->"))
class OneNamePerPage(unittest.TestCase):
+15 -5
View File
@@ -251,11 +251,21 @@ class SmallCopyFixes(unittest.TestCase):
self.assertIn("`retroarch/cores/system/` on the SD card", guides)
self.assertIn("--dest E:\\retroarch\\cores\\system", section)
def test_the_end_line_is_the_printed_one(self):
section = _section(_wiki("first-install.md"), "5. Read the last line")
self.assertIn("`N files installed, M already up to date.`", section)
source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
self.assertIn("installed, \"\n f\"{_plural(up_to_date, 'file')} already up to date.", source)
def test_the_end_is_the_shown_one(self):
# The one-liner opens the full screen: the page names the titles
# its result screen shows, as tui_outcome writes them.
import install
section = _section(_wiki("first-install.md"), "5. Read the result")
state = install.TuiState()
state.stack, state.code = ["result"], 0
for summary in ({"installed": 3, "up_to_date": 0, "failed": []},
{"installed": 2, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]},
{"installed": 0, "failed": [{"dest": "a.bin", "reason": "HTTP 404"}]}):
state.summary = summary
title, _lines = install.tui_outcome(state)
with self.subTest(title=title):
self.assertIn(f"**{title}**", section)
class CommunityAnswers(unittest.TestCase):
+30 -34
View File
@@ -67,44 +67,44 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh |
On Batocera, Recalbox and similar setups, see
[Get your files](../which-pack.md).
## 2. Check the folder it found
## 2. Check the setup it found
It shows your setup and its BIOS folder:
It opens a menu:
```
Found <your setup> at <its folder>
RetroBIOS: Where do you play?
Found on this machine:
> <your setup>
Another setup or folder
BIOS folder: <its folder>
```
- **Right folder?** Go on.
- **Wrong folder?** Press Ctrl+C. Run it again with your folder:
- **Right folder?** Press Enter.
- **Wrong folder?** Choose **Another setup or folder**, then browse to yours.
- **A handheld's SD card in a reader?** It is listed, as **muOS SD card**.
<!-- os-tabs windows: --dest D:\path\to\bios -->
```sh
curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh -s -- --dest ~/path/to/bios
```
Arrows move, Enter chooses, Esc goes back. A controller's A and B do the same.
## 3. Choose what to install
## 3. Install
```
Enter) install everything
i) only what the installed cores read
c) choose systems, cores and regions
q) quit
```
The next screen counts the missing files and the free space.
Press Enter on **Install**. **Choose systems** keeps the consoles you
play.
**Enter** installs everything. **c** lets you pick consoles.
**Old card?** In a card reader, choose **Copy the BIOS files from your old
card or a folder**, then the card. It stays unchanged.
## 4. Wait
Ctrl+C stops it. Run the command again to carry on.
B or Ctrl+C stops it. Run it again to carry on.
## 5. Read the last line
## 5. Read the result
- **`Done.`** `N files installed, M already up to date.` All files are in place.
- **`Finished with errors.`** Some files failed. Run the same command again.
It retries only those.
- **`Nothing was installed.`** The lines above it say why. See
[If it stops](#if-it-stops).
- **Done**: all files are in place.
- **Finished with errors**: run the command again. It retries only those.
- **Nothing installed**: the screen says why. See [If it stops](#if-it-stops).
For questions line by line (screen readers), add `--plain`.
## What the command does
@@ -127,18 +127,14 @@ All good:
## Undo
Every file it replaces goes to a `retrobios-backup` folder next to your BIOS
folder. The last lines say how to put them back.
Files it replaces go to a `retrobios-backup` folder beside your BIOS folder.
The result says how to restore them.
## If it stops
### No supported platform detected.
### Nothing was found here.
At a terminal, a menu follows. Pick your setup by its number, then type its
BIOS folder, or press Enter for the usual one.
With `--no-input`, or without a terminal, it stops and prints the command to
use.
Choose **Another setup or folder**, then your setup and its folder.
### Error: Python 3.8 or newer is required.
@@ -157,9 +153,9 @@ The download was cut short. Nothing was changed. Run the command again.
You cannot write there. Do what the message says.
### Not enough space on the drive holding ...
### The card is full
Nothing was written. Free some space, or pick another drive with `--dest`.
Nothing was written. Free some space, or choose another folder.
### The game still does not start
+80 -29
View File
@@ -67,7 +67,10 @@ curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh \
```
`--dest` is the BIOS folder itself: on a card, `retroarch/cores/system` for
RetroArch on Switch, `RetroArch/system` on Android.
RetroArch on Switch, `RetroArch/system` on Android. The root of a handheld's
card works too when the installer knows that card (muOS, Onion, spruceOS,
KNULLI, ROCKNIX and the MinUI family, see
[Platform detection](#platform-detection)): the files go where its system reads.
PowerShell needs another form. `iex` takes the script text as its own argument,
so anything appended to `irm ... | iex` is read as an argument to `iex` and
@@ -454,6 +457,9 @@ python install.py --from ~/Downloads --from /media/me/backup
python install.py --check --from /media/me/STOCK
```
The first line names the root of the muOS card, the one holding `MUOS`: the
files go to `MUOS/bios` on it, where muOS reads them, and the run says so.
Every file is recognised by its contents, never by its name:
`PSX BIOS (USA).bin` in a subfolder lands as `scph5501.bin` when it holds those
bytes, and a file of the right name with other bytes (an empty file left by a
@@ -750,6 +756,26 @@ family writes the release name as the first line of `.system/version.txt` at
the card root (`MinUI-`, `NextUI-`, `MyMinUI-`), and that line names the file
list.
A card that holds a system's own files is read the same way, without
`--platform` or with the one that names its list, by the rules the table above
gives for the device, below the card's root instead of its mount point:
| On the card | System | Files go to |
|-------------|--------|-------------|
| `MUOS/` | muOS | `MUOS/bios`, RetroArch's list |
| `.tmp_update/onionVersion/version.txt` | Onion | `BIOS`, RetroArch's list |
| `spruce/` | spruceOS | `BIOS`, RetroArch's list |
| `System/usr/trimui/crossmix-version.txt` | CrossMix-OS | `BIOS`, RetroArch's list |
| `.allium/` | Allium | `BIOS`, RetroArch's list |
| `Koriki/version.txt` | Koriki | `BIOS`, RetroArch's list |
| `system/batocera.conf` with `system.hostname=KNULLI`, as KNULLI's first boot writes its share | KNULLI | `bios`, KNULLI's list |
| `.config/system/configs/system.cfg` with `root.password=rocknix`, as ROCKNIX's first boot writes its storage | ROCKNIX | `roms/bios`, ROCKNIX's list |
The full screen lists such a card on its first screen ("muOS SD card (E:\)").
Named by its system (`--platform muos`, `--platform onion`), a `--dest` that
is neither such a card nor its BIOS folder is refused, with the folder that
system reads.
Desktop installs are read from their own configuration, and several can be
found on one machine.
@@ -953,22 +979,25 @@ python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/syst
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.
Copy the file for your system to the SD card, then start RetroBIOS from the
menu.
The files below ship with the next release. Until then, build them from a
clone of the repository with `python scripts/build_port.py`, which writes all
four into `dist/`, or use the computer route at the end of this section.
| 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 |
| Any other system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/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 |
| 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 |
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/`.
`RetroBIOS.zip` also installs on muOS, from `MUOS/PortMaster/autoinstall/`
on the first card (`/mnt/mmc` on the device). It then shows the interface in
muxterm, as the muOS app does.
| Button | Action |
|--------|--------|
@@ -978,19 +1007,34 @@ of the repository, `python scripts/build_port.py` writes all four into
| X | Tick or untick a system |
| Y | Options |
| L1, R1 | Page up, page down |
| Start + Select | Quit at once (PortMaster port, Tools) |
| Hold Select, then Start | Quit at once (PortMaster port, muOS, Tools) |
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.
Start pressed first also chooses the line in focus: hold Select first. A
running install is then stopped the way B stops it, and the last screen says
what it wrote. On muOS without PortMaster, muxterm reads the pad itself: Y
ticks a system, Select shows its keyboard, and Menu quits.
**Without Wi-Fi.** On a computer, download the BIOS pack whose name contains
your system (every part, for a pack in parts) and copy it next to RetroBIOS:
into `ports/retrobios` for the PortMaster port and on Batocera, to the root
of the card on muOS, into the `retrobios` folder of the Tools entry. With no
Each launcher tells the interface which buttons reach it, in
`RETROBIOS_PAD`, so the hints can name them:
| Value | Input | Set by |
|-------|-------|--------|
| `gptokeyb` | gptokeyb or gptokeyb2 with `retrobios.gptk`, the table above | the PortMaster port, the Tools entry, the muOS app with PortMaster |
| `muxterm` | muxterm's own keys: D-pad, Start as Enter, B as Backspace, Y as Space | the muOS app without PortMaster |
| `evmapy` | `RetroBIOS.sh.keys`, the same buttons as the table | the Batocera and Knulli port |
| `miyoo` | buttons that are already keys, A as Space and B as Left Ctrl | no launcher yet |
A launcher with no mapper at all (a keyboard) leaves it unset.
**Without Wi-Fi.** On a computer, download the BIOS pack that serves your
system (every part, for a pack in parts): RetroArch's on muOS, ArkOS,
dArkOS and JELOS, Batocera's on Batocera. Copy it next to RetroBIOS: into
`ports/retrobios` for the PortMaster port and on Batocera, to the root of
either card on muOS, into the `retrobios` folder of the Tools entry. With no
connection, RetroBIOS installs from it, as
[`--from-pack`](#without-a-network-offline-install) does. With no pack there,
it names the pack and the folder.
it names the pack and the folder. The releases carry no pack for ROCKNIX,
Knulli or AmberELEC: there RetroBIOS needs Wi-Fi, or the computer route.
On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS
files from your other card**: muOS's `MUOS/bios` on the card the system does
@@ -1003,28 +1047,24 @@ still comes from the pack, so the pack is needed too.
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).
[Platform detection](#platform-detection). On a read-only or full card,
RetroBIOS keeps its log in `retrobios` under `XDG_RUNTIME_DIR` or `/tmp`, and the interface names the
folder it cannot write.
**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.
and stops when console 4 does not come to the front. `retrobios/log.txt`
names each route it tried, and `log.prev.txt` keeps the run before.
**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
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 }
<span id="without-a-network"></span>
@@ -1150,6 +1190,13 @@ The build scripts need 3.9 and are not bound by this.
Every change to `install.py` recomputes the SHA-256 pinned in `install.sh` and
`install.ps1` in the same commit. The tests compare the pins with the file.
The [handheld](#on-a-handheld-the-retrobios-port) archives each carry
`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.
Narrowing by system, core, region or target replays the pack builder's rules
from fields the manifest carries, and a parity test compares an installer
selection with the pack built the same way. Given a manifest without those
@@ -1165,6 +1212,10 @@ fields, the installer refuses to narrow rather than install everything.
| `130` | stopped with Ctrl+C, or with `q`, Ctrl+C or Ctrl+D at a question. Run the same command again to carry on |
| `141` | the output was piped to a reader that closed early (`--list-platforms \| head`) |
The full screen ended by SIGTERM (Start+Select on a handheld) before any
install exits `143`. During an install it stops the install first, and exits
`130`.
`install.ps1` passes the installer's code on unchanged: run as a file it exits
with it, run through `irm \| iex` it sets `$LASTEXITCODE` (an `exit` there
would close the PowerShell window). It declares no parameter of its own, so
+1
View File
@@ -591,6 +591,7 @@ does not look.
| `known_hash_adler32` | expected Adler-32 hash (Dolphin's DSP ROMs). Pair with `adler32_byteswap` when the code hashes 16-bit byte-swapped data |
| `adler32_pairs_with`, `adler32_pairs` | the file hashed together with this one and the combinations the code accepts, this file's value first, in table order: Dolphin warns on a DSP ROM pair outside its `known_roms` even when each file passes alone |
| `pin_frozen` | why `source_commit` is held on purpose rather than followed. The profile is then judged against its own revision, like one pinned to a superseded tag |
| `data_root` | per operating system (`android`, `ios`, `windows`, `macos`, `linux`), the folder the app reads its user data from, under which `standalone_path` destinations sit: `path` as the user finds it (`Android/data/<package>/files`, `%APPDATA%/...`), with the `source_ref` that builds it. Only the systems the app runs on are keyed |
| `upstream_frozen` | why `upstream_commit` is held: the port vendors that upstream tree, synced to that release, or merged that commit. The upstream view is read at its own pin and a bump leaves it. Without it, a port that is a GitHub fork of its upstream follows the merge base of the two |
| `prose_written_at` | the pin a reader confirmed the prose citations against. When the profile's history shows a text living under several pins, profile_sync takes this one as the revision the text describes, so a bump is no longer refused for a citation already reread. It never overrides a history with a single candidate |
| `version_source` | for a project published outside any forge, `{url, pattern}`: the page that announces releases and a regular expression whose first group is the latest version. `profile_sync --check-version` compares it with `core_version`, and `check_freshness --profiles` calls the profile stale when they differ. `no_genuine_copy` names an announced version found only rebuilt or re-signed: the profile stays on the newest unmodified build and is not stale until the page announces a later one |
+10 -7
View File
@@ -274,13 +274,16 @@ 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.
gptokeyb and gptokeyb2 (each killing the way its source does), setfont,
chvt, curl, sudo, foot, xterm, muxterm, PortMaster's `control.txt` and
muOS's `retro.sh`. The checks: gptokeyb's kill reaches only the Start +
Select sentinel and the interface then stops with its result written, the
console's settings and font are put back, an offline run says so at once,
picks the pack of its system from the card or says where to put one, and
each firmware's terminal is the one used. The fakes also fail: a mapper that
dies, a chvt the compositor refuses, a terminal app that cannot open, a
read-only card. None of these may leave a console waiting for a button.
What reaches the screen and the pad on a real device is not exercised.
```bash
python -m unittest tests.test_handheld_launchers -v
+2 -1
View File
@@ -38,7 +38,8 @@ Run it without `--check`. It adds missing files and replaces wrong ones.
- **Right folder?** [Get your files](../which-pack.md) names it.
- **Exact name?** `scph5501.bin` and `SCPH-5501.BIN` differ. On Linux,
capitals count too.
- **A `bios` folder inside your BIOS folder?** Move its files up one level.
- **A `bios` folder inside your BIOS folder?** Run the installer: it copies
those files up one level.
- **Unzipped by hand on Linux?** The command adds names that differ by
capitals.
- **A ZIP in parts, like `Pack.part1of2.zip`?** Unzip every part into one