mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 12:19:13 -05:00
chore: merge the handheld launchers
This commit is contained in:
commit
af32a0c6bf
27 files changed
+2099
-259
No files matched your search
@@ -227,6 +227,11 @@ jobs:
|
||||
- name: Hold install.py to Python 3.8
|
||||
run: vermin --no-tips -t=3.8- --violations install.py
|
||||
|
||||
# The handheld launchers' test runs shellcheck and fails under CI
|
||||
# without it. The Ubuntu image ships it: this names it if one stops.
|
||||
- name: Check that shellcheck is installed
|
||||
run: shellcheck --version
|
||||
|
||||
# The site is built as deploy-site.yml builds it, from the lockfile.
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
@@ -10665,6 +10665,10 @@ def _tui_passthrough(args: argparse.Namespace) -> list[tuple[str, str | None]]:
|
||||
flags.append((flag, str(Path(value).absolute())))
|
||||
flags += [("--emulator", name) for name in args.emulator or []]
|
||||
flags += [("--from-pack", str(Path(pack).absolute())) for pack in args.from_pack or []]
|
||||
# The handheld launchers start --tui --from the other card: without it here
|
||||
# the runs downloaded what that card holds.
|
||||
flags += [("--from", str(Path(folder).absolute()))
|
||||
for folder in getattr(args, "from", None) or []]
|
||||
for flag in ("--every-board", "--standalone-copies", "--verbose"):
|
||||
if getattr(args, flag[2:].replace("-", "_")):
|
||||
flags.append((flag, None))
|
||||
|
||||
+1
-1
@@ -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() {
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+35
@@ -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
|
||||
@@ -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"]}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+59
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
# RetroBIOS inside muxterm (gptokeyb2). muxterm already sends the D-pad as
|
||||
# arrows, Start as Enter, B as Backspace and Y as Space to the program it
|
||||
# runs (MustardOS/terminal input.c and osk.c), so mapping those here too
|
||||
# would press each key twice. Only what muxterm leaves unmapped is added.
|
||||
[controls]
|
||||
overlay = clear
|
||||
a = enter
|
||||
x = space
|
||||
l1 = pageup
|
||||
r1 = pagedown
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn install.py --events into tab-separated records for the launcher.
|
||||
"""Turn install.py --events into tab-separated records for the launchers.
|
||||
|
||||
The launcher is bash and draws through PortMaster's dialog FIFO, so it reads
|
||||
one record per line with `IFS=$'\\t' read`. Text fields are cleaned for that
|
||||
FIFO: PortMasterDialog sends its arguments through an unquoted
|
||||
`echo $(printf ...)`, where a tab or newline splits a field and `*`, `?` or
|
||||
`[` expand as a file pattern.
|
||||
The launchers are bash, so they read one record per line with
|
||||
`IFS=$'\\t' read`: tui.txt reads the system and its BIOS folder to name the
|
||||
pack an offline run needs, and the PortMaster screens draw through
|
||||
PortMaster's dialog FIFO. Text fields are cleaned for that FIFO:
|
||||
PortMasterDialog sends its arguments through an unquoted `echo $(printf ...)`,
|
||||
where a tab or newline splits a field and `*`, `?` or `[` expand as a file
|
||||
pattern.
|
||||
|
||||
rbevents.py status EVENTS records of a --check run
|
||||
rbevents.py follow SUMMARY an install run on stdin: progress
|
||||
@@ -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 = \"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# RetroBIOS from the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS and
|
||||
# AmberELEC: checks the BIOS folder and downloads the files it lacks, in the
|
||||
# installer's full-screen interface, each firmware the way its own tools do.
|
||||
#
|
||||
# ArkOS, dArkOS console 1 with a Terminus font, /opt/inttools/gptokeyb
|
||||
# (dArkOS_Tools/Wifi.sh)
|
||||
# AmberELEC /dev/console, TERM=linux, the system gptokeyb
|
||||
# (modules/SetTime.sh)
|
||||
# ROCKNIX, JELOS the foot or weston terminal the Tools menu opens, then a
|
||||
# graphical terminal or the console, with gptokeyb
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT="${RB_ROOT:-}"
|
||||
RB_DIR="$HERE/retrobios"
|
||||
RB_LOG="$RB_DIR/log.txt"
|
||||
ESUDO=""
|
||||
OS_NAME=""
|
||||
if [ -f "$ROOT/etc/os-release" ]; then
|
||||
OS_NAME=$(awk -F= '$1 == "NAME" { gsub(/"/, "", $2); print $2 }' "$ROOT/etc/os-release")
|
||||
fi
|
||||
|
||||
rb_gptokeyb() {
|
||||
local candidate
|
||||
for candidate in "$@"; do
|
||||
if [ -x "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
command -v gptokeyb 2>/dev/null
|
||||
}
|
||||
|
||||
if [ -d "$ROOT/opt/system" ] && [ -d "$ROOT/opt/inttools" ]; then
|
||||
# ArkOS and dArkOS run EmulationStation as a user with sudo.
|
||||
if sudo -n true >/dev/null 2>&1; then
|
||||
ESUDO="sudo"
|
||||
fi
|
||||
RB_ROUTES="vt"
|
||||
RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}"
|
||||
RB_PACK_PATH="$RB_DIR:$HERE:/roms:/roms2"
|
||||
# The second card is /roms2, and "Switch to SD2 for Roms" moves RetroArch's
|
||||
# system folder there (dArkOS scripts/Switch to SD2 for Roms.sh:91-92): the
|
||||
# folder RetroArch reads is the destination, the other card's is offered.
|
||||
RB_OTHER_CARDS="${RB_OTHER_CARDS:-/roms/bios:/roms2/bios}"
|
||||
RB_BIOS_DEST=$(awk -F'"' '/^system_directory *=/ { print $2; exit }' \
|
||||
"$ROOT/home/ark/.config/retroarch/retroarch.cfg" 2>/dev/null)
|
||||
controlfolder="$ROOT/opt/system/Tools/PortMaster"
|
||||
mapper=$(rb_gptokeyb "$ROOT/opt/inttools/gptokeyb" "$controlfolder/gptokeyb")
|
||||
[ -f "$ROOT/opt/inttools/gamecontrollerdb.txt" ] \
|
||||
&& export SDL_GAMECONTROLLERCONFIG_FILE="$ROOT/opt/inttools/gamecontrollerdb.txt"
|
||||
elif [ "$OS_NAME" = "AmberELEC" ]; then
|
||||
RB_ROUTES="vt"
|
||||
RB_CONSOLE="${RB_CONSOLE:-/dev/console}"
|
||||
RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms"
|
||||
controlfolder="/storage/roms/ports/PortMaster"
|
||||
mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb")
|
||||
[ -f /storage/.config/SDL-GameControllerDB/gamecontrollerdb.txt ] \
|
||||
&& export SDL_GAMECONTROLLERCONFIG_FILE="/storage/.config/SDL-GameControllerDB/gamecontrollerdb.txt"
|
||||
else
|
||||
# ROCKNIX, JELOS and the other systems built like them: the Tools menu
|
||||
# runs this inside foot on sway builds, so its own terminal comes first.
|
||||
RB_ROUTES="here app vt"
|
||||
RB_CONSOLE="${RB_CONSOLE:-/dev/tty1}"
|
||||
RB_PACK_PATH="$RB_DIR:$HERE:/storage/roms"
|
||||
controlfolder="/storage/roms/ports/PortMaster"
|
||||
mapper=$(rb_gptokeyb "$ROOT/usr/bin/gptokeyb" "$controlfolder/gptokeyb")
|
||||
fi
|
||||
# Kill mode on the installer's own path: Start + Select closes the
|
||||
# interface, never another program.
|
||||
RB_MAPPER=""
|
||||
[ -n "$mapper" ] && RB_MAPPER="$ESUDO $mapper -1"
|
||||
|
||||
# shellcheck source=../shared/tui.txt
|
||||
source "$RB_DIR/tui.txt"
|
||||
: > "$RB_LOG"
|
||||
rb_select_python
|
||||
# A launcher's own arguments are not install.py's (muOS passes the app folder).
|
||||
# shellcheck disable=SC2119
|
||||
rb_launch
|
||||
status=$?
|
||||
rb_release_python
|
||||
exit $status
|
||||
+100
-36
@@ -1,14 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip.
|
||||
"""Build the handheld launchers in ports/ into their archives in dist/.
|
||||
|
||||
The archive has the layout PortMaster's own release build gives a port
|
||||
RetroBIOS.zip the PortMaster port (ports/RetroBIOS)
|
||||
RetroBIOS-batocera.zip Batocera and Knulli, from Ports (ports/batocera)
|
||||
RetroBIOS.muxapp muOS, from Applications (ports/muos)
|
||||
RetroBIOS-tools.zip the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS
|
||||
and AmberELEC (ports/tools)
|
||||
|
||||
Each carries ports/shared (tui.txt, which runs the full-screen interface,
|
||||
rbevents.py and the controller mapping) beside its own launcher. The
|
||||
PortMaster archive has the layout PortMaster's own release build gives a port
|
||||
(PortMaster-New tools/build_release.py): the launch script at the top, and
|
||||
one folder holding port.json, gameinfo.xml, the README as <name>.md, the
|
||||
licenses and the port's files. install.py is copied from the repository
|
||||
root, and only when it is the installer the bootstraps pin, so the port
|
||||
runs the same bytes as the one-line command of the same commit. Members are
|
||||
written as the packs write theirs: fixed timestamps, sorted names, the
|
||||
executable bit read from the source, so the same tree gives the same zip.
|
||||
licenses and the port's files. A muOS application archive is extracted into
|
||||
the applications folder, so its top folder is the application.
|
||||
|
||||
install.py is copied from the repository root, and only when it is the
|
||||
installer the bootstraps pin, so every launcher runs the same bytes as the
|
||||
one-line command of the same commit. Members are written as the packs write
|
||||
theirs: fixed timestamps, sorted names, the executable bit read from the
|
||||
source, so the same tree gives the same archives.
|
||||
|
||||
Usage:
|
||||
python scripts/build_port.py
|
||||
@@ -27,10 +38,14 @@ from pathlib import Path
|
||||
from artifacts import ArtifactLockBusy, artifact_lock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PORT_SOURCE = REPO_ROOT / "ports" / "RetroBIOS"
|
||||
PORT_DIR = "retrobios"
|
||||
PORT_SCRIPT = "RetroBIOS.sh"
|
||||
PORT_ZIP = "RetroBIOS.zip"
|
||||
BATOCERA_ZIP = "RetroBIOS-batocera.zip"
|
||||
MUOS_APP = "RetroBIOS.muxapp"
|
||||
TOOLS_ZIP = "RetroBIOS-tools.zip"
|
||||
# Every archive a release attaches, in the order the release notes list them.
|
||||
ARCHIVES = (PORT_ZIP, BATOCERA_ZIP, MUOS_APP, TOOLS_ZIP)
|
||||
_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE)
|
||||
|
||||
|
||||
@@ -42,66 +57,115 @@ def pinned_installer_sha256(root: Path = REPO_ROOT) -> str:
|
||||
return found.group(1)
|
||||
|
||||
|
||||
def port_members(root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
|
||||
"""(name in the archive, source file) for every member, sorted by name."""
|
||||
source = root / "ports" / "RetroBIOS"
|
||||
def _engine(folder: str, root: Path, mapping: bool = True) -> list[tuple[str, Path]]:
|
||||
"""What every launcher runs: the installer, the interface launcher, the
|
||||
event reader, the licenses, and the gptokeyb mapping where one is read."""
|
||||
shared = root / "ports" / "shared"
|
||||
members = [
|
||||
(PORT_SCRIPT, source / PORT_SCRIPT),
|
||||
(f"{PORT_DIR}/port.json", source / "port.json"),
|
||||
(f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"),
|
||||
(f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"),
|
||||
(f"{PORT_DIR}/install.py", root / "install.py"),
|
||||
(f"{PORT_DIR}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
|
||||
(f"{PORT_DIR}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
|
||||
(f"{folder}/install.py", root / "install.py"),
|
||||
(f"{folder}/tui.txt", shared / "tui.txt"),
|
||||
(f"{folder}/rbevents.py", shared / "rbevents.py"),
|
||||
(f"{folder}/licenses/LICENSE-retrobios.txt", root / "LICENSE"),
|
||||
(f"{folder}/licenses/NOTICE-retrobios.txt", root / "NOTICE"),
|
||||
]
|
||||
members += [
|
||||
(f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path)
|
||||
for path in sorted((source / PORT_DIR).rglob("*"))
|
||||
if path.is_file() and "__pycache__" not in path.parts
|
||||
]
|
||||
names = [name for name, _path in members]
|
||||
if mapping:
|
||||
members.append((f"{folder}/retrobios.gptk", shared / "retrobios.gptk"))
|
||||
return members
|
||||
|
||||
|
||||
def archive_members(name: str, root: Path = REPO_ROOT) -> list[tuple[str, Path]]:
|
||||
"""(name in the archive, source file) for every member of *name*, sorted."""
|
||||
ports = root / "ports"
|
||||
if name == PORT_ZIP:
|
||||
source = ports / "RetroBIOS"
|
||||
members = [
|
||||
(PORT_SCRIPT, source / PORT_SCRIPT),
|
||||
(f"{PORT_DIR}/port.json", source / "port.json"),
|
||||
(f"{PORT_DIR}/gameinfo.xml", source / "gameinfo.xml"),
|
||||
(f"{PORT_DIR}/{PORT_DIR}.md", source / "README.md"),
|
||||
*_engine(PORT_DIR, root),
|
||||
]
|
||||
members += [
|
||||
(f"{PORT_DIR}/{path.relative_to(source / PORT_DIR).as_posix()}", path)
|
||||
for path in sorted((source / PORT_DIR).rglob("*"))
|
||||
if path.is_file() and "__pycache__" not in path.parts
|
||||
]
|
||||
elif name == BATOCERA_ZIP:
|
||||
source = ports / "batocera"
|
||||
members = [
|
||||
(PORT_SCRIPT, source / PORT_SCRIPT),
|
||||
(f"{PORT_SCRIPT}.keys", source / f"{PORT_SCRIPT}.keys"),
|
||||
(f"{PORT_DIR}/README.md", source / "README.md"),
|
||||
# evmapy reads RetroBIOS.sh.keys: no gptokeyb there.
|
||||
*_engine(PORT_DIR, root, mapping=False),
|
||||
]
|
||||
elif name == MUOS_APP:
|
||||
source = ports / "muos"
|
||||
members = [
|
||||
("RetroBIOS/mux_launch.sh", source / "mux_launch.sh"),
|
||||
("RetroBIOS/muxterm.gptk", source / "muxterm.gptk"),
|
||||
("RetroBIOS/README.md", source / "README.md"),
|
||||
*_engine("RetroBIOS", root),
|
||||
]
|
||||
elif name == TOOLS_ZIP:
|
||||
source = ports / "tools"
|
||||
members = [
|
||||
(PORT_SCRIPT, source / PORT_SCRIPT),
|
||||
(f"{PORT_DIR}/README.md", source / "README.md"),
|
||||
*_engine(PORT_DIR, root),
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"no launcher archive is named {name}")
|
||||
names = [member for member, _path in members]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError("two port members share a name")
|
||||
raise ValueError(f"two members of {name} share a name")
|
||||
return sorted(members)
|
||||
|
||||
|
||||
def build_port(output: Path, root: Path = REPO_ROOT) -> Path:
|
||||
"""Write the port archive to *output* and return it."""
|
||||
def build_archive(name: str, output: Path, root: Path = REPO_ROOT) -> Path:
|
||||
"""Write the launcher archive *name* to *output* and return it."""
|
||||
pinned = pinned_installer_sha256(root)
|
||||
actual = hashlib.sha256((root / "install.py").read_bytes()).hexdigest()
|
||||
if actual != pinned:
|
||||
raise ValueError(
|
||||
f"install.py is {actual[:12]}, install.sh pins {pinned[:12]}: "
|
||||
"re-pin the bootstraps before building the port"
|
||||
"re-pin the bootstraps before building the launchers"
|
||||
)
|
||||
# The packs' member writer: imported here, as it loads the whole
|
||||
# pack builder, which a caller reading PORT_ZIP does not need.
|
||||
# pack builder, which a caller reading ARCHIVES does not need.
|
||||
from generate_pack import _add_pack_member # noqa: PLC0415
|
||||
|
||||
members = archive_members(name, root)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
scratch = output.with_name(f".{output.name}.tmp")
|
||||
with zipfile.ZipFile(scratch, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
|
||||
for name, path in port_members(root):
|
||||
_add_pack_member(zf, str(path), name)
|
||||
for member, path in members:
|
||||
_add_pack_member(zf, str(path), member)
|
||||
scratch.replace(output)
|
||||
return output
|
||||
|
||||
|
||||
def build_port(output: Path, root: Path = REPO_ROOT) -> Path:
|
||||
"""Write the PortMaster port archive to *output* and return it."""
|
||||
return build_archive(PORT_ZIP, output, root)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
allow_abbrev=False, description="Build the RetroBIOS PortMaster port"
|
||||
allow_abbrev=False, description="Build the RetroBIOS handheld launchers"
|
||||
)
|
||||
parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist",
|
||||
help="where RetroBIOS.zip is written (default: dist/)")
|
||||
help="where the archives are written (default: dist/)")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
with artifact_lock(str(args.output_dir)):
|
||||
path = build_port(args.output_dir / PORT_ZIP)
|
||||
paths = [build_archive(name, args.output_dir / name) for name in ARCHIVES]
|
||||
except (ArtifactLockBusy, OSError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})")
|
||||
for path in paths:
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
print(f"{path} ({path.stat().st_size} bytes, sha256 {digest})")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -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 {})}
|
||||
|
||||
@@ -0,0 +1,835 @@
|
||||
"""The handheld launchers: their scripts, controller mappings and archives,
|
||||
and what each one does on a simulated device.
|
||||
|
||||
A device is simulated with a pseudo-terminal standing for its console, a
|
||||
fake install.py that records how it was started, and fakes for what the
|
||||
firmware provides (gptokeyb, setfont, chvt, curl, foot, xterm, muxterm,
|
||||
PortMaster's control.txt and muOS's retro.sh), all on PATH or in a folder
|
||||
the launcher reads. What only a device shows (the text reaching the screen,
|
||||
the pad reaching the console, a compositor covering it) is not exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import termios
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PORTS = REPO_ROOT / "ports"
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import build_port # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
install = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(install)
|
||||
|
||||
SCRIPTS = [
|
||||
PORTS / "shared" / "tui.txt",
|
||||
PORTS / "RetroBIOS" / "RetroBIOS.sh",
|
||||
PORTS / "RetroBIOS" / "retrobios" / "screens.txt",
|
||||
PORTS / "batocera" / "RetroBIOS.sh",
|
||||
PORTS / "muos" / "mux_launch.sh",
|
||||
PORTS / "tools" / "RetroBIOS.sh",
|
||||
]
|
||||
|
||||
# What each button does in the interface (wiki/installer.md, the full-screen
|
||||
# interface): the convergent menu mapping of handheld_runtime.md.
|
||||
ROLES = {
|
||||
"a": "enter", "start": "enter", "b": "esc", "back": "esc", "x": "space",
|
||||
"y": "tab", "l1": "pageup", "r1": "pagedown",
|
||||
"up": "up", "down": "down", "left": "left", "right": "right",
|
||||
}
|
||||
# What a Linux console or a terminal sends for each key gptokeyb and evmapy
|
||||
# emit, and the key the interface reads from it.
|
||||
SEQUENCES = {
|
||||
"enter": "\r", "esc": "\x1b", "space": " ", "tab": "\t",
|
||||
"pageup": "\x1b[5~", "pagedown": "\x1b[6~",
|
||||
"up": "\x1b[A", "down": "\x1b[B", "left": "\x1b[D", "right": "\x1b[C",
|
||||
}
|
||||
INTERFACE_KEYS = {
|
||||
"enter": "enter", "esc": "esc", "space": "char", "tab": "tab",
|
||||
"pageup": "pgup", "pagedown": "pgdn",
|
||||
"up": "up", "down": "down", "left": "left", "right": "right",
|
||||
}
|
||||
# gptokeyb's char_to_keycode (PortsMaster/gptokeyb src/util.cpp) names.
|
||||
GPTOKEYB_NAMES = set(SEQUENCES) | {"backspace", '\\"'}
|
||||
|
||||
|
||||
def _tempdir(test: unittest.TestCase) -> Path:
|
||||
scratch = REPO_ROOT / "tmp"
|
||||
scratch.mkdir(exist_ok=True)
|
||||
path = Path(tempfile.mkdtemp(dir=str(scratch)))
|
||||
test.addCleanup(shutil.rmtree, path, True)
|
||||
return path
|
||||
|
||||
|
||||
def read_gptk(path: Path) -> dict[str, list[str]]:
|
||||
"""key -> values, as gptokeyb's parseConfigFile reads `key = value`."""
|
||||
mapping: dict[str, list[str]] = {}
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or line.startswith("["):
|
||||
continue
|
||||
key, sep, value = line.partition(" = ")
|
||||
assert sep, line
|
||||
mapping.setdefault(key, []).append(value.split("#")[0].strip())
|
||||
return mapping
|
||||
|
||||
|
||||
class EveryScriptParses(unittest.TestCase):
|
||||
def test_bash_n(self):
|
||||
for script in SCRIPTS:
|
||||
with self.subTest(script=str(script.relative_to(REPO_ROOT))):
|
||||
proc = subprocess.run(["bash", "-n", str(script)], capture_output=True,
|
||||
text=True, check=False)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
|
||||
def test_shellcheck(self):
|
||||
"""CI provides shellcheck (validate.yml checks it), so there it may
|
||||
not skip."""
|
||||
if shutil.which("shellcheck") is None:
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI must provide shellcheck for the handheld launchers")
|
||||
self.skipTest("shellcheck is not installed")
|
||||
proc = subprocess.run(
|
||||
["shellcheck", "--shell=bash", "--severity=style", "--external-sources",
|
||||
"--source-path=SCRIPTDIR", *map(str, SCRIPTS)],
|
||||
capture_output=True, text=True, check=False)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
|
||||
def test_launchers_are_executable(self):
|
||||
for script in (PORTS / "RetroBIOS" / "RetroBIOS.sh", PORTS / "batocera" / "RetroBIOS.sh",
|
||||
PORTS / "muos" / "mux_launch.sh", PORTS / "tools" / "RetroBIOS.sh"):
|
||||
with self.subTest(script=str(script.relative_to(REPO_ROOT))):
|
||||
self.assertTrue(script.stat().st_mode & 0o111)
|
||||
self.assertTrue(script.read_text().startswith("#!/bin/bash\n"))
|
||||
|
||||
def test_muos_headers(self):
|
||||
"""muOS's frontend reads the HELP, ICON and GRID lines of an app
|
||||
(MustardOS internal share/application/*/mux_launch.sh)."""
|
||||
head = (PORTS / "muos" / "mux_launch.sh").read_text().splitlines()[1:4]
|
||||
self.assertEqual([line.split(":")[0] for line in head], ["# HELP", "# ICON", "# GRID"])
|
||||
self.assertTrue(all(line.split(":", 1)[1].strip() for line in head))
|
||||
|
||||
|
||||
class ControllerMappings(unittest.TestCase):
|
||||
def _decodes(self, key: str) -> str:
|
||||
decoder = install.KeyDecoder()
|
||||
keys = decoder.feed(SEQUENCES[key]) + decoder.flush()
|
||||
self.assertEqual(len(keys), 1, key)
|
||||
return keys[0].name
|
||||
|
||||
def test_gptk_maps_every_button_the_interface_reads(self):
|
||||
mapping = read_gptk(PORTS / "shared" / "retrobios.gptk")
|
||||
for button, key in ROLES.items():
|
||||
with self.subTest(button=button):
|
||||
self.assertEqual(mapping[button][0], key)
|
||||
self.assertEqual(self._decodes(key), INTERFACE_KEYS[key])
|
||||
for values in mapping.values():
|
||||
self.assertIn(values[0], GPTOKEYB_NAMES | {"repeat"})
|
||||
self.assertTrue(set(values[1:]) <= {"repeat"})
|
||||
self.assertEqual(mapping["up"], ["up", "repeat"])
|
||||
self.assertEqual(mapping["down"], ["down", "repeat"])
|
||||
self.assertEqual(mapping["guide"], ['\\"'])
|
||||
|
||||
def test_evmapy_keys_map_the_same_buttons(self):
|
||||
"""Batocera's evmapy names L1 and R1 pageup and pagedown, and Select
|
||||
select (batocera-pygame evmapy.keys)."""
|
||||
data = json.loads((PORTS / "batocera" / "RetroBIOS.sh.keys").read_text())
|
||||
actions = {a["trigger"]: a for a in data["actions_player1"]}
|
||||
triggers = {"a": "a", "start": "start", "b": "b", "back": "select", "x": "x",
|
||||
"y": "y", "l1": "pageup", "r1": "pagedown", "up": "up",
|
||||
"down": "down", "left": "left", "right": "right"}
|
||||
for button, trigger in triggers.items():
|
||||
with self.subTest(button=button):
|
||||
action = actions[trigger]
|
||||
self.assertEqual(action["type"], "key")
|
||||
self.assertEqual(action["target"], [f"KEY_{ROLES[button].upper()}"])
|
||||
for stick in ("up", "down", "left", "right"):
|
||||
self.assertEqual(actions[f"joystick1{stick}"]["target"], [f"KEY_{stick.upper()}"])
|
||||
self.assertEqual(len(actions), len(data["actions_player1"]))
|
||||
|
||||
def test_muxterm_mapping_adds_only_what_muxterm_leaves(self):
|
||||
"""muxterm writes the D-pad, Start, B and Y to the program itself
|
||||
(MustardOS/terminal input.c, osk.c): mapping them again would press
|
||||
each twice."""
|
||||
text = (PORTS / "muos" / "muxterm.gptk").read_text()
|
||||
self.assertIn("[controls]\noverlay = clear\n", text)
|
||||
mapping = read_gptk(PORTS / "muos" / "muxterm.gptk")
|
||||
mapping.pop("overlay")
|
||||
self.assertEqual(mapping, {"a": ["enter"], "x": ["space"],
|
||||
"l1": ["pageup"], "r1": ["pagedown"]})
|
||||
|
||||
|
||||
class TheArchives(unittest.TestCase):
|
||||
LAYOUT = {
|
||||
build_port.PORT_ZIP: ("RetroBIOS.sh", "retrobios"),
|
||||
build_port.BATOCERA_ZIP: ("RetroBIOS.sh", "retrobios"),
|
||||
build_port.MUOS_APP: (None, "RetroBIOS"),
|
||||
build_port.TOOLS_ZIP: ("RetroBIOS.sh", "retrobios"),
|
||||
}
|
||||
|
||||
def setUp(self):
|
||||
self.out = _tempdir(self)
|
||||
|
||||
def test_every_release_archive_is_built(self):
|
||||
self.assertEqual(set(build_port.ARCHIVES), set(self.LAYOUT))
|
||||
|
||||
def test_layout(self):
|
||||
for name, (script, folder) in self.LAYOUT.items():
|
||||
with self.subTest(archive=name):
|
||||
with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf:
|
||||
names = zf.namelist()
|
||||
modes = {n: zf.getinfo(n).external_attr >> 16 for n in names}
|
||||
self.assertEqual(names, sorted(names))
|
||||
top = [n for n in names if "/" not in n]
|
||||
self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {folder})
|
||||
for member in ("install.py", "tui.txt", "rbevents.py",
|
||||
"licenses/LICENSE-retrobios.txt"):
|
||||
self.assertIn(f"{folder}/{member}", names)
|
||||
launcher = script or f"{folder}/mux_launch.sh"
|
||||
self.assertTrue(modes[launcher] & 0o111, launcher)
|
||||
if name == build_port.BATOCERA_ZIP:
|
||||
self.assertEqual(top, ["RetroBIOS.sh", "RetroBIOS.sh.keys"])
|
||||
self.assertNotIn("retrobios/retrobios.gptk", names)
|
||||
elif script:
|
||||
self.assertEqual(top, [script])
|
||||
self.assertIn(f"{folder}/retrobios.gptk", names)
|
||||
else:
|
||||
self.assertEqual(top, [])
|
||||
self.assertIn("RetroBIOS/muxterm.gptk", names)
|
||||
self.assertIn("RetroBIOS/retrobios.gptk", names)
|
||||
|
||||
def test_each_carries_the_pinned_installer(self):
|
||||
pinned = build_port.pinned_installer_sha256()
|
||||
for name, (_script, folder) in self.LAYOUT.items():
|
||||
with self.subTest(archive=name):
|
||||
with zipfile.ZipFile(build_port.build_archive(name, self.out / name)) as zf:
|
||||
bundled = hashlib.sha256(zf.read(f"{folder}/install.py")).hexdigest()
|
||||
self.assertEqual(bundled, pinned)
|
||||
|
||||
def test_reproducible(self):
|
||||
for name in build_port.ARCHIVES:
|
||||
with self.subTest(archive=name):
|
||||
first = build_port.build_archive(name, self.out / f"a-{name}").read_bytes()
|
||||
second = build_port.build_archive(name, self.out / f"b-{name}").read_bytes()
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_an_unpinned_installer_is_refused(self):
|
||||
root = _tempdir(self)
|
||||
for name in ("install.py", "install.sh", "LICENSE", "NOTICE"):
|
||||
shutil.copy(REPO_ROOT / name, root / name)
|
||||
shutil.copytree(PORTS, root / "ports")
|
||||
with (root / "install.py").open("a") as fh:
|
||||
fh.write("\n")
|
||||
for name in build_port.ARCHIVES:
|
||||
with self.subTest(archive=name), self.assertRaises(ValueError):
|
||||
build_port.build_archive(name, self.out / name, root)
|
||||
|
||||
def test_the_release_process_attaches_each(self):
|
||||
text = (REPO_ROOT / "wiki" / "release-process.md").read_text(encoding="utf-8")
|
||||
for name in build_port.ARCHIVES:
|
||||
self.assertIn(name, text)
|
||||
|
||||
|
||||
FAKE_INSTALL = r'''
|
||||
import json, os, sys
|
||||
calls = os.environ["FAKE_CALLS"]
|
||||
|
||||
|
||||
def tty(fd):
|
||||
try:
|
||||
return os.ttyname(fd)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
with open(calls, "a") as fh:
|
||||
fh.write(json.dumps({"argv0": sys.argv[0], "args": sys.argv[1:], "stdin": tty(0),
|
||||
"stdout": tty(1), "term": os.environ.get("TERM", ""),
|
||||
"pythonhome": os.environ.get("PYTHONHOME", "")}) + "\n")
|
||||
if "--check" in sys.argv:
|
||||
print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS",
|
||||
"platforms": [{"platform": "retroarch", "name": "RetroArch",
|
||||
"bios_dir": "/roms/bios"}]}))
|
||||
print(json.dumps({"event": "error", "stage": "manifest", "reason": "network",
|
||||
"platform": "retroarch", "message": "unreachable"}))
|
||||
print(json.dumps({"event": "end", "code": 1}))
|
||||
sys.exit(1)
|
||||
if os.environ.get("FAKE_REFUSE"):
|
||||
print("install.py: error: --tui cannot open the full-screen interface here: "
|
||||
"the terminal is 30x10, smaller than 40x12", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if os.environ.get("FAKE_RAW") and os.isatty(0):
|
||||
import tty as _tty
|
||||
_tty.setraw(0)
|
||||
if os.environ.get("FAKE_ERROR"):
|
||||
print("Error: " + os.environ["FAKE_ERROR"], file=sys.stderr)
|
||||
sys.exit(int(os.environ.get("FAKE_CODE", "0")))
|
||||
'''
|
||||
|
||||
# Records its arguments and stays up until it is told to stop, as gptokeyb
|
||||
# does, and records that it was.
|
||||
FAKE_MAPPER = '''#!/bin/sh
|
||||
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
|
||||
echo $$ > "$FAKE_STATE/mapper.pid"
|
||||
trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; kill $child 2>/dev/null; exit 0' TERM
|
||||
sleep 30 &
|
||||
child=$!
|
||||
wait $child
|
||||
'''
|
||||
|
||||
FAKE_RECORDER = '''#!/bin/sh
|
||||
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
|
||||
'''
|
||||
|
||||
FAKE_SETFONT = '''#!/bin/sh
|
||||
printf '%s\\n' "setfont $*" >> "$FAKE_STATE/tools.log"
|
||||
while [ $# -gt 0 ]; do
|
||||
[ "$1" = "-O" ] && { [ -n "$FAKE_NO_SAVE" ] && exit 1; printf 'font' > "$2"; }
|
||||
shift
|
||||
done
|
||||
'''
|
||||
|
||||
# A terminal app: records how it was started, then runs its command.
|
||||
FAKE_TERMAPP = '''#!/bin/sh
|
||||
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--|-e) shift; break ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
exec "$@"
|
||||
'''
|
||||
|
||||
|
||||
class Device:
|
||||
"""A simulated handheld: fake tools on PATH and a pty as its console."""
|
||||
|
||||
def __init__(self, test: unittest.TestCase, **env: str):
|
||||
self.test = test
|
||||
self.root = _tempdir(test)
|
||||
self.state = self.root / "state"
|
||||
self.state.mkdir()
|
||||
self.bin = self.root / "bin"
|
||||
self.bin.mkdir()
|
||||
master, slave = pty.openpty()
|
||||
test.addCleanup(os.close, master)
|
||||
test.addCleanup(os.close, slave)
|
||||
termios.tcsetattr(slave, termios.TCSANOW, termios.tcgetattr(slave))
|
||||
import fcntl # noqa: PLC0415
|
||||
# Wide enough that no path in a message is folded.
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 250, 0, 0))
|
||||
self.master, self.slave = master, slave
|
||||
self.console = os.ttyname(slave)
|
||||
self.before = termios.tcgetattr(slave)
|
||||
(self.bin / "python3").symlink_to(shutil.which("python3.8") or sys.executable)
|
||||
self.tool("gptokeyb", FAKE_MAPPER)
|
||||
self.tool("setfont", FAKE_SETFONT)
|
||||
self.tool("chvt", FAKE_RECORDER)
|
||||
# A probe that cannot connect is offline (curl exit 7).
|
||||
self.tool("curl", '#!/bin/sh\nexit "${FAKE_CURL:-0}"\n')
|
||||
self.tool("pgrep", '#!/bin/sh\nexit "${FAKE_PGREP:-1}"\n')
|
||||
fonts = self.root / "fonts"
|
||||
fonts.mkdir()
|
||||
for name in ("Lat7-TerminusBold32x16", "Lat7-TerminusBold22x11",
|
||||
"Lat7-TerminusBold20x10", "Lat7-Terminus16"):
|
||||
(fonts / f"{name}.psf.gz").write_bytes(b"")
|
||||
(self.root / "fb").write_text("640,480\n")
|
||||
environ = {k: v for k, v in os.environ.items()
|
||||
if k not in ("DISPLAY", "WAYLAND_DISPLAY", "RETROBIOS_BASE_URL")}
|
||||
self.env = {
|
||||
**environ,
|
||||
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
|
||||
"HOME": str(self.root),
|
||||
"FAKE_STATE": str(self.state),
|
||||
"FAKE_CALLS": str(self.state / "calls.jsonl"),
|
||||
"RB_FONTS": str(fonts),
|
||||
"RB_FB": str(self.root / "fb"),
|
||||
"RB_MESSAGE_WAIT": "1",
|
||||
"TERM": "dumb",
|
||||
**env,
|
||||
}
|
||||
|
||||
def tool(self, name: str, text: str) -> Path:
|
||||
path = self.bin / name
|
||||
# python3 starts as a link to the real interpreter: never write through it.
|
||||
if path.is_symlink() or path.exists():
|
||||
path.unlink()
|
||||
path.write_text(text)
|
||||
path.chmod(0o755)
|
||||
return path
|
||||
|
||||
def unpack(self, archive: str, into: Path) -> Path:
|
||||
"""The launcher archive extracted where the firmware keeps it, with
|
||||
the fake installer in place of install.py."""
|
||||
into.mkdir(parents=True, exist_ok=True)
|
||||
built = build_port.build_archive(archive, self.root / "dist" / archive)
|
||||
with zipfile.ZipFile(built) as zf:
|
||||
zf.extractall(into)
|
||||
for info in zf.infolist():
|
||||
mode = info.external_attr >> 16
|
||||
if mode:
|
||||
(into / info.filename).chmod(mode & 0o777)
|
||||
for found in into.rglob("install.py"):
|
||||
found.write_text(FAKE_INSTALL)
|
||||
return into
|
||||
|
||||
def run(self, argv: list[str], stdio_tty: bool = False, keys: str = "", **env: str):
|
||||
"""Run a launcher with *keys* waiting on the console as buttons
|
||||
pressed. Its console output is read from the pty."""
|
||||
if keys:
|
||||
os.write(self.master, keys.encode())
|
||||
stdin = self.slave if stdio_tty else subprocess.DEVNULL
|
||||
stdout = self.slave if stdio_tty else subprocess.PIPE
|
||||
proc = subprocess.run(argv, env={**self.env, **env}, stdin=stdin, stdout=stdout,
|
||||
stderr=subprocess.PIPE, timeout=120, check=False)
|
||||
self.screen = self._drain()
|
||||
return proc
|
||||
|
||||
def _drain(self) -> str:
|
||||
chunks = []
|
||||
os.set_blocking(self.master, False)
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(self.master, 65536)
|
||||
except (BlockingIOError, OSError):
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode("utf-8", "replace")
|
||||
|
||||
def calls(self) -> list[dict]:
|
||||
path = self.state / "calls.jsonl"
|
||||
if not path.exists():
|
||||
return []
|
||||
return [json.loads(line) for line in path.read_text().splitlines()]
|
||||
|
||||
def tui_calls(self) -> list[dict]:
|
||||
return [c for c in self.calls() if "--tui" in c["args"]]
|
||||
|
||||
def tools(self) -> list[str]:
|
||||
path = self.state / "tools.log"
|
||||
return path.read_text().splitlines() if path.exists() else []
|
||||
|
||||
def mapper_args(self) -> list[str]:
|
||||
path = self.state / "mapper.args"
|
||||
return path.read_text().splitlines() if path.exists() else []
|
||||
|
||||
def assert_mapper_stopped(self) -> None:
|
||||
"""The launcher stopped gptokeyb itself, before it exited."""
|
||||
pid = int((self.state / "mapper.pid").read_text())
|
||||
stopped = (self.state / "mapper.stopped").exists()
|
||||
if not stopped:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.test.assertTrue(stopped, "gptokeyb was left running")
|
||||
|
||||
def assert_console_restored(self) -> None:
|
||||
self.test.assertEqual(termios.tcgetattr(self.slave), self.before)
|
||||
|
||||
|
||||
CONTROL_TXT = '''
|
||||
directory="{roms}"
|
||||
ESUDO=""
|
||||
ESUDOKILL="-1"
|
||||
CUR_TTY="{console}"
|
||||
GPTOKEYB="$controlfolder/gptokeyb $ESUDOKILL"
|
||||
get_controls() {{ sdl_controllerconfig="fake"; }}
|
||||
'''
|
||||
|
||||
DIALOG_TXT = '''
|
||||
PM_PID=""
|
||||
PM_DONE="$FAKE_STATE/pm_done"
|
||||
trace() { printf '%s\\n' "$*" >> "$FAKE_STATE/dialog.log"; }
|
||||
PortMasterIPCheck() { echo 192.168.0.1; }
|
||||
PortMasterDialogInit() { trace "INIT $*"; }
|
||||
PortMasterDialogExit() { trace EXIT; }
|
||||
PortMasterDialog() { trace "CMD $*"; }
|
||||
PortMasterDialogResult() { trace "ASK $*"; echo quit; }
|
||||
'''
|
||||
|
||||
|
||||
class ThePortMasterPort(unittest.TestCase):
|
||||
"""RetroBIOS.sh from the PortMaster archive, on a fake PortMaster."""
|
||||
|
||||
def device(self, **env: str) -> Device:
|
||||
device = Device(self, **env)
|
||||
roms = device.root / "roms"
|
||||
self.gamedir = device.unpack(build_port.PORT_ZIP, roms / "ports") / "retrobios"
|
||||
control = device.root / "xdg" / "PortMaster"
|
||||
control.mkdir(parents=True)
|
||||
(control / "control.txt").write_text(
|
||||
CONTROL_TXT.format(roms=str(roms).lstrip("/"), console=device.console))
|
||||
(control / "PortMasterDialog.txt").write_text(DIALOG_TXT)
|
||||
shutil.copy(device.bin / "gptokeyb", control / "gptokeyb")
|
||||
device.env["XDG_DATA_HOME"] = str(device.root / "xdg")
|
||||
self.script = str(roms / "ports" / "RetroBIOS.sh")
|
||||
return device
|
||||
|
||||
def dialog(self, device: Device) -> list[str]:
|
||||
path = device.state / "dialog.log"
|
||||
return path.read_text().splitlines() if path.exists() else []
|
||||
|
||||
def test_online_on_the_console(self):
|
||||
device = self.device()
|
||||
proc = device.run(["bash", self.script], FAKE_RAW="1", FAKE_CODE="0")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui"])
|
||||
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
|
||||
self.assertEqual(call["term"], "linux")
|
||||
# Kill mode on the installer's own path, which the interface's command
|
||||
# line carries: never python3.
|
||||
installer = str(self.gamedir / "install.py")
|
||||
self.assertEqual(device.mapper_args(),
|
||||
["-1", installer, "-c", str(self.gamedir / "retrobios.gptk")])
|
||||
self.assertEqual(call["argv0"], installer)
|
||||
device.assert_mapper_stopped()
|
||||
device.assert_console_restored()
|
||||
self.assertIn("\x1bc", device.screen)
|
||||
saved = str(self.gamedir / "run" / "font.psf")
|
||||
fonts = device.env["RB_FONTS"]
|
||||
self.assertEqual(device.tools(), [
|
||||
f"setfont -C {device.console} -O {saved} {fonts}/Lat7-TerminusBold22x11.psf.gz",
|
||||
f"setfont -C {device.console} {saved}",
|
||||
])
|
||||
self.assertEqual(self.dialog(device), [])
|
||||
|
||||
def test_the_interface_status_is_the_port_status(self):
|
||||
device = self.device()
|
||||
proc = device.run(["bash", self.script], FAKE_CODE="3", FAKE_ERROR="the folder is read-only")
|
||||
self.assertEqual(proc.returncode, 3)
|
||||
self.assertIn("Error: the folder is read-only", device.screen)
|
||||
self.assertIn("log.txt", device.screen)
|
||||
|
||||
def test_offline_installs_from_a_pack_on_the_card(self):
|
||||
device = self.device(FAKE_CURL="7")
|
||||
roms = device.root / "roms"
|
||||
(roms / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
|
||||
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
|
||||
pack.write_bytes(b"")
|
||||
proc = device.run(["bash", self.script])
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
|
||||
"--platform", "retroarch"])
|
||||
|
||||
def test_offline_prefers_the_pack_of_this_system(self):
|
||||
device = self.device(FAKE_CURL="6")
|
||||
(self.gamedir / "Batocera_43.1_BIOS_Pack.zip").write_bytes(b"")
|
||||
pack = device.root / "roms" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.part1of2.zip"
|
||||
pack.write_bytes(b"")
|
||||
device.run(["bash", self.script])
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"][1:3], ["--from-pack", str(pack)])
|
||||
|
||||
def test_offline_without_a_pack_says_where_to_put_one(self):
|
||||
device = self.device(FAKE_CURL="7")
|
||||
proc = device.run(["bash", self.script])
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertEqual(device.tui_calls(), [])
|
||||
screen = " ".join(device.screen.split())
|
||||
self.assertIn("No network connection", screen)
|
||||
self.assertIn("RetroArch", screen)
|
||||
self.assertIn(str(self.gamedir), screen)
|
||||
self.assertIn("github.com/Abdess/retrobios/releases/latest", screen)
|
||||
device.assert_mapper_stopped()
|
||||
device.assert_console_restored()
|
||||
|
||||
def test_a_certificate_failure_is_not_offline(self):
|
||||
# curl 60: the network answered, install.py reports the certificate.
|
||||
device = self.device(FAKE_CURL="60")
|
||||
device.run(["bash", self.script])
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui"])
|
||||
|
||||
def test_an_old_python_is_told_on_the_console(self):
|
||||
device = self.device()
|
||||
device.tool("python3", '#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n')
|
||||
device.tool("uname", "#!/bin/sh\necho armv7l\n")
|
||||
proc = device.run(["bash", self.script])
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
screen = " ".join(device.screen.split())
|
||||
self.assertIn("Python 3.8 or newer is needed", screen)
|
||||
self.assertIn("3.7.3", screen)
|
||||
self.assertIn("--dest", screen)
|
||||
self.assertEqual(device.calls(), [])
|
||||
|
||||
def test_a_refused_console_falls_back_to_portmaster_screens(self):
|
||||
device = self.device()
|
||||
device.run(["bash", self.script], FAKE_REFUSE="1")
|
||||
self.assertEqual(len(device.tui_calls()), 1)
|
||||
dialog = self.dialog(device)
|
||||
self.assertEqual(dialog[0], "INIT no-check")
|
||||
self.assertIn("ASK selection_list --register=notice --want-description --want-cancel",
|
||||
dialog)
|
||||
device.assert_mapper_stopped()
|
||||
device.assert_console_restored()
|
||||
|
||||
def test_under_a_compositor_without_a_terminal_app_the_screens_open(self):
|
||||
device = self.device(FAKE_PGREP="0")
|
||||
device.run(["bash", self.script])
|
||||
self.assertEqual(device.tui_calls(), [])
|
||||
self.assertEqual(self.dialog(device)[0], "INIT no-check")
|
||||
self.assertEqual(device.mapper_args(), [])
|
||||
|
||||
def test_arkos_offers_the_card_portmaster_does_not_run_from(self):
|
||||
device = self.device(FAKE_CURL="7")
|
||||
marker = device.root / "text.plymouth"
|
||||
marker.write_text("")
|
||||
roms = device.root / "roms" / "bios"
|
||||
other = device.root / "roms2" / "bios"
|
||||
roms.mkdir(parents=True)
|
||||
other.mkdir(parents=True)
|
||||
(roms / "a.bin").write_bytes(b"x")
|
||||
(other / "b.bin").write_bytes(b"y")
|
||||
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
|
||||
pack.write_bytes(b"")
|
||||
device.run(["bash", self.script], keys="\r", RB_ARK_MARKER=str(marker),
|
||||
RB_OTHER_CARDS=f"{roms}:{other}")
|
||||
[call] = device.tui_calls()
|
||||
# The port's games card is roms here: its bios is the destination.
|
||||
self.assertEqual(call["args"][:3], ["--tui", "--from", str(other)])
|
||||
|
||||
def test_under_a_compositor_foot_shows_the_interface(self):
|
||||
device = self.device(WAYLAND_DISPLAY="wayland-1")
|
||||
device.tool("foot", FAKE_TERMAPP)
|
||||
proc = device.run(["bash", self.script], FAKE_CODE="4")
|
||||
self.assertEqual(proc.returncode, 4)
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui"])
|
||||
self.assertEqual(device.tools(), [f"foot -F -- bash {self.gamedir}/tui.txt here"])
|
||||
self.assertEqual(device.mapper_args()[1], str(self.gamedir / "install.py"))
|
||||
device.assert_mapper_stopped()
|
||||
|
||||
|
||||
class TheBatoceraPort(unittest.TestCase):
|
||||
def device(self, **env: str) -> tuple[Device, Path]:
|
||||
device = Device(self, **env)
|
||||
ports = device.unpack(build_port.BATOCERA_ZIP, device.root / "userdata" / "roms" / "ports")
|
||||
return device, ports
|
||||
|
||||
def test_xterm_in_the_x11_session(self):
|
||||
device, ports = self.device(DISPLAY=":0")
|
||||
device.tool("xterm", FAKE_TERMAPP)
|
||||
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")])
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui"])
|
||||
self.assertEqual(device.tools(),
|
||||
[f"xterm -fullscreen -e bash {ports}/retrobios/tui.txt here"])
|
||||
# evmapy reads RetroBIOS.sh.keys: no gptokeyb is started.
|
||||
self.assertEqual(device.mapper_args(), [])
|
||||
|
||||
def test_console_switch_when_no_terminal_app(self):
|
||||
device, ports = self.device(FAKE_PGREP="0")
|
||||
(device.root / "active").write_text("tty2\n")
|
||||
number = re.fullmatch(r"/dev/pts/(\d+)", device.console)
|
||||
self.assertIsNotNone(number)
|
||||
proc = device.run(["/bin/bash", str(ports / "RetroBIOS.sh")],
|
||||
RB_VT_ACTIVE=str(device.root / "active"),
|
||||
RB_VT_PREFIX="/dev/pts/", RB_VT_SWITCH=number.group(1))
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["stdin"], device.console)
|
||||
chvt = [line for line in device.tools() if line.startswith("chvt")]
|
||||
self.assertEqual(chvt, [f"chvt {number.group(1)}", "chvt 2"])
|
||||
device.assert_console_restored()
|
||||
|
||||
|
||||
class TheMuosApp(unittest.TestCase):
|
||||
def device(self, muxterm: bool) -> tuple[Device, Path]:
|
||||
device = Device(self)
|
||||
apps = device.unpack(build_port.MUOS_APP, device.root / "mmc" / "MUOS" / "application")
|
||||
muos = device.root / "opt-muos"
|
||||
(muos / "script" / "var").mkdir(parents=True)
|
||||
(muos / "script" / "var" / "retro.sh").write_text(
|
||||
'SETUP_APP() { printf "SETUP_APP %s\\n" "$1" >> "$FAKE_STATE/tools.log"; }\n'
|
||||
'GET_VAR() { case "$2" in\n'
|
||||
f' storage/rom/mount) echo "{device.root / "mmc"}" ;;\n'
|
||||
f' storage/sdcard/mount) echo "{device.root / "sdcard"}" ;;\n'
|
||||
' *) echo "" ;;\nesac; }\n')
|
||||
if muxterm:
|
||||
(muos / "frontend").mkdir()
|
||||
shutil.copy(device.tool("muxterm", FAKE_TERMAPP), muos / "frontend" / "muxterm")
|
||||
pm = device.root / "mmc" / "MUOS" / "PortMaster"
|
||||
pm.mkdir(parents=True)
|
||||
shutil.copy(device.bin / "gptokeyb", pm / "gptokeyb2")
|
||||
device.env["RB_MUOS"] = str(muos)
|
||||
return device, apps / "RetroBIOS"
|
||||
|
||||
def test_in_muxterm(self):
|
||||
device, app = self.device(muxterm=True)
|
||||
# muOS starts an app as <app>/mux_launch.sh <app>.
|
||||
proc = device.run([str(app / "mux_launch.sh"), str(app)])
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui"])
|
||||
self.assertEqual(device.tools(), ["SETUP_APP retrobios",
|
||||
f"muxterm --gl -- bash {app}/tui.txt here"])
|
||||
self.assertEqual(device.mapper_args(),
|
||||
[str(app / "install.py"), "-c", str(app / "muxterm.gptk")])
|
||||
device.assert_mapper_stopped()
|
||||
|
||||
def test_on_the_console_without_muxterm(self):
|
||||
device, app = self.device(muxterm=False)
|
||||
proc = device.run([str(app / "mux_launch.sh"), str(app)], RB_CONSOLE=device.console)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["stdin"], device.console)
|
||||
self.assertEqual(device.mapper_args()[2], str(app / "retrobios.gptk"))
|
||||
device.assert_console_restored()
|
||||
|
||||
def test_offline_reads_a_pack_at_the_card_root(self):
|
||||
device, app = self.device(muxterm=True)
|
||||
pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
|
||||
pack.write_bytes(b"")
|
||||
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7")
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"][:3], ["--tui", "--from-pack", str(pack)])
|
||||
|
||||
def two_cards(self, device: Device) -> tuple[Path, Path, Path]:
|
||||
"""SD1 and SD2 both hold MUOS/bios, SD2's is the one muOS binds."""
|
||||
sd1 = device.root / "mmc" / "MUOS" / "bios"
|
||||
sd2 = device.root / "sdcard" / "MUOS" / "bios"
|
||||
sd1.mkdir(parents=True)
|
||||
sd2.mkdir(parents=True)
|
||||
(sd1 / "scph5501.bin").write_bytes(b"x")
|
||||
(sd2 / "gba_bios.bin").write_bytes(b"y")
|
||||
pack = device.root / "mmc" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
|
||||
pack.write_bytes(b"")
|
||||
return sd1, sd2, pack
|
||||
|
||||
def test_offline_offers_the_other_card(self):
|
||||
device, app = self.device(muxterm=False)
|
||||
sd1, sd2, pack = self.two_cards(device)
|
||||
proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\r", FAKE_CURL="7",
|
||||
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
self.assertIn("Copy the BIOS files from your other card?", device.screen)
|
||||
self.assertIn(str(sd1), device.screen)
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui", "--from", str(sd1), "--from-pack", str(pack),
|
||||
"--platform", "retroarch"])
|
||||
|
||||
def test_b_declines_the_other_card(self):
|
||||
device, app = self.device(muxterm=False)
|
||||
_sd1, sd2, pack = self.two_cards(device)
|
||||
device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b", FAKE_CURL="7",
|
||||
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
|
||||
"--platform", "retroarch"])
|
||||
|
||||
def test_the_card_the_system_reads_is_never_offered(self):
|
||||
device, app = self.device(muxterm=False)
|
||||
sd1, _sd2, _pack = self.two_cards(device)
|
||||
shutil.rmtree(device.root / "sdcard")
|
||||
device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7",
|
||||
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd1))
|
||||
self.assertNotIn("other card", device.screen)
|
||||
[call] = device.tui_calls()
|
||||
self.assertNotIn("--from", call["args"])
|
||||
|
||||
def test_online_asks_nothing(self):
|
||||
device, app = self.device(muxterm=False)
|
||||
_sd1, sd2, _pack = self.two_cards(device)
|
||||
device.run([str(app / "mux_launch.sh"), str(app)],
|
||||
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
|
||||
self.assertNotIn("other card", device.screen)
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["args"], ["--tui"])
|
||||
|
||||
|
||||
class TheToolsEntry(unittest.TestCase):
|
||||
def device(self, **env: str) -> tuple[Device, Path]:
|
||||
device = Device(self, **env)
|
||||
tools = device.unpack(build_port.TOOLS_ZIP, device.root / "tools")
|
||||
device.env["RB_ROOT"] = str(device.root / "fs")
|
||||
return device, tools
|
||||
|
||||
def test_arkos_console_one(self):
|
||||
device, tools = self.device()
|
||||
fs = device.root / "fs"
|
||||
(fs / "opt" / "system").mkdir(parents=True)
|
||||
(fs / "opt" / "inttools").mkdir(parents=True)
|
||||
shutil.copy(device.bin / "gptokeyb", fs / "opt" / "inttools" / "gptokeyb")
|
||||
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual((call["stdin"], call["term"]), (device.console, "linux"))
|
||||
installer = str(tools / "retrobios" / "install.py")
|
||||
self.assertEqual(device.mapper_args(),
|
||||
["-1", installer, "-c", str(tools / "retrobios" / "retrobios.gptk")])
|
||||
device.assert_mapper_stopped()
|
||||
device.assert_console_restored()
|
||||
|
||||
def test_arkos_other_card_without_a_pack_needs_one(self):
|
||||
"""--from needs the list of files, which an offline run takes from a
|
||||
pack: the message says so."""
|
||||
device, tools = self.device(FAKE_CURL="7")
|
||||
fs = device.root / "fs"
|
||||
(fs / "opt" / "system").mkdir(parents=True)
|
||||
(fs / "opt" / "inttools").mkdir(parents=True)
|
||||
roms, roms2 = device.root / "roms" / "bios", device.root / "roms2" / "bios"
|
||||
roms.mkdir(parents=True)
|
||||
roms2.mkdir(parents=True)
|
||||
(roms / "bios.bin").write_bytes(b"x")
|
||||
cfg = fs / "home" / "ark" / ".config" / "retroarch"
|
||||
cfg.mkdir(parents=True)
|
||||
(cfg / "retroarch.cfg").write_text(f'system_directory = "{roms2}"\n')
|
||||
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], keys="\r",
|
||||
RB_CONSOLE=device.console, RB_OTHER_CARDS=f"{roms}:{roms2}")
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
screen = " ".join(device.screen.split())
|
||||
self.assertIn(f"Copy the BIOS files from your other card? {roms} holds files", screen)
|
||||
self.assertIn("with it, the files on your other card are copied too", screen)
|
||||
self.assertEqual(device.tui_calls(), [])
|
||||
|
||||
def test_amberelec_console(self):
|
||||
device, tools = self.device()
|
||||
etc = device.root / "fs" / "etc"
|
||||
etc.mkdir(parents=True)
|
||||
(etc / "os-release").write_text('NAME="AmberELEC"\n')
|
||||
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], RB_CONSOLE=device.console)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual(call["stdin"], device.console)
|
||||
self.assertEqual(device.mapper_args()[:2], ["-1", str(tools / "retrobios" / "install.py")])
|
||||
|
||||
def test_rocknix_runs_in_the_terminal_tools_opens(self):
|
||||
device, tools = self.device()
|
||||
etc = device.root / "fs" / "etc"
|
||||
etc.mkdir(parents=True)
|
||||
(etc / "os-release").write_text('NAME="ROCKNIX"\n')
|
||||
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
|
||||
[call] = device.tui_calls()
|
||||
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
|
||||
self.assertEqual(call["term"], "xterm")
|
||||
# The terminal app owns its window: no console reset, no font.
|
||||
self.assertNotIn("\x1bc", device.screen)
|
||||
self.assertEqual([t for t in device.tools() if t.startswith("setfont")], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -399,6 +399,20 @@ class Flow(unittest.TestCase):
|
||||
press(state, "enter")
|
||||
self.assertEqual(state.exit_code, 0)
|
||||
|
||||
def test_every_run_reads_the_folders_given_with_from(self):
|
||||
"""--tui --from OLD: the check and the install both copy from OLD."""
|
||||
args = _args(**{"from": [Path("old-card"), Path("backup.zip")],
|
||||
"from_pack": [Path("pack.zip")]})
|
||||
flags = install._tui_passthrough(args)
|
||||
self.assertIn(("--from", str(Path("old-card").absolute())), flags)
|
||||
self.assertIn(("--from", str(Path("backup.zip").absolute())), flags)
|
||||
self.assertIn(("--from-pack", str(Path("pack.zip").absolute())), flags)
|
||||
state = checked_state()
|
||||
state.passthrough = flags
|
||||
for run in ("check", "install"):
|
||||
self.assertIn(f"--from={Path('old-card').absolute()}",
|
||||
install.tui_argv(state, run, "py", "i.py"))
|
||||
|
||||
def test_the_check_runs_with_the_command_line_choices(self):
|
||||
state = fresh_state()
|
||||
state.passthrough = [("--target", "switch")]
|
||||
|
||||
+49
-31
@@ -1,9 +1,10 @@
|
||||
"""The PortMaster port: its archive, its event reader and its screens.
|
||||
"""The PortMaster port: its archive, its event reader and its fallback screens.
|
||||
|
||||
The screens are driven here with PortMasterDialog's functions replaced by
|
||||
stubs that record each command and answer from a script, and with a fake
|
||||
install.py that writes canned --events streams. What a device adds (pugwash
|
||||
drawing, the pad, mount, the network) is not exercised.
|
||||
drawing, the pad, mount, the network) is not exercised. The full-screen
|
||||
route of the port, and the other launchers, are in test_handheld_launchers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +13,7 @@ import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -22,6 +24,7 @@ from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PORT = REPO_ROOT / "ports" / "RetroBIOS"
|
||||
SHARED = REPO_ROOT / "ports" / "shared"
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import build_port # noqa: E402
|
||||
@@ -29,8 +32,7 @@ import build_port # noqa: E402
|
||||
_spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
|
||||
install = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(install)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"rbevents", PORT / "retrobios" / "rbevents.py")
|
||||
_spec = importlib.util.spec_from_file_location("rbevents", SHARED / "rbevents.py")
|
||||
rbevents = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(rbevents)
|
||||
|
||||
@@ -46,13 +48,6 @@ def _tempdir(test: unittest.TestCase) -> Path:
|
||||
|
||||
|
||||
class EveryScriptParses(unittest.TestCase):
|
||||
def test_bash_n(self):
|
||||
for script in SCRIPTS:
|
||||
with self.subTest(script=script.name):
|
||||
proc = subprocess.run(["bash", "-n", str(script)], capture_output=True,
|
||||
text=True, check=False)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
|
||||
def test_only_dialog_functions_the_oldest_releases_have(self):
|
||||
"""PortMaster 2024.02.26 and 8.5.23 define these in PortMasterDialog.txt,
|
||||
and have no funcs.txt: pm_message and the rest would be undefined."""
|
||||
@@ -62,7 +57,6 @@ class EveryScriptParses(unittest.TestCase):
|
||||
"messages_begin", "message", "messages_end", "progress",
|
||||
"progress_clear", "check_runtime"}
|
||||
text = "\n".join(path.read_text(encoding="utf-8") for path in SCRIPTS)
|
||||
import re # noqa: PLC0415
|
||||
used = set(re.findall(r"\b(PortMasterDialog\w*|PortMasterIPCheck)\b", text))
|
||||
self.assertLessEqual(used - {"PortMasterDialog.txt"}, known)
|
||||
self.assertNotRegex(text, r"\bpm_message\b|\bpm_platform_helper\b")
|
||||
@@ -70,6 +64,15 @@ class EveryScriptParses(unittest.TestCase):
|
||||
self.assertLessEqual(sent, commands)
|
||||
self.assertNotIn("register_clear_info", text)
|
||||
|
||||
def test_pugwash_applies_the_button_layout(self):
|
||||
"""Under no-harbour pugwash skips apply_button_mapping, so A and B
|
||||
land swapped on the devices PortMaster fixes (PortMaster-GUI
|
||||
pugwash/main.py, app.py): every start is no-check."""
|
||||
text = (PORT / "retrobios" / "screens.txt").read_text(encoding="utf-8")
|
||||
inits = re.findall(r'PortMasterDialogInit "([\w-]+)"', text)
|
||||
self.assertTrue(inits)
|
||||
self.assertEqual(set(inits), {"no-check"})
|
||||
|
||||
|
||||
class ThePortArchive(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -90,7 +93,8 @@ class ThePortArchive(unittest.TestCase):
|
||||
self.assertEqual({n.split("/")[0] for n in names if "/" in n}, {"retrobios"})
|
||||
self.assertEqual([n for n in names if n.endswith(".sh")], ["RetroBIOS.sh"])
|
||||
for member in ("retrobios/install.py", "retrobios/rbevents.py",
|
||||
"retrobios/screens.txt", "retrobios/gameinfo.xml",
|
||||
"retrobios/screens.txt", "retrobios/tui.txt",
|
||||
"retrobios/retrobios.gptk", "retrobios/gameinfo.xml",
|
||||
"retrobios/retrobios.md", "retrobios/licenses/LICENSE-retrobios.txt"):
|
||||
self.assertIn(member, names)
|
||||
self.assertEqual(port["version"], 4)
|
||||
@@ -106,21 +110,6 @@ class ThePortArchive(unittest.TestCase):
|
||||
install_sh = (REPO_ROOT / "install.sh").read_text(encoding="utf-8")
|
||||
self.assertIn(f'DEFAULT_INSTALL_SHA256="{bundled}"', install_sh)
|
||||
|
||||
def test_an_unpinned_installer_is_refused(self):
|
||||
root = _tempdir(self)
|
||||
for name in ("install.py", "install.sh", "LICENSE", "NOTICE"):
|
||||
shutil.copy(REPO_ROOT / name, root / name)
|
||||
shutil.copytree(PORT, root / "ports" / "RetroBIOS")
|
||||
with (root / "install.py").open("a") as fh:
|
||||
fh.write("\n")
|
||||
with self.assertRaises(ValueError):
|
||||
build_port.build_port(self.out / "x.zip", root)
|
||||
|
||||
def test_reproducible(self):
|
||||
first = self._build("a.zip").read_bytes()
|
||||
second = self._build("b.zip").read_bytes()
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
class EventRecords(unittest.TestCase):
|
||||
"""rbevents turns --events into fields the FIFO carries intact."""
|
||||
@@ -277,10 +266,12 @@ PortMasterDialogResult() {
|
||||
*) echo "$answer" ;;
|
||||
esac
|
||||
}
|
||||
source "$RB_SHARED/tui.txt"
|
||||
source "$GAMEDIR/screens.txt"
|
||||
if [ -n "$FAKE_ARCH" ]; then
|
||||
rb_arch() { echo "$FAKE_ARCH"; }
|
||||
fi
|
||||
rb_select_python
|
||||
rb_main
|
||||
trace STATUS "$?"
|
||||
'''
|
||||
@@ -289,7 +280,7 @@ trace STATUS "$?"
|
||||
class TheScreens(unittest.TestCase):
|
||||
"""Which screens the port shows, scenario by scenario."""
|
||||
|
||||
def run_port(self, scenario: str, answers: list[str], **env: str) -> list[tuple]:
|
||||
def run_port(self, scenario: str, answers: list[str], before=None, **env: str) -> list[tuple]:
|
||||
work = _tempdir(self)
|
||||
(work / "install.py").write_text(FAKE_INSTALL)
|
||||
(work / "driver.sh").write_text(DRIVER)
|
||||
@@ -299,12 +290,21 @@ class TheScreens(unittest.TestCase):
|
||||
# The oldest Python install.py supports, where it is installed: the
|
||||
# event reader runs on whatever the device has.
|
||||
(bin_dir / "python3").symlink_to(shutil.which("python3.8") or sys.executable)
|
||||
# The network probe: 0 answers, 7 cannot connect.
|
||||
(bin_dir / "curl").write_text('#!/bin/sh\nexit "${FAKE_CURL:-0}"\n')
|
||||
(bin_dir / "curl").chmod(0o755)
|
||||
(work / "packs").mkdir()
|
||||
if before is not None:
|
||||
before(work)
|
||||
self.work = work
|
||||
full_env = {
|
||||
**os.environ,
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"HOME": str(work),
|
||||
"GAMEDIR": str(PORT / "retrobios"),
|
||||
"RB_SHARED": str(SHARED),
|
||||
"RB_EVENTS": str(SHARED / "rbevents.py"),
|
||||
"RB_PACK_PATH": str(work / "packs"),
|
||||
"controlfolder": str(work / "PortMaster"),
|
||||
"ESUDO": "",
|
||||
"RB_STATE": str(work / "run"),
|
||||
@@ -367,7 +367,7 @@ class TheScreens(unittest.TestCase):
|
||||
self.assertEqual(self.lists(shown), [("menu", ["status", "check", "quit"])])
|
||||
menu = shown[[s[0] for s in shown].index("list")][2]
|
||||
self.assertEqual(menu["status"][0], "Fake OS 1.0: all 3 BIOS files present")
|
||||
self.assertEqual(shown[0], ("INIT", "no-harbour"))
|
||||
self.assertEqual(shown[0], ("INIT", "no-check"))
|
||||
self.assertEqual(shown[-2:], [("EXIT",), ("STATUS", "0")])
|
||||
self.assertEqual(self.calls(), ["--check --events"])
|
||||
|
||||
@@ -417,8 +417,26 @@ class TheScreens(unittest.TestCase):
|
||||
self.assertEqual(self.lists(shown), [("notice", ["info", "check", "quit"])])
|
||||
notice = shown[[s[0] for s in shown].index("list")][2]
|
||||
self.assertEqual(notice["info"][0], "No connection")
|
||||
self.assertIn("Connect to Wi-Fi", notice["info"][1])
|
||||
self.assertIn("connect to Wi-Fi", notice["info"][1])
|
||||
self.assertIn("/roms/bios", notice["info"][1])
|
||||
# Where a pack goes, and which one.
|
||||
self.assertIn(f"copy it into {self.work / 'packs'} on the SD card", notice["info"][1])
|
||||
self.assertIn("whose name contains RetroArch", notice["info"][1])
|
||||
|
||||
def test_offline_with_a_pack_on_the_card(self):
|
||||
pack = None
|
||||
|
||||
def place(work: Path) -> None:
|
||||
nonlocal pack
|
||||
pack = work / "packs" / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
|
||||
pack.write_bytes(b"")
|
||||
|
||||
shown = self.run_port("missing", ["update", "quit"], FAKE_CURL="7", before=place)
|
||||
self.assertEqual(self.calls()[:2], [
|
||||
f"--check --events --from-pack {pack}",
|
||||
f"--events --from-pack {pack} --platform retroarch",
|
||||
])
|
||||
self.assertEqual(shown[-1], ("STATUS", "0"))
|
||||
|
||||
def test_detection_failure(self):
|
||||
shown = self.run_port("nodetect", ["check", "quit"])
|
||||
|
||||
+65
-51
@@ -936,67 +936,81 @@ elsewhere. A custom BIOS directory is still named directly:
|
||||
python install.py --platform retroarch --dest /storage/emulated/0/RetroArch/system
|
||||
```
|
||||
|
||||
## On a handheld: the RetroBIOS port
|
||||
## On a handheld { #on-a-handheld-the-retrobios-port }
|
||||
|
||||
On a Linux handheld that runs [PortMaster](https://portmaster.games/) (ArkOS,
|
||||
ROCKNIX, AmberELEC, muOS, KNULLI and the others it supports), the installer
|
||||
also runs from the device itself, as a port with gamepad screens. The screens
|
||||
are PortMaster's own, so the port carries no graphics code.
|
||||
RetroBIOS also runs on the handheld itself, in the
|
||||
[full-screen interface](#the-full-screen-interface), with the controller.
|
||||
Take the file for your system from the
|
||||
[latest release](https://github.com/Abdess/retrobios/releases/latest), copy
|
||||
it to the SD card, then start RetroBIOS from the menu.
|
||||
|
||||
**Getting it.** Two ways:
|
||||
| System | File | Copy it to | Start it from |
|
||||
|--------|------|------------|---------------|
|
||||
| Any system with [PortMaster](https://portmaster.games/): ArkOS, dArkOS, ROCKNIX, AmberELEC, JELOS, muOS, Knulli | `RetroBIOS.zip` | PortMaster's `autoinstall` folder: `/roms/ports/PortMaster/autoinstall/` on ROCKNIX and AmberELEC, `/roms/tools/PortMaster/autoinstall/` on ArkOS, `/mmc/MUOS/PortMaster/autoinstall/` on muOS, `/userdata/system/.local/share/PortMaster/autoinstall/` on Knulli | Ports, after opening PortMaster once |
|
||||
| Batocera, Knulli | `RetroBIOS-batocera.zip`, extracted | `roms/ports` on the SHARE partition | Ports, after updating the game list |
|
||||
| muOS | `RetroBIOS.muxapp` | `ARCHIVE` at the root of the card, then install it from the Archive Manager | Applications |
|
||||
| ArkOS, dArkOS | `RetroBIOS-tools.zip`, extracted | `tools` on the EASYROMS partition | Options, then Tools |
|
||||
| ROCKNIX, JELOS | `RetroBIOS-tools.zip`, extracted | `/storage/.config/modules`, over the network share or SSH | Tools |
|
||||
| AmberELEC | `RetroBIOS-tools.zip`, extracted | `/storage/.config/distribution/modules` | Tools |
|
||||
|
||||
- From PortMaster's catalog, once the port is accepted there: open PortMaster,
|
||||
find RetroBIOS under the Other genre, install.
|
||||
- `RetroBIOS.zip`, copied into PortMaster's autoinstall folder. The release
|
||||
process attaches it to each [release](https://github.com/Abdess/retrobios/releases/latest)
|
||||
cut after the port was added. Releases cut before carry none: when the
|
||||
latest one lists no `RetroBIOS.zip`, build it from a clone (below). Starting PortMaster then installs it, with or without a
|
||||
network connection. PortMaster's FAQ names the folder: `/roms/ports/PortMaster/autoinstall/`
|
||||
on AmberELEC and ROCKNIX, `/roms/tools/PortMaster/autoinstall/` on ArkOS,
|
||||
`/mmc/MUOS/PortMaster/autoinstall/` on muOS,
|
||||
`/userdata/system/.local/share/PortMaster/autoinstall/` on KNULLI.
|
||||
A release cut before these files existed carries none of them. From a clone
|
||||
of the repository, `python scripts/build_port.py` writes all four into
|
||||
`dist/`.
|
||||
|
||||
From a clone of the repository, `python scripts/build_port.py` builds the
|
||||
same archive into `dist/RetroBIOS.zip`.
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| D-pad | Move |
|
||||
| A, Start | Choose, continue |
|
||||
| B, Select | Back, and quit on the first screen |
|
||||
| X | Tick or untick a system |
|
||||
| Y | Options |
|
||||
| L1, R1 | Page up, page down |
|
||||
| Start + Select | Quit at once (PortMaster port, Tools) |
|
||||
|
||||
**What it shows.** RetroBIOS appears among the ports. Started, it runs
|
||||
`install.py --check --events` and opens on the result: the system's name,
|
||||
how many BIOS files are present, missing, or present with a hash that does
|
||||
not match. The menu
|
||||
offers to update everything missing, to choose systems (each listed with what
|
||||
it lacks, selected or cleared with A), to check again, or to quit. An update
|
||||
shows a progress bar, one step per file, then what was installed, what failed
|
||||
and why, and what the collection cannot supply. Every list has its own Back or
|
||||
Quit entry, since older PortMaster releases do not read B there.
|
||||
On muOS the interface runs in muxterm, which already reads Start, B, Y (tick)
|
||||
and the D-pad. A, X, L1 and R1 come from PortMaster's gptokeyb2 when
|
||||
PortMaster is installed, and Menu quits.
|
||||
|
||||
**What it needs.**
|
||||
**Without Wi-Fi.** On a computer, download the BIOS pack whose name contains
|
||||
your system (every part, for a pack in parts) and copy it next to RetroBIOS:
|
||||
into `ports/retrobios` for the PortMaster port and on Batocera, to the root
|
||||
of the card on muOS, into the `retrobios` folder of the Tools entry. With no
|
||||
connection, RetroBIOS installs from it, as
|
||||
[`--from-pack`](#without-a-network-offline-install) does. With no pack there,
|
||||
it names the pack and the folder.
|
||||
|
||||
- A network connection: the list of files to check comes from the internet,
|
||||
as do the files. Without one the port says so and offers to check again.
|
||||
- Python 3.8 or newer. The port uses the system's `python3` when it is recent
|
||||
enough, else PortMaster's `python_3.11` runtime on aarch64 devices, which it
|
||||
downloads through PortMaster on first use. An armhf device with an older
|
||||
Python is told so, with the computer route below.
|
||||
- A BIOS folder the installer detects (see [Platform detection](#platform-detection)).
|
||||
On a system it does not know yet, the port says it found no BIOS folder.
|
||||
On a two-card device, RetroBIOS without Wi-Fi first asks to **copy the BIOS
|
||||
files from your other card**: muOS's `MUOS/bios` on the card the system does
|
||||
not read, or the other of `/roms/bios` and `/roms2/bios` on ArkOS and dArkOS.
|
||||
A says yes, as [`--from`](#copy-from-an-old-card) does. The list of files
|
||||
still comes from the pack, so the pack is needed too.
|
||||
|
||||
**The computer route.** With the SD card mounted on a computer, the one-line
|
||||
command fills it, naming the platform and the card's BIOS folder:
|
||||
`--platform <platform> --dest <BIOS folder on the card>`. The port's screens
|
||||
for an unknown system or an old Python name this route.
|
||||
**What it needs.** Python 3.8 or newer: the system's, else PortMaster's
|
||||
`python_3.11` runtime on aarch64, which the PortMaster port downloads on
|
||||
first use and the Tools entry then reuses. That runtime uses the system's
|
||||
OpenSSL 1.1, so on an image with OpenSSL 3 only it may not download (not
|
||||
tried on a device). The BIOS folder must be one the installer detects, see
|
||||
[Platform detection](#platform-detection).
|
||||
|
||||
**Which installer runs.** The archive carries `install.py`, the file the
|
||||
one-line command of the same commit downloads: the build refuses any other
|
||||
(`scripts/build_port.py` compares it with the SHA-256 `install.sh` pins). A
|
||||
port that downloaded the installer at each start against a pinned SHA-256
|
||||
would stop working at the next change to `install.py`, since the pin is fixed
|
||||
when the port is built. The file lists come from `main`, as for the one-line
|
||||
command. A newer port version carries a newer installer. When a list is in a
|
||||
format the carried installer does not read, the port says to update it.
|
||||
**Where no terminal shows.** The PortMaster port draws on the console, or in
|
||||
foot or xterm under a desktop session. Where neither can, it opens
|
||||
PortMaster's own screens instead: the same check, update and choice of
|
||||
systems, one list at a time. On Batocera and Knulli the visible terminal is
|
||||
not verified on a device yet: RetroBIOS tries xterm or foot, then console 4,
|
||||
and `retrobios/log.txt` names the route it took.
|
||||
|
||||
The launcher (`ports/RetroBIOS/RetroBIOS.sh`) and its screens
|
||||
(`retrobios/screens.txt`) read the installer through
|
||||
[Machine-readable events](#machine-readable-events) only.
|
||||
**From a computer.** Onion, MinUI, NextUI, Allium, GarlicOS and the Switch
|
||||
have no Python 3 to run the installer. Put the card in a computer and run the
|
||||
[one-line command](#one-line) there with
|
||||
`--platform <platform> --dest <BIOS folder on the card>`. This works for
|
||||
every system above too.
|
||||
|
||||
**Which installer runs.** Each file carries `install.py`, the one the
|
||||
one-line command of the same commit downloads: `scripts/build_port.py`
|
||||
refuses any other (it compares it with the SHA-256 `install.sh` pins). The
|
||||
file lists come from `main`. The launchers in `ports/` start it with `--tui`,
|
||||
and read it through [Machine-readable events](#machine-readable-events) for
|
||||
the rest.
|
||||
|
||||
## Without a network (offline install) { #without-a-network-offline-install }
|
||||
|
||||
|
||||
@@ -218,16 +218,19 @@ python scripts/pipeline.py --include-archived
|
||||
# which install.py --check reads to name a part missing from a folder.
|
||||
python scripts/split_pack.py dist/
|
||||
|
||||
# 2b. The handheld port, RetroBIOS.zip, built from ports/RetroBIOS with the
|
||||
# installer the bootstraps pin. It is a release file, listed in the
|
||||
# checksums below, and check_release.py holds it to them.
|
||||
# 2b. The handheld launchers, each carrying the installer the bootstraps pin:
|
||||
# RetroBIOS.zip (the PortMaster port), RetroBIOS-batocera.zip (Batocera
|
||||
# and Knulli Ports), RetroBIOS.muxapp (muOS Applications) and
|
||||
# RetroBIOS-tools.zip (the Tools menu of ArkOS, dArkOS, ROCKNIX, JELOS,
|
||||
# AmberELEC). They are release files, listed in the checksums below, and
|
||||
# check_release.py compares each asset with its line there.
|
||||
python scripts/build_port.py
|
||||
|
||||
# 3. Checksums of the files as published, then sign the list. The checksums
|
||||
# answer corruption, and the signature answers a rewritten release, which is
|
||||
# the one thing a checksum published beside its own artifacts cannot
|
||||
# answer.
|
||||
(cd dist && sha256sum *.zip > SHA256SUMS.txt)
|
||||
(cd dist && sha256sum *.zip RetroBIOS.muxapp > SHA256SUMS.txt)
|
||||
ssh-keygen -Y sign -f ~/.ssh/retrobios_signing -n file dist/SHA256SUMS.txt
|
||||
|
||||
# 4. Record what each pack holds, read from the archives just checked:
|
||||
@@ -249,7 +252,7 @@ python scripts/release_notes.py --whats-new tmp/whats-new.md --output tmp/notes.
|
||||
# A public release with half its assets is a broken download for everyone
|
||||
# during the whole upload.
|
||||
gh release create "v${DATE}" --draft --title "BIOS Pack v${DATE}" --notes-file tmp/notes.md
|
||||
for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip; do
|
||||
for f in dist/SHA256SUMS.txt dist/SHA256SUMS.txt.sig dist/*.zip dist/RetroBIOS.muxapp; do
|
||||
[ -f "$f" ] && gh release upload "v${DATE}" "$f#$(basename "$f")" --clobber
|
||||
done
|
||||
# A count does not see a failed upload beside a stray file, nor a part
|
||||
|
||||
+33
-11
@@ -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
|
||||
|
||||
Reference in new issue
Block a user