diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index ad11cd62..2787400f 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -17,6 +17,8 @@ on:
- "install.py"
- "install.sh"
- "install.ps1"
+ # The handheld port bundles install.py and its tests read both.
+ - "ports/**"
# Every input of the site build, so a page that would fail the deploy
# fails here first (tests/test_workflow_paths.py).
- "wiki/**"
@@ -42,6 +44,7 @@ on:
- "install.py"
- "install.sh"
- "install.ps1"
+ - "ports/**"
- "wiki/**"
- "docs_assets/**"
- "provenance/**"
diff --git a/ports/RetroBIOS/README.md b/ports/RetroBIOS/README.md
new file mode 100644
index 00000000..eb023ef9
--- /dev/null
+++ b/ports/RetroBIOS/README.md
@@ -0,0 +1,18 @@
+## Notes
+
+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.
+
+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.
+
+## 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) |
diff --git a/ports/RetroBIOS/RetroBIOS.sh b/ports/RetroBIOS/RetroBIOS.sh
new file mode 100755
index 00000000..59928003
--- /dev/null
+++ b/ports/RetroBIOS/RetroBIOS.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# RetroBIOS: checks the BIOS folder of this system and downloads the files
+# it lacks, with PortMaster's own screens.
+
+XDG_DATA_HOME=${XDG_DATA_HOME:-$HOME/.local/share}
+
+if [ -d "/opt/system/Tools/PortMaster/" ]; then
+ controlfolder="/opt/system/Tools/PortMaster"
+elif [ -d "/opt/tools/PortMaster/" ]; then
+ controlfolder="/opt/tools/PortMaster"
+elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
+ controlfolder="$XDG_DATA_HOME/PortMaster"
+else
+ controlfolder="/roms/ports/PortMaster"
+fi
+
+source "$controlfolder/control.txt"
+[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
+get_controls
+
+GAMEDIR="/$directory/ports/retrobios"
+cd "$GAMEDIR" || exit 1
+
+> "$GAMEDIR/log.txt" && exec > >(tee "$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.
+if [ ! -f "$controlfolder/PortMasterDialog.txt" ]; then
+ echo "RetroBIOS needs PortMaster 2024.02.26 or newer: update PortMaster, then start RetroBIOS again."
+ exit 1
+fi
+source "$controlfolder/PortMasterDialog.txt"
+source "$GAMEDIR/screens.txt"
+
+export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig"
+
+rb_main
+status=$?
+
+if declare -F pm_finish > /dev/null; then
+ pm_finish
+fi
+exit $status
diff --git a/ports/RetroBIOS/gameinfo.xml b/ports/RetroBIOS/gameinfo.xml
new file mode 100644
index 00000000..4eb01b2f
--- /dev/null
+++ b/ports/RetroBIOS/gameinfo.xml
@@ -0,0 +1,11 @@
+
+
+
+ ./RetroBIOS.sh
+ RetroBIOS
+ 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.
+ Abdess
+ Open Source
+ Utility
+
+
diff --git a/ports/RetroBIOS/port.json b/ports/RetroBIOS/port.json
new file mode 100644
index 00000000..083ef871
--- /dev/null
+++ b/ports/RetroBIOS/port.json
@@ -0,0 +1,31 @@
+{
+ "version": 4,
+ "name": "retrobios.zip",
+ "items": [
+ "RetroBIOS.sh",
+ "retrobios"
+ ],
+ "items_opt": [],
+ "attr": {
+ "title": "RetroBIOS",
+ "porter": [
+ "Abdess"
+ ],
+ "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_md": null,
+ "genres": [
+ "other"
+ ],
+ "image": null,
+ "rtr": true,
+ "exp": false,
+ "runtime": [],
+ "store": [],
+ "availability": "full",
+ "reqs": [],
+ "arch": [],
+ "min_glibc": ""
+ }
+}
diff --git a/ports/RetroBIOS/retrobios/rbevents.py b/ports/RetroBIOS/retrobios/rbevents.py
new file mode 100644
index 00000000..8c71c3a0
--- /dev/null
+++ b/ports/RetroBIOS/retrobios/rbevents.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""Turn install.py --events into tab-separated records for the launcher.
+
+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.
+
+ rbevents.py status EVENTS records of a --check run
+ rbevents.py follow SUMMARY an install run on stdin: progress
+ records on stdout, the outcome in SUMMARY
+
+Python 3.8 and the standard library, like install.py.
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+from typing import Iterable, Iterator, TextIO
+
+_UNSAFE = re.compile(r"[\x00-\x1f\x7f*?\[\]\\]+")
+_SPACES = re.compile(r" {2,}")
+
+
+def clean(value: object) -> str:
+ """One FIFO-safe field: no control character, pattern character or run of spaces."""
+ text = _UNSAFE.sub(" ", str(value))
+ return _SPACES.sub(" ", text).strip()
+
+
+def size_text(count: int) -> str:
+ """12 KB, 3.4 MB, 1.2 GB."""
+ value = float(count)
+ for unit in ("B", "KB", "MB", "GB"):
+ if value < 1024 or unit == "GB":
+ return f"{value:.0f} {unit}" if unit in ("B", "KB") else f"{value:.1f} {unit}"
+ value /= 1024
+ return f"{value:.1f} GB"
+
+
+def events(lines: Iterable[str]) -> Iterator[dict]:
+ """The JSON objects of an event stream. A line that is not one is skipped."""
+ for line in lines:
+ line = line.strip()
+ if not line.startswith("{"):
+ continue
+ try:
+ event = json.loads(line)
+ except ValueError:
+ continue
+ if isinstance(event, dict) and isinstance(event.get("event"), str):
+ yield event
+
+
+def record(out: TextIO, *fields: object) -> None:
+ # A tab is whitespace to `read`, so two in a row are one separator: an
+ # empty field would shift the ones after it. "-" stands for it.
+ out.write("\t".join(clean(field) or "-" for field in fields) + "\n")
+ out.flush()
+
+
+def status(stream: Iterable[str], out: TextIO) -> None:
+ """The records the start screen and the menus read.
+
+ Only the first detected platform is described: a handheld runs one.
+ """
+ platform = None
+ ended = False
+ for event in events(stream):
+ kind = event["event"]
+ if kind == "start":
+ record(out, "start", event.get("installer", ""))
+ elif kind == "detect":
+ found = event.get("platforms") or []
+ record(out, "detect", event.get("os_name", ""), len(found))
+ if found:
+ first = found[0]
+ platform = first.get("platform")
+ record(out, "platform", platform, first.get("name", ""),
+ first.get("bios_dir", ""))
+ elif kind == "error":
+ record(out, "error", event.get("reason", ""), event.get("message", ""))
+ elif kind == "system" and event.get("platform") == platform:
+ needed = event.get("missing", 0) + event.get("wrong_hash", 0)
+ system = event.get("system", "")
+ # install.py files what belongs to no single system under "other".
+ name = "Files of no single system" if system == "other" else event.get("name", "")
+ record(out, "system", system, name,
+ event.get("files", 0), event.get("present", 0), event.get("missing", 0),
+ event.get("wrong_hash", 0), size_text(event.get("bytes_needed", 0)),
+ "1" if needed else "0")
+ elif kind == "check" and event.get("platform") == platform:
+ record(out, "check", event.get("files", 0), event.get("present", 0),
+ event.get("missing", 0), event.get("wrong_hash", 0),
+ size_text(event.get("bytes_needed", 0)), event.get("data_missing", 0),
+ event.get("not_installable", 0))
+ elif kind == "end":
+ ended = True
+ record(out, "end", event.get("code", 1))
+ if not ended:
+ # Refused before the run started, or stopped without a word.
+ record(out, "end", "none")
+
+
+def follow(stream: Iterable[str], out: TextIO, summary: TextIO) -> None:
+ """Progress records as an install runs, and its outcome in *summary*."""
+ ended = False
+ for event in events(stream):
+ kind = event["event"]
+ if kind == "download":
+ record(out, "download", event.get("files", 0), size_text(event.get("bytes", 0)))
+ elif kind == "downloaded":
+ record(out, "file", event.get("files_done", 0), event.get("files_total", 0),
+ "ok" if event.get("ok") else "failed", event.get("dest", ""),
+ size_text(event.get("bytes_done", 0)),
+ size_text(event.get("bytes_total", 0)))
+ elif kind == "progress":
+ record(out, "progress", event.get("files_done", 0), event.get("files_total", 0),
+ size_text(event.get("bytes_done", 0)),
+ size_text(event.get("bytes_total", 0)))
+ elif kind == "error":
+ record(out, "error", event.get("reason", ""), event.get("message", ""))
+ record(summary, "error", event.get("reason", ""), event.get("message", ""))
+ elif kind == "summary":
+ record(summary, "summary", event.get("installed", 0), event.get("up_to_date", 0),
+ event.get("pending", 0), "1" if event.get("interrupted") else "0")
+ for failure in event.get("failed") or []:
+ record(summary, "failed", failure.get("dest", ""), failure.get("reason", ""))
+ for group in event.get("skipped") or []:
+ record(summary, "skipped", group.get("label", ""), group.get("files", 0),
+ group.get("required", 0))
+ elif kind == "end":
+ ended = True
+ record(summary, "end", event.get("code", 1))
+ if not ended:
+ record(summary, "end", "none")
+
+
+def main(argv: list[str]) -> int:
+ if len(argv) == 2 and argv[0] == "status":
+ with open(argv[1], encoding="utf-8", errors="replace") as stream:
+ status(stream, sys.stdout)
+ return 0
+ if len(argv) == 2 and argv[0] == "follow":
+ with Path(argv[1]).open("w", encoding="utf-8") as summary:
+ follow(sys.stdin, sys.stdout, summary)
+ return 0
+ sys.stderr.write("usage: rbevents.py status EVENTS | follow SUMMARY\n")
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))
diff --git a/ports/RetroBIOS/retrobios/screens.txt b/ports/RetroBIOS/retrobios/screens.txt
new file mode 100644
index 00000000..91d45650
--- /dev/null
+++ b/ports/RetroBIOS/retrobios/screens.txt
@@ -0,0 +1,462 @@
+#!/bin/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.
+#
+# Only what PortMaster 2024.02.26 and the 8.5.23 copy JELOS and KOLOS ship
+# is used: PortMasterDialogInit, PortMasterDialog, PortMasterDialogResult,
+# PortMasterDialogExit, PortMasterIPCheck, and the commands
+# register_set_info, register_clear, selection_list, messages_begin,
+# message, messages_end, progress, progress_clear and check_runtime.
+# Those releases ignore --want-cancel, so every list ends on its own Back
+# or Quit entry. They also read "\n" literally, so no text has a newline,
+# 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).
+
+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_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. 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/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
+ 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
+# stops waiting after 30 seconds on the older releases and then returns
+# WAIT: the answer is read again until pugwash writes one or exits.
+rb_answer() {
+ local answer tries=0
+ answer=$(PortMasterDialogResult "$@")
+ while [ "$answer" = "WAIT" ] || [ -z "$answer" ]; do
+ if [ -n "$PM_PID" ] && [ ! -d "/proc/$PM_PID" ]; then
+ break
+ fi
+ if [ "$tries" -ge 6000 ]; then
+ break
+ fi
+ tries=$((tries + 1))
+ sleep 0.3
+ answer=$(cat "$PM_DONE" 2>/dev/null)
+ done
+ printf '%s\n' "$answer"
+}
+
+rb_plural() {
+ if [ "$1" = "1" ]; then
+ printf '%s %s\n' "$1" "$2"
+ else
+ printf '%s %ss\n' "$1" "$2"
+ fi
+}
+
+rb_entry() {
+ PortMasterDialog "register_set_info" "$1" "$2" "title:$3" "description:$4"
+}
+
+# Show a list and print the chosen key. B, where the release reads it,
+# answers like the Back or Quit entry ($2).
+rb_choose() {
+ local answer
+ answer=$(rb_answer "selection_list" "--register=$1" "--want-description" "--want-cancel")
+ case "$answer" in
+ CANCEL|FAIL|WAIT|"") printf '%s\n' "$2" ;;
+ *) printf '%s\n' "$answer" ;;
+ esac
+}
+
+# A screen of text with one or two ways out: rb_notice TITLE TEXT KEY LABEL [KEY LABEL].
+# B answers like the last one, which leaves.
+rb_notice() {
+ local title="$1" text="$2"
+ shift 2
+ local last="$1" answer
+ PortMasterDialog "register_clear" "notice"
+ # The list has no heading of its own: the first entry is the heading.
+ rb_entry "notice" "info" "$title" "$text"
+ rb_entry "notice" "$1" "$2" "$text"
+ if [ $# -ge 4 ]; then
+ rb_entry "notice" "$3" "$4" "$text"
+ last="$3"
+ fi
+ while true; do
+ answer=$(rb_choose "notice" "$last")
+ if [ "$answer" != "info" ]; then
+ printf '%s\n' "$answer"
+ return
+ fi
+ done
+}
+
+# --- Reading install.py ---------------------------------------------------
+
+# Run a --check and read its records into RB_* variables.
+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"
+ 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"
+}
+
+rb_read_status() {
+ RB_OS_NAME="this system"
+ RB_DETECTED=""
+ RB_PLATFORM=""
+ RB_PLATFORM_NAME=""
+ RB_BIOS_DIR=""
+ RB_ERROR=""
+ RB_ERROR_TEXT=""
+ RB_CHECKED=""
+ RB_END="none"
+ RB_SYSTEMS=()
+ RB_COMPLETE=0
+ local kind a b c d e f g h
+ while IFS=$'\t' read -r kind a b c d e f g h; do
+ case "$kind" in
+ detect) RB_OS_NAME="$a"; RB_DETECTED="$b" ;;
+ platform) RB_PLATFORM="$a"; RB_PLATFORM_NAME="$b"; RB_BIOS_DIR="$c" ;;
+ error) RB_ERROR="$a"; RB_ERROR_TEXT="$b" ;;
+ check)
+ RB_CHECKED=1
+ RB_FILES="$a"; RB_PRESENT="$b"; RB_MISSING="$c"; RB_WRONG="$d"
+ RB_NEEDED_SIZE="$e"; RB_DATA_MISSING="$f"; RB_NOT_INSTALLABLE="$g"
+ ;;
+ system)
+ if [ "$h" = "1" ]; then
+ RB_SYSTEMS+=("$a"$'\t'"$b"$'\t'"$c"$'\t'"$d"$'\t'"$e"$'\t'"$f"$'\t'"$g")
+ else
+ RB_COMPLETE=$((RB_COMPLETE + 1))
+ fi
+ ;;
+ end) RB_END="$a" ;;
+ esac
+ done < "$1"
+}
+
+rb_status_text() {
+ local text="$RB_OS_NAME, $RB_PLATFORM_NAME file list: $RB_PRESENT/$RB_FILES BIOS files, $RB_MISSING missing, $RB_WRONG wrong hash."
+ if [ "${RB_NOT_INSTALLABLE:-0}" != "0" ]; then
+ text="$text $RB_NOT_INSTALLABLE not in the collection."
+ fi
+ printf '%s\n' "$text"
+}
+
+rb_pending() {
+ printf '%s\n' $((RB_MISSING + RB_WRONG + RB_DATA_MISSING))
+}
+
+# --- 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
+}
+
+# What the check found when it found nothing to show: no platform, no
+# connection, an unreadable list. Prints check or quit.
+rb_screen_problem() {
+ if [ "$RB_END" = "none" ]; then
+ rb_notice "The installer stopped before checking" "install.py ended without a result. Its messages are in $RB_LOG." "check" "Check again" "quit" "Quit"
+ 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: checking only" "$RB_OS_NAME, BIOS folder $RB_BIOS_DIR. The list of files to check comes from the internet, and so do the files. Connect to Wi-Fi, then check again." "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
+ rb_notice "The file list could not be read" "${RB_ERROR_TEXT:-install.py ended with code $RB_END}. Its messages are in $RB_LOG." "check" "Check again" "quit" "Quit"
+ fi
+}
+
+# The menu after a check. Prints update, choose, check or quit.
+rb_screen_menu() {
+ local status pending
+ status=$(rb_status_text)
+ pending=$(rb_pending)
+ PortMasterDialog "register_clear" "menu"
+ # The list has no heading of its own: the first entry is the status.
+ if [ "$pending" -gt 0 ]; then
+ local data=""
+ if [ "${RB_DATA_MISSING:-0}" != "0" ]; then
+ data=" and $RB_DATA_MISSING data files"
+ fi
+ rb_entry "menu" "status" "$RB_OS_NAME: $RB_PRESENT/$RB_FILES BIOS files, $RB_MISSING missing, $RB_WRONG wrong hash" "$status BIOS folder: $RB_BIOS_DIR."
+ rb_entry "menu" "update" "Update everything missing" "$status Downloads $(rb_plural $((RB_MISSING + RB_WRONG)) file)$data, $RB_NEEDED_SIZE, to $RB_BIOS_DIR."
+ rb_entry "menu" "choose" "Choose systems" "$status Pick the systems to update."
+ else
+ rb_entry "menu" "status" "$RB_OS_NAME: all $RB_FILES BIOS files present" "$status Nothing to download. BIOS folder: $RB_BIOS_DIR."
+ fi
+ rb_entry "menu" "check" "Check again" "$status Reads the BIOS folder and the file list again."
+ rb_entry "menu" "quit" "Quit" "$status"
+ local answer
+ while true; do
+ answer=$(rb_choose "menu" "quit")
+ if [ "$answer" != "status" ]; then
+ printf '%s\n' "$answer"
+ return
+ fi
+ done
+}
+
+# Systems with files to fetch, toggled with A. Prints the chosen system
+# ids, one per line, or nothing for Back.
+rb_screen_systems() {
+ local -A chosen=()
+ local line id name files present missing wrong size answer count
+ while true; do
+ count=0
+ for id in "${!chosen[@]}"; do
+ count=$((count + 1))
+ done
+ PortMasterDialog "register_clear" "systems"
+ if [ "$count" -gt 0 ]; then
+ rb_entry "systems" "update" "Update the $count selected" "Downloads what the selected systems lack to $RB_BIOS_DIR."
+ else
+ rb_entry "systems" "update" "Update the selected" "Nothing selected yet: pick systems below with A."
+ fi
+ rb_entry "systems" "back" "Back" "Back to the menu. $(rb_plural "$RB_COMPLETE" system) complete, not listed."
+ for line in "${RB_SYSTEMS[@]}"; do
+ IFS=$'\t' read -r id name files present missing wrong size <<< "$line"
+ local mark=""
+ if [ -n "${chosen[$id]}" ]; then
+ mark=" (selected)"
+ fi
+ rb_entry "systems" "sys:$id" "$name$mark" "$present/$files present, $missing missing, $wrong wrong hash, $size to download. A selects or clears it."
+ done
+ answer=$(rb_choose "systems" "back")
+ case "$answer" in
+ back) return ;;
+ update)
+ if [ "$count" -gt 0 ]; then
+ printf '%s\n' "${!chosen[@]}" | sort
+ return
+ fi
+ ;;
+ sys:*)
+ id="${answer#sys:}"
+ if [ -n "${chosen[$id]}" ]; then
+ unset "chosen[$id]"
+ else
+ chosen[$id]=1
+ fi
+ ;;
+ esac
+ done
+}
+
+# Download with a progress bar fed by the event stream: rb_update [SYSTEM...]
+rb_update() {
+ local args=() system
+ for system in "$@"; do
+ args+=(--system "$system")
+ done
+ mkdir -p "$RB_STATE"
+ PortMasterDialog "messages_begin"
+ 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_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
+ download) PortMasterDialog "message" "$(rb_plural "$a" file), $b." ;;
+ file) PortMasterDialog "progress" "$d, $e of $f" "$a" "$b" ;;
+ progress) PortMasterDialog "progress" "$c of $d" "$a" "$b" ;;
+ error) PortMasterDialog "message" "$b" ;;
+ esac
+ done
+ PortMasterDialog "progress_clear"
+ PortMasterDialog "messages_end"
+}
+
+# What the update did. Prints back or quit.
+rb_screen_summary() {
+ local kind a b c d shown=0 failed=0
+ local end="none" installed=0 up_to_date=0 pending=0 interrupted=0 error=""
+ while IFS=$'\t' read -r kind a b c d; do
+ case "$kind" in
+ summary) installed="$a"; up_to_date="$b"; pending="$c"; interrupted="$d" ;;
+ failed) failed=$((failed + 1)) ;;
+ error) error="$b" ;;
+ end) end="$a" ;;
+ esac
+ done < "$RB_STATE/summary.tsv"
+ PortMasterDialog "register_clear" "summary"
+ # The first entry is the outcome, the heading the list does not have.
+ if [ "$end" = "none" ]; then
+ rb_entry "summary" "info:result" "The installer stopped" "install.py ended without a result. Its messages are in $RB_LOG."
+ elif [ -n "$error" ]; then
+ rb_entry "summary" "info:result" "Nothing downloaded" "$error."
+ else
+ rb_entry "summary" "info:result" "Installed: $(rb_plural "$installed" file)" "$up_to_date already up to date, $failed failed, $pending still to fetch."
+ fi
+ rb_entry "summary" "back" "Back to the menu" "Checks the BIOS folder again."
+ rb_entry "summary" "quit" "Quit" "Leaves RetroBIOS."
+ if [ "$interrupted" = "1" ]; then
+ rb_entry "summary" "info:stopped" "Stopped before the end" "Update again to carry on. Files already installed are kept."
+ fi
+ while IFS=$'\t' read -r kind a b c d; do
+ case "$kind" in
+ failed)
+ shown=$((shown + 1))
+ if [ "$shown" -le 50 ]; then
+ rb_entry "summary" "info:failed:$shown" "Failed: $a" "$b"
+ fi
+ ;;
+ skipped)
+ rb_entry "summary" "info:skipped:$a" "Not in the collection ($a): $(rb_plural "$b" file)" "Required: $c. The RetroBIOS collection does not hold these files, so they cannot be downloaded."
+ ;;
+ esac
+ done < "$RB_STATE/summary.tsv"
+ if [ "$shown" -gt 50 ]; then
+ rb_entry "summary" "info:more" "And $((shown - 50)) more failed" "The full list is in $RB_LOG."
+ fi
+ local answer
+ while true; do
+ answer=$(rb_choose "summary" "back")
+ case "$answer" in
+ back|quit) printf '%s\n' "$answer"; return ;;
+ esac
+ done
+}
+
+# --- Flow -----------------------------------------------------------------
+
+rb_main() {
+ if ! rb_select_python; then
+ PortMasterDialogInit "no-harbour"
+ rb_screen_no_python
+ PortMasterDialogExit
+ rb_release_python
+ return 1
+ fi
+ PortMasterDialogInit "no-harbour"
+ RB_PLATFORM_ARGS=()
+ local action systems chosen
+ rb_check
+ while true; do
+ if [ -z "$RB_CHECKED" ]; then
+ action=$(rb_screen_problem)
+ else
+ # Later runs name the platform the first one found, so a second
+ # detected platform never changes what the screens describe.
+ RB_PLATFORM_ARGS=(--platform "$RB_PLATFORM")
+ action=$(rb_screen_menu)
+ fi
+ case "$action" in
+ update)
+ rb_update
+ [ "$(rb_screen_summary)" = "quit" ] && break
+ rb_check
+ ;;
+ choose)
+ systems=$(rb_screen_systems)
+ if [ -n "$systems" ]; then
+ mapfile -t chosen <<< "$systems"
+ rb_update "${chosen[@]}"
+ [ "$(rb_screen_summary)" = "quit" ] && break
+ rb_check
+ fi
+ ;;
+ check) rb_check ;;
+ *) break ;;
+ esac
+ done
+ PortMasterDialogExit
+ rb_release_python
+ return 0
+}
diff --git a/scripts/build_port.py b/scripts/build_port.py
new file mode 100644
index 00000000..33082be6
--- /dev/null
+++ b/scripts/build_port.py
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+"""Build the PortMaster port in ports/RetroBIOS into dist/RetroBIOS.zip.
+
+The archive has the layout PortMaster's own release build gives a port
+(PortMaster-New tools/build_release.py): the launch script at the top, and
+one folder holding port.json, gameinfo.xml, the README as .md, the
+licenses and the port's files. install.py is copied from the repository
+root, and only when it is the installer the bootstraps pin, so the port
+runs the same bytes as the one-line command of the same commit. Members are
+written as the packs write theirs: fixed timestamps, sorted names, the
+executable bit read from the source, so the same tree gives the same zip.
+
+Usage:
+ python scripts/build_port.py
+ python scripts/build_port.py --output-dir tmp/port
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import re
+import sys
+import zipfile
+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"
+_PIN = re.compile(r'^DEFAULT_INSTALL_SHA256="([0-9a-f]{64})"$', re.MULTILINE)
+
+
+def pinned_installer_sha256(root: Path = REPO_ROOT) -> str:
+ """The SHA-256 install.sh accepts for install.py."""
+ found = _PIN.search((root / "install.sh").read_text(encoding="utf-8"))
+ if found is None:
+ raise ValueError("install.sh pins no installer SHA-256")
+ 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"
+ 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"),
+ ]
+ 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 len(set(names)) != len(names):
+ raise ValueError("two port members 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."""
+ 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"
+ )
+ # The packs' member writer: imported here, as it loads the whole
+ # pack builder, which a caller reading PORT_ZIP does not need.
+ from generate_pack import _add_pack_member # noqa: PLC0415
+
+ 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)
+ scratch.replace(output)
+ return output
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ allow_abbrev=False, description="Build the RetroBIOS PortMaster port"
+ )
+ parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "dist",
+ help="where RetroBIOS.zip is written (default: dist/)")
+ args = parser.parse_args()
+ try:
+ with artifact_lock(str(args.output_dir)):
+ path = build_port(args.output_dir / PORT_ZIP)
+ 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})")
+ return 0
+
+
+if __name__ == "__main__":
+ from common import run_cli
+
+ run_cli(main)
diff --git a/tests/test_port.py b/tests/test_port.py
new file mode 100644
index 00000000..076a46be
--- /dev/null
+++ b/tests/test_port.py
@@ -0,0 +1,436 @@
+"""The PortMaster port: its archive, its event reader and its 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.
+"""
+from __future__ import annotations
+
+import hashlib
+import importlib.util
+import io
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+PORT = REPO_ROOT / "ports" / "RetroBIOS"
+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)
+_spec = importlib.util.spec_from_file_location(
+ "rbevents", PORT / "retrobios" / "rbevents.py")
+rbevents = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(rbevents)
+
+SCRIPTS = [PORT / "RetroBIOS.sh", PORT / "retrobios" / "screens.txt"]
+
+
+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
+
+
+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."""
+ known = {"PortMasterDialogInit", "PortMasterDialog", "PortMasterDialogResult",
+ "PortMasterDialogExit", "PortMasterIPCheck"}
+ commands = {"register_set_info", "register_clear", "selection_list",
+ "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")
+ sent = set(re.findall(r'(?:PortMasterDialog|rb_answer) "(\w+)"', text))
+ self.assertLessEqual(sent, commands)
+ self.assertNotIn("register_clear_info", text)
+
+
+class ThePortArchive(unittest.TestCase):
+ def setUp(self):
+ self.out = _tempdir(self)
+
+ def _build(self, name: str = "RetroBIOS.zip") -> Path:
+ return build_port.build_port(self.out / name)
+
+ def test_layout_portmaster_installs(self):
+ """One launch script at the top, one folder holding port.json: what
+ harbourmaster's captain.check_port accepts without a warning."""
+ with zipfile.ZipFile(self._build()) as zf:
+ names = zf.namelist()
+ port = json.loads(zf.read("retrobios/port.json"))
+ mode = zf.getinfo("RetroBIOS.sh").external_attr >> 16
+ self.assertEqual(names, sorted(names))
+ self.assertEqual([n for n in names if "/" not in n], ["RetroBIOS.sh"])
+ 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/retrobios.md", "retrobios/licenses/LICENSE-retrobios.txt"):
+ self.assertIn(member, names)
+ self.assertEqual(port["version"], 4)
+ self.assertEqual(port["name"], "retrobios.zip")
+ self.assertEqual(port["items"], ["RetroBIOS.sh", "retrobios"])
+ self.assertEqual(port["attr"]["genres"], ["other"])
+ self.assertTrue(mode & 0o111, "the launch script is executable")
+
+ def test_bundled_installer_is_the_pinned_one(self):
+ with zipfile.ZipFile(self._build()) as zf:
+ bundled = hashlib.sha256(zf.read("retrobios/install.py")).hexdigest()
+ self.assertEqual(bundled, build_port.pinned_installer_sha256())
+ 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."""
+
+ def test_fields_are_fifo_safe(self):
+ self.assertEqual(rbevents.clean("a\tb\n[x]*? c"), "a b x c")
+ out = io.StringIO()
+ rbevents.record(out, "file", "", "a")
+ self.assertEqual(out.getvalue(), "file\t-\ta\n")
+
+ def test_follow_writes_the_outcome(self):
+ stream = [
+ json.dumps({"event": "download", "platform": "p", "files": 1, "bytes": 2048}),
+ "not json",
+ json.dumps({"event": "downloaded", "dest": "x.bin", "ok": False,
+ "reason": "HTTP 404", "size": 2048, "files_done": 1,
+ "files_total": 1, "bytes_done": 0, "bytes_total": 2048}),
+ json.dumps({"event": "summary", "mode": "install", "installed": 0,
+ "up_to_date": 4, "pending": 1,
+ "failed": [{"dest": "x.bin", "reason": "HTTP 404"}],
+ "skipped": [], "interrupted": False}),
+ json.dumps({"event": "end", "code": 2}),
+ ]
+ out, summary = io.StringIO(), io.StringIO()
+ rbevents.follow(stream, out, summary)
+ self.assertEqual(out.getvalue().splitlines(), [
+ "download\t1\t2 KB", "file\t1\t1\tfailed\tx.bin\t0 B\t2 KB"])
+ self.assertEqual(summary.getvalue().splitlines(), [
+ "summary\t0\t4\t1\t0", "failed\tx.bin\tHTTP 404", "end\t2"])
+
+ def test_a_run_without_end_says_so(self):
+ out = io.StringIO()
+ rbevents.status([], out)
+ self.assertEqual(out.getvalue(), "end\tnone\n")
+
+
+FAKE_INSTALL = r'''
+import json, os, sys
+from pathlib import Path
+
+state = Path(os.environ["FAKE_STATE"])
+scenario = os.environ["FAKE_SCENARIO"]
+keys = json.loads(os.environ["FAKE_KEYS"])
+with (state / "calls.txt").open("a") as fh:
+ fh.write(" ".join(sys.argv[1:]) + "\n")
+check = "--check" in sys.argv
+
+
+def emit(kind, **fields):
+ assert sorted(fields) == sorted(keys[kind]), kind
+ print(json.dumps({"event": kind, **fields}), flush=True)
+
+
+def end(code):
+ emit("end", code=code)
+ sys.exit(code)
+
+
+emit("start", schema=1, installer="fake", mode="check" if check else "install")
+if scenario == "nodetect":
+ emit("detect", os="linux", os_name="Fake OS 1.0", platforms=[])
+ end(1)
+emit("detect", os="linux", os_name="Fake OS 1.0", platforms=[
+ {"platform": "retroarch", "name": "RetroArch", "bios_dir": "/roms/bios"}])
+if scenario == "offline":
+ emit("error", stage="manifest", reason="network", platform="retroarch",
+ message="the file list could not be fetched: [Errno 101] Network is unreachable")
+ end(1)
+done = scenario == "complete" or (state / "installed").exists()
+if check:
+ for system, name, files, present, missing, wrong, needed in (
+ ("sys-a", "Alpha*", 2, 2 if done else 0, 0 if done else 1, 0 if done else 1,
+ 0 if done else 300),
+ ("sys-b", "Bravo", 1, 1, 0, 0, 0),
+ ):
+ emit("system", platform="retroarch", system=system, name=name, files=files,
+ present=present, missing=missing, wrong_hash=wrong, bytes_needed=needed)
+ emit("check", platform="retroarch", name="RetroArch", bios_dir="/roms/bios", files=3,
+ present=3 if done else 1, missing=0 if done else 1, wrong_hash=0 if done else 1,
+ bytes_needed=0 if done else 300, data_missing=0, not_installable=1)
+ emit("summary", mode="check", installed=0, up_to_date=3 if done else 1,
+ pending=0 if done else 2, failed=[], skipped=[], interrupted=False)
+ end(0 if done else 1)
+emit("download", platform="retroarch", files=2, bytes=300)
+for n, (dest, size) in enumerate((("a.bin", 100), ("b.bin", 200)), 1):
+ emit("downloaded", dest=dest, ok=True, reason="", size=size, files_done=n,
+ files_total=2, bytes_done=100 if n == 1 else 300, bytes_total=300)
+(state / "installed").write_text("")
+emit("summary", mode="install", installed=2, up_to_date=1, pending=0, failed=[],
+ skipped=[{"reason": "not_found", "label": "not found", "files": 1, "required": 1}],
+ interrupted=False)
+end(0)
+'''
+
+# PortMasterDialog.txt replaced: every call is a trace line, fields split by
+# \x1f, and each selection list is answered from the answers file.
+DRIVER = r'''
+trace() {
+ printf '%s' "$1"
+ shift
+ local field
+ for field in "$@"; do printf '\x1f%s' "$field"; done
+ printf '\n'
+} >> "$RB_TRACE"
+PM_PID=""
+PM_DONE="$FAKE_STATE/pm_done"
+PortMasterDialogInit() { trace INIT "$@"; }
+PortMasterDialogExit() { trace EXIT; }
+PortMasterIPCheck() { [ -n "$FAKE_IP" ] && echo 192.168.0.1; }
+PortMasterDialog() { trace CMD "$@"; }
+PortMasterDialogResult() {
+ trace ASK "$@"
+ if [ "$1" = "check_runtime" ]; then
+ echo "${FAKE_RUNTIME:-FAIL}"
+ return
+ fi
+ local answer
+ answer=$(head -n 1 "$RB_ANSWERS")
+ sed -i 1d "$RB_ANSWERS"
+ case "$answer" in
+ "") echo CANCEL ;;
+ WAIT:*) printf '%s' "${answer#WAIT:}" > "$PM_DONE"; echo WAIT ;;
+ *) echo "$answer" ;;
+ esac
+}
+source "$GAMEDIR/screens.txt"
+if [ -n "$FAKE_ARCH" ]; then
+ rb_arch() { echo "$FAKE_ARCH"; }
+fi
+rb_main
+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]:
+ work = _tempdir(self)
+ (work / "install.py").write_text(FAKE_INSTALL)
+ (work / "driver.sh").write_text(DRIVER)
+ (work / "answers").write_text("".join(f"{a}\n" for a in answers))
+ bin_dir = work / "bin"
+ bin_dir.mkdir()
+ # 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)
+ self.work = work
+ full_env = {
+ **os.environ,
+ "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
+ "HOME": str(work),
+ "GAMEDIR": str(PORT / "retrobios"),
+ "controlfolder": str(work / "PortMaster"),
+ "ESUDO": "",
+ "RB_STATE": str(work / "run"),
+ "RB_LOG": str(work / "log.txt"),
+ "RB_INSTALL": str(work / "install.py"),
+ "RB_TRACE": str(work / "trace"),
+ "RB_ANSWERS": str(work / "answers"),
+ "FAKE_STATE": str(work),
+ "FAKE_SCENARIO": scenario,
+ "FAKE_KEYS": json.dumps(install.EVENT_KEYS),
+ **env,
+ }
+ proc = subprocess.run(["bash", str(work / "driver.sh")], env=full_env,
+ capture_output=True, text=True, timeout=120, check=False)
+ self.assertEqual(proc.returncode, 0, proc.stderr)
+ log = work / "log.txt"
+ self.assertNotIn("Traceback", log.read_text() if log.exists() else "")
+ return self._screens((work / "trace").read_text(encoding="utf-8"))
+
+ def _screens(self, trace: str) -> list[tuple]:
+ """INIT, EXIT, STATUS, each list shown with its entries, and the
+ progress and message commands, in order."""
+ registers: dict[str, dict[str, tuple[str, str]]] = {}
+ shown: list[tuple] = []
+ for line in trace.splitlines():
+ kind, *fields = line.split("\x1f")
+ for field in fields:
+ self.assertNotRegex(field, r"[*?\[\]\t]", line)
+ if kind == "CMD" and fields[0] == "register_clear":
+ registers[fields[1]] = {}
+ elif kind == "CMD" and fields[0] == "register_set_info":
+ info = dict(item.split(":", 1) for item in fields[3:])
+ registers.setdefault(fields[1], {})[fields[2]] = (
+ info["title"], info["description"])
+ elif kind == "ASK" and fields[0] == "selection_list":
+ self.assertIn("--want-cancel", fields)
+ name = fields[1].split("=", 1)[1]
+ shown.append(("list", name, dict(registers[name])))
+ elif kind == "ASK":
+ shown.append(("ask", *fields))
+ elif kind == "CMD" and fields[0] in ("progress", "message"):
+ shown.append(("cmd", *fields))
+ elif kind in ("INIT", "EXIT", "STATUS"):
+ shown.append((kind, *fields))
+ for screen in shown:
+ if screen[0] == "list":
+ titles = [title for title, _desc in screen[2].values()]
+ self.assertTrue({"Back", "Quit", "Back to the menu"} & set(titles),
+ f"{screen[1]} has no Back or Quit entry")
+ return shown
+
+ def lists(self, shown: list[tuple]) -> list[tuple[str, list[str]]]:
+ return [(s[1], list(s[2])) for s in shown if s[0] == "list"]
+
+ def calls(self) -> list[str]:
+ return (self.work / "calls.txt").read_text().splitlines()
+
+ def test_all_present(self):
+ shown = self.run_port("complete", ["quit"])
+ 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[-2:], [("EXIT",), ("STATUS", "0")])
+ self.assertEqual(self.calls(), ["--check --events"])
+
+ def test_some_missing_update_everything(self):
+ shown = self.run_port("missing", ["update", "back", "quit"])
+ self.assertEqual(self.lists(shown), [
+ ("menu", ["status", "update", "choose", "check", "quit"]),
+ ("summary", ["info:result", "back", "quit", "info:skipped:not found"]),
+ ("menu", ["status", "check", "quit"]),
+ ])
+ menu = shown[[s[0] for s in shown].index("list")][2]
+ self.assertEqual(menu["status"][0], "Fake OS 1.0: 1/3 BIOS files, 1 missing, 1 wrong hash")
+ self.assertIn("Downloads 2 files, 300 B, to /roms/bios", menu["update"][1])
+ progress = [s for s in shown if s[:2] == ("cmd", "progress")]
+ self.assertEqual(progress, [
+ ("cmd", "progress", "Starting", "0", "1"),
+ ("cmd", "progress", "a.bin, 100 B of 300 B", "1", "2"),
+ ("cmd", "progress", "b.bin, 300 B of 300 B", "2", "2"),
+ ])
+ summary = [s for s in shown if s[:2] == ("list", "summary")][0][2]
+ self.assertEqual(summary["info:result"][0], "Installed: 2 files")
+ self.assertEqual(self.calls(), [
+ "--check --events",
+ "--events --platform retroarch",
+ "--check --events --platform retroarch",
+ ])
+
+ def test_some_missing_choose_systems(self):
+ shown = self.run_port("missing", [
+ "choose", "update", "sys:sys-a", "sys:sys-a", "sys:sys-a", "update", "quit"])
+ systems = [s[2] for s in shown if s[:2] == ("list", "systems")]
+ self.assertEqual(list(systems[0]), ["update", "back", "sys:sys-a"])
+ self.assertEqual(systems[0]["sys:sys-a"][0], "Alpha")
+ self.assertEqual(systems[1]["sys:sys-a"][0], "Alpha")
+ self.assertEqual(systems[2]["sys:sys-a"][0], "Alpha (selected)")
+ self.assertEqual(systems[2]["update"][0], "Update the 1 selected")
+ self.assertIn("1 system complete, not listed", systems[0]["back"][1])
+ self.assertEqual(self.calls()[1], "--events --platform retroarch --system sys-a")
+ self.assertEqual(self.lists(shown)[-1][0], "summary")
+ self.assertEqual(shown[-1], ("STATUS", "0"))
+
+ def test_no_network(self):
+ shown = self.run_port("offline", ["quit"])
+ 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: checking only")
+ self.assertIn("Connect to Wi-Fi", notice["info"][1])
+ self.assertIn("/roms/bios", notice["info"][1])
+
+ def test_detection_failure(self):
+ shown = self.run_port("nodetect", ["check", "quit"])
+ self.assertEqual([name for name, _keys in self.lists(shown)], ["notice", "notice"])
+ notice = shown[[s[0] for s in shown].index("list")][2]
+ self.assertEqual(notice["info"][0], "No BIOS folder found")
+ self.assertIn("Fake OS 1.0", notice["info"][1])
+ self.assertIn("--dest", notice["info"][1])
+ self.assertEqual(len(self.calls()), 2)
+
+ def test_python_too_old(self):
+ for arch, env, words in (
+ ("armv7l", {}, ["3.7.3", "aarch64 only", "armv7l", "--dest"]),
+ ("aarch64", {}, ["3.7.3", "needs a connection", "--dest"]),
+ ("aarch64", {"FAKE_IP": "1", "FAKE_RUNTIME": "FAIL"},
+ ["3.7.3", "could not be installed", "--dest"]),
+ ):
+ with self.subTest(arch=arch, env=env):
+ shown = self.run_old(arch, **env)
+ lists = self.lists(shown)
+ self.assertEqual(lists, [("notice", ["info", "quit"])])
+ notice = [s for s in shown if s[0] == "list"][0][2]
+ self.assertEqual(notice["info"][0], "Python 3.8 or newer is needed")
+ for word in words:
+ self.assertIn(word, notice["info"][1])
+ self.assertEqual(shown[-1], ("STATUS", "1"))
+ self.assertFalse((self.work / "calls.txt").exists())
+ asked_runtime = ("ask", "check_runtime", "python_3.11.squashfs") in shown
+ self.assertEqual(asked_runtime, bool(env.get("FAKE_RUNTIME")))
+
+ def run_old(self, arch: str, **env: str) -> list[tuple]:
+ """A python3 on PATH that is 3.7.3, ahead of every other."""
+ holder = _tempdir(self)
+ fake = holder / "python3"
+ fake.write_text('#!/bin/sh\n[ "$1" = "--version" ] && echo "Python 3.7.3"\nexit 1\n')
+ fake.chmod(0o755)
+ return self.run_port("complete", [], FAKE_ARCH=arch,
+ PATH=f"{holder}{os.pathsep}{os.environ['PATH']}", **env)
+
+ def test_an_answer_after_the_old_30_second_wait(self):
+ """2024.02.26 and 8.5.23 stop waiting after 30 s and answer WAIT:
+ the port reads the answer pugwash writes later."""
+ shown = self.run_port("complete", ["WAIT:quit"])
+ self.assertEqual(shown[-1], ("STATUS", "0"))
+ self.assertEqual(len(self.lists(shown)), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index b3a50f9c..3feaf147 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -44,6 +44,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_selection` |
+| Handheld port | stubbed PortMaster dialogs, fake installer | `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_check_freshness`, `test_dedup`, `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_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_site_availability`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_platform_hashes`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
@@ -251,6 +252,23 @@ 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_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.
+
+```bash
+python -m unittest tests.test_port -v
+```
+
## Verification discipline
The test suite is one layer of verification. The full quality gate is: