#!/bin/bash # shellcheck shell=bash # RetroBIOS on a handheld: runs install.py --tui on a terminal the player can # see, with the controller mapped to the keys the interface reads. # # Sourced by every launcher (the PortMaster port, the Batocera and Knulli # port, the muOS app, the Tools entry), each of which sets the inputs below # and calls rb_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 # RB_OTHER_CARDS BIOS folders the firmware keeps on each of its cards, # separated by ":", as its own code names them # RB_BIOS_DEST the one of them the firmware reads, never offered as the # other card # RB_FALLBACK 1 when the launcher has screens of its own to fall back to # 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]}")" RB_DIR="${RB_DIR:-$(dirname "$RB_LIB")}" RB_INSTALL="${RB_INSTALL:-$RB_DIR/install.py}" RB_EVENTS="${RB_EVENTS:-$RB_DIR/rbevents.py}" RB_GPTK="${RB_GPTK:-$RB_DIR/retrobios.gptk}" RB_STATE="${RB_STATE:-$RB_DIR/run}" RB_LOG="${RB_LOG:-$RB_DIR/log.txt}" RB_ROUTES="${RB_ROUTES:-app vt}" RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}" RB_PACK_PATH="${RB_PACK_PATH:-$RB_DIR}" RB_OTHER_CARDS="${RB_OTHER_CARDS:-}" RB_BIOS_DEST="${RB_BIOS_DEST:-}" RB_FONTS="${RB_FONTS:-/usr/share/consolefonts}" RB_FB="${RB_FB:-/sys/class/graphics/fb0/virtual_size}" RB_ROUTE_TABLE="${RB_ROUTE_TABLE:-/proc/net/route}" RB_VT_ACTIVE="${RB_VT_ACTIVE:-/sys/class/tty/tty0/active}" RB_PROBE_URL="${RETROBIOS_BASE_URL:-https://raw.githubusercontent.com/Abdess/retrobios/}" # 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 finds a muOS, Knulli or ROCKNIX card by itself, and any other with 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_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_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 RB_PYCMD=(env "PYTHONHOME=$RB_PYTHONHOME" "$RB_PYTHON") else 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() { "$@" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' >/dev/null 2>&1 } # The OS python3 when it is 3.8 or newer, else PortMaster's python_3.11 # runtime on aarch64, mounted as PortMaster's own Python ports do. A launcher # that can download the runtime defines rb_fetch_runtime. Sets # RB_PYTHON_PROBLEM when neither can run install.py. rb_select_python() { if command -v python3 >/dev/null 2>&1 && rb_python_ok python3; then RB_PYTHON="python3" return 0 fi if [ "$(rb_arch)" != "aarch64" ]; then RB_PYTHON_PROBLEM="old" return 1 fi rb_runtime } rb_runtime() { local python_dir="$HOME/$RB_RUNTIME" local squashfs="${controlfolder:-/nonexistent}/libs/$RB_RUNTIME.squashfs" if [ ! -f "$squashfs" ]; then if ! declare -F rb_fetch_runtime >/dev/null; then RB_PYTHON_PROBLEM="runtime_missing" return 1 fi rb_fetch_runtime "$squashfs" || return 1 fi mkdir -p "$python_dir" if [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then $ESUDO umount "$python_dir" >/dev/null 2>&1 fi if ! $ESUDO mount "$squashfs" "$python_dir"; then RB_PYTHON_PROBLEM="runtime_failed" return 1 fi RB_MOUNTED="$python_dir" RB_PYTHON="$python_dir/bin/python3" RB_PYTHONHOME="$python_dir" if ! PYTHONHOME="$python_dir" rb_python_ok "$RB_PYTHON"; then RB_PYTHON="" RB_PYTHON_PROBLEM="runtime_failed" return 1 fi return 0 } rb_release_python() { if [ -n "$RB_MOUNTED" ] && [ "${PM_CAN_MOUNT:-Y}" != "N" ]; then $ESUDO umount "$RB_MOUNTED" >/dev/null 2>&1 fi RB_MOUNTED="" } rb_python_text() { local found found=$(python3 --version 2>&1 | awk '{print $2}') case "$RB_PYTHON_PROBLEM" in old) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's Python runtime exists for aarch64 only, and this device is $(rb_arch). $RB_PC_ROUTE" ;; runtime_offline) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime needs a connection to download. $RB_PC_ROUTE" ;; runtime_missing) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}. The RetroBIOS port from PortMaster downloads PortMaster's $RB_RUNTIME runtime, which this entry then uses too. $RB_PC_ROUTE" ;; *) printf '%s\n' "Python 3.8 or newer is needed. This system has ${found:-none}, and PortMaster's $RB_RUNTIME runtime could not be installed or run. $RB_PC_ROUTE" ;; esac } # --- Network and packs ---------------------------------------------------- # Whether the file list can be reached. Only a failure to resolve or to # connect counts as offline: a certificate refused by an old curl still means # a network, and install.py names that problem itself. Bounded as a whole: # GNU wget retries a timeout 20 times. rb_online() { local code if command -v curl >/dev/null 2>&1; then 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|124) return 1 ;; *) return 0 ;; esac fi if command -v wget >/dev/null 2>&1; then 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. awk 'NR > 1 && $2 == "00000000" { found = 1 } END { exit !found }' "$RB_ROUTE_TABLE" 2>/dev/null } # The system and BIOS folder install.py finds here, read without a network: # detection is local, and RETROBIOS_INSTALL_OFFLINE stops the run at once # where the file list would download (reason offline), instead of three # tries of 30 s on a network already found missing. Set for this run only: # the interface's own run reads a pack, which makes no request either. rb_detect() { RB_PLATFORM="" RB_PLATFORM_NAME="" RB_BIOS_DIR="" mkdir -p "$RB_STATE" rb_pycmd RETROBIOS_INSTALL_OFFLINE=1 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 RB_PLATFORM="$a" RB_PLATFORM_NAME="$b" RB_BIOS_DIR="$c" [ "$RB_BIOS_DIR" = "-" ] && RB_BIOS_DIR="" break fi done < <(rb_py "$RB_EVENTS" status "$RB_STATE/detect.jsonl" 2>> "$RB_LOG") } # The first pack in RB_PACK_PATH 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 want="_${RB_PLATFORM_NAME// /_}_" fi local IFS=":" for dir in $RB_PACK_PATH; do [ -d "$dir" ] || continue for file in "$dir"/*_BIOS_Pack.zip "$dir"/*_BIOS_Pack.part1of*.zip; do [ -f "$file" ] || continue base="${file##*/}" stem="_${base%%_BIOS_Pack*}_" if [ -n "$want" ] && [[ "$stem" == *"$want"* ]]; then printf '%s\n' "$file" return 0 fi [ -z "$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)" ] } # The BIOS folder on the firmware's other card, when it holds a file: a # two-card device reads one card (muOS binds MUOS/bios from SD2 when it has # one, else from SD1), and the files bought for it often sit on the other. rb_other_card() { local dir local IFS=":" for dir in ${RB_OTHER_CARDS:-}; do [ -d "$dir" ] || continue if rb_same_dir "$dir" "${RB_BIOS_DEST:-}" || rb_same_dir "$dir" "${RB_BIOS_DIR:-}"; then continue fi if [ -n "$(find "$dir" -type f -print 2>/dev/null | head -n 1)" ]; then printf '%s\n' "$dir" return 0 fi done return 0 } rb_pack_text() { local dir="${RB_PACK_PATH%%:*}" pack 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)" fi printf '%s\n' "No network connection. To install without one, download $pack from $RB_RELEASES on a computer, copy it into $dir on the SD card, then start RetroBIOS again. Or connect to Wi-Fi and start again." } # --- Terminal ------------------------------------------------------------- rb_graphical() { [ -n "${WAYLAND_DISPLAY:-}" ] || [ -n "${DISPLAY:-}" ] \ || pgrep -x 'sway|weston|labwc|cage|gamescope|Xorg|X' >/dev/null 2>&1 } # A terminal app the graphical session can show, full screen. A launcher # that knows its own (muOS's muxterm) sets RB_TERMAPP after sourcing this. rb_terminal_app() { if [ "${#RB_TERMAPP[@]}" -gt 0 ]; then return 0 fi if [ -z "${WAYLAND_DISPLAY:-}" ] && [ -z "${DISPLAY:-}" ]; then return 1 fi if command -v foot >/dev/null 2>&1; then RB_TERMAPP=(foot -F --) elif command -v xterm >/dev/null 2>&1; then RB_TERMAPP=(xterm -fullscreen -e) else return 1 fi } # The only pad SDL may show muxterm: vendor 0, product 1, which no device # can carry. SDL builds a GUID from the ids only when the vendor is not 0, # else from the name, and reads a name GUID back as vendor 0 and product 0 # (SDL_CreateJoystickGUID and SDL_GetJoystickGUIDInfo, SDL_joystick.c), so # a pad plugged in later is hidden too. The first list, 0001:0001, is the # id gpio-keys gives the H700 pad on muOS before 2026-08-28: every button # acted twice there. RB_SDL_NO_PAD="0x0000/0x0001" # muxterm, muOS's terminal, as the terminal app. It reads the pad itself # (Y as Space, where the interface reads Y as Options), so with a mapper it # is told to leave the pad alone and each button is sent once. muxterm also # keeps PgUp and PgDn for its scrollback and never writes them to the # interface (MustardOS/terminal input.c): L1 and R1 send nothing there, # rather than scroll the interface out of sight. Run after rb_open_log. rb_muxterm() { if [ -z "${RB_MAPPER:-}" ]; then RB_TERMAPP=("$1" --gl --) RB_PAD="muxterm" return 0 fi RB_TERMAPP=(env "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$RB_SDL_NO_PAD" "$1" --gl --) RB_PAD="gptokeyb_muxterm" if awk '$1 == "l1" || $1 == "r1" { print $1 " = \\\""; next } { print }' \ "$RB_GPTK" > "$RB_STATE/muxterm.gptk" 2>> "$RB_LOG"; then RB_GPTK="$RB_STATE/muxterm.gptk" else rb_say "$RB_STATE/muxterm.gptk cannot be written: L1 and R1 scroll muxterm" fi } # 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 # font in place can be put back: saved with setfont -O, or the # Lat7-Terminus20x10 ArkOS tools restore. rb_font_set() { command -v setfont >/dev/null 2>&1 || return 0 local size width height spec fh fw name file="" fallback size=$(cat "$RB_FB" 2>/dev/null) || return 0 width="${size%%,*}" height="${size##*,}" case "$width$height" in ''|*[!0-9]*) return 0 ;; esac for spec in "32 16 Lat7-TerminusBold32x16" "28 14 Lat7-TerminusBold28x14" \ "22 11 Lat7-TerminusBold22x11" "20 10 Lat7-TerminusBold20x10" \ "16 8 Lat7-Terminus16"; do read -r fh fw name <<< "$spec" if [ $((width / fw)) -ge 56 ] && [ $((height / fh)) -ge 20 ] \ && [ -f "$RB_FONTS/$name.psf.gz" ]; then file="$RB_FONTS/$name.psf.gz" break fi done if [ -z "$file" ] && [ $((width / 8)) -ge 40 ] && [ $((height / 16)) -ge 12 ] \ && [ -f "$RB_FONTS/Lat7-Terminus16.psf.gz" ]; then file="$RB_FONTS/Lat7-Terminus16.psf.gz" fi [ -n "$file" ] || return 0 if $ESUDO setfont -C "$RB_TTY" -O "$RB_STATE/font.psf" "$file" >> "$RB_LOG" 2>&1 \ && [ -s "$RB_STATE/font.psf" ]; then RB_FONT_BACK="$RB_STATE/font.psf" return 0 fi fallback="$RB_FONTS/Lat7-Terminus20x10.psf.gz" if [ -f "$fallback" ] && $ESUDO setfont -C "$RB_TTY" "$file" >> "$RB_LOG" 2>&1; then RB_FONT_BACK="$fallback" fi } rb_font_restore() { if [ -n "$RB_FONT_BACK" ]; then $ESUDO setfont -C "$RB_TTY" "$RB_FONT_BACK" >> "$RB_LOG" 2>&1 fi RB_FONT_BACK="" } # The number of the console in front, which a switch has to come back to. rb_active_vt() { local active active=$(cat "$RB_VT_ACTIVE" 2>/dev/null) active="${active#tty}" case "$active" in ''|*[!0-9]*) active=$(fgconsole 2>/dev/null) ;; esac case "$active" in ''|*[!0-9]*) return 1 ;; esac printf '%s\n' "$active" } # 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" fi if [ ! -r "$RB_TTY" ] || [ ! -w "$RB_TTY" ]; then $ESUDO chmod 666 "$RB_TTY" >> "$RB_LOG" 2>&1 fi RB_STTY=$(stty -g < "$RB_TTY" 2>/dev/null) printf '\033c' > "$RB_TTY" rb_font_set } rb_vt_leave() { [ -n "$RB_TTY" ] || return 0 rb_font_restore printf '\033c' > "$RB_TTY" 2>/dev/null if [ -n "$RB_STTY" ]; then stty "$RB_STTY" < "$RB_TTY" 2>/dev/null fi if [ -n "$RB_VT_BACK" ]; then rb_chvt "$RB_VT_BACK" fi RB_TTY="" RB_STTY="" RB_VT_BACK="" } # --- 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. # It also ends once the launcher is gone, which a SIGKILL leaves no trap # to say. rb_stop_start() { RB_STOP_TOKEN="retrobios-stop-${$}x" # shellcheck disable=SC2016 # expanded by the inner bash bash -c 'exec {f}<> <(:); while [ -d "/proc/$1" ]; do read -r -t 5 -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_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() { 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 } # The mapper's own name, gptokeyb or gptokeyb2, past sudo and its options. rb_mapper_name() { local word # shellcheck disable=SC2086 # RB_MAPPER is a command and its flags for word in $RB_MAPPER; do case "$word" in sudo|-*|*=*) ;; *) printf '%s\n' "${word##*/}"; return 0 ;; esac done printf 'gptokeyb\n' } # "run" when the mapper stopped during the run: the interface just went. rb_mapper_text() { if [ "${1:-}" = "run" ]; then printf '%s\n' "The controller stopped responding: $(rb_mapper_name), which turns the buttons into keys, stopped during the run, so RetroBIOS closed. What it said is in $RB_LOG. Start RetroBIOS again." return 0 fi printf '%s\n' "The controller cannot be read: $(rb_mapper_name), 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 ''|*[!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' "$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() { 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 "$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=() 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 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. rb_message "$(rb_pack_text) The pack carries the list of files: with it, the files on your other card are copied too." else rb_message "$(rb_pack_text)" fi printf '1\n' > "$RB_STATE/status" return 1 fi args+=(--from-pack "$pack") if [ -n "$RB_PLATFORM" ]; then args+=(--platform "$RB_PLATFORM") fi fi rb_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 status=125 if [ "${RB_FALLBACK:-0}" != "1" ]; then rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG." fi elif [ "$RB_STOPPED" = "mapper" ]; then # The interface disappeared: why, for RB_DEAD_WAIT seconds, since no # button is left to close it. rb_message "$(rb_mapper_text run)" 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" ] && [ "$(head -n 1 "$RB_STATE/tui.err" 2>/dev/null)" = "No button pressed, closing." ]; then # Closed for want of a button: the mapper never opened the pad, so no A # is coming. The error it closed is the next line, shown for # RB_DEAD_WAIT seconds. RB_MESSAGE_WAIT="$RB_DEAD_WAIT" rb_message \ "$(sed -n 2p "$RB_STATE/tui.err") The messages are in $RB_LOG." elif [ "$status" != "0" ] && [ -s "$RB_STATE/tui.err" ]; then # 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 } # 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) 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) 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 # No mapper runs yet to close it with a button: left in view a while # before the menu draws over it. rb_nap "$RB_DEAD_WAIT" 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 # A mapper that runs but never opens the pad (one SDL has no mapping for) # leaves no button to move the interface or end the run: it closes by # itself after this many seconds on its first screen with nothing pressed. export RETROBIOS_IDLE_EXIT="${RETROBIOS_IDLE_EXIT:-300}" # 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 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 (muxterm's Menu) or the launcher signalled: # the interface gets its time to stop, and the status says the run # ended, so the launcher opens no other route after it. trap 'rb_ui_stop; printf "143\n" > "$RB_STATE/status"; exit 143' TERM HUP rb_here "$@" exit $? ;; *) printf '%s\n' "usage: bash tui.txt here [install.py options]" >&2 exit 2 ;; esac fi