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