chore: merge the launcher round 3 fixes

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 21:35:10 +02:00
commit 34fb1a4cbb
7 files changed
+542 -92

No files matched your search

+2 -5
View File
@@ -69,13 +69,10 @@ rb_open_log keep
# muOS: PortMaster's mod_muOS.txt points CUR_TTY at /dev/null, so there is no
# console to draw on. muxterm, the terminal muOS's Terminal core runs, shows
# the interface instead. It is told to leave the pad alone (an SDL hint) so
# that gptokeyb sends every button once, with this port's mapping: muxterm
# sends Y as Space, where the interface reads Y as Options. The hint allows
# one vendor and product id, 0001:0001, which no pad carries.
# the interface instead, told to leave the pad to gptokeyb (rb_muxterm).
RB_MUXTERM="${RB_MUXTERM:-/opt/muos/frontend/muxterm}"
if [ -x "$RB_MUXTERM" ] && [ -n "$RB_MAPPER" ]; then
RB_TERMAPP=(env SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001 "$RB_MUXTERM" --gl --)
rb_muxterm "$RB_MUXTERM"
fi
export SDL_GAMECONTROLLERCONFIG="$sdl_controllerconfig"
+5 -1
View File
@@ -10,9 +10,13 @@ connection, or from a pack copied to the root of the card without one.
It runs in muxterm, muOS's terminal. With PortMaster installed, gptokeyb2
turns every button into a key and muxterm is told to leave the pad alone, so
each button is sent once: D-pad to move, A or Start to choose, B or Select to
go back, X to tick a system, Y for the options, L1 and R1 to page. Hold
go back, X to tick a system, Y for the options. L1 and R1 do nothing in
muxterm, which keeps Page Up and Page Down for its own scrollback. Hold
Select, then press Start to quit. Without PortMaster, muxterm reads the pad
itself (Y then ticks a system) and Menu closes it.
With no button pressed on its first screen for 5 minutes, it closes by
itself: a pad the system cannot read then never leaves it stuck.
Not tried on a device yet: the icon, and whether muxterm honours SDL's
controller allow-list as SDL 2.30 does.
+3 -11
View File
@@ -46,17 +46,9 @@ if [ -x "$PM_DIR/gptokeyb2" ]; then
fi
if [ -x "$MUXTERM" ]; then
RB_ROUTES="app"
if [ -n "${RB_MAPPER:-}" ]; then
# muxterm reads the pad itself and sends Y as Space (MustardOS/terminal
# input.c), where the interface reads Y as Options. The SDL hint allows
# only vendor and product 0001:0001, which no pad carries, so muxterm
# leaves the pad to gptokeyb2 and every button is sent once.
RB_TERMAPP=(env SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001 "$MUXTERM" --gl --)
else
# No gptokeyb2: muxterm's own buttons are all there is.
RB_TERMAPP=("$MUXTERM" --gl --)
RB_PAD="muxterm"
fi
# With gptokeyb2, muxterm leaves the pad to it. Without, muxterm's own
# buttons are all there is.
rb_muxterm "$MUXTERM"
else
RB_ROUTES="vt"
RB_CONSOLE="${RB_CONSOLE:-/dev/tty0}"
+75 -12
View File
@@ -62,7 +62,7 @@ RB_MAPPER_NEEDED="${RB_MAPPER_NEEDED:-1}"
RB_PAD="${RB_PAD:-}"
RB_RUNTIME="python_3.11"
RB_RELEASES="github.com/Abdess/retrobios/releases/latest"
RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer"
RB_PC_ROUTE="On a computer, with this SD card mounted, the same installer fills the card: install.py finds a muOS, Knulli or ROCKNIX card by itself, and any other with install.py --platform NAME --dest BIOS-FOLDER-ON-THE-CARD. Instructions: abdess.github.io/retrobios/wiki/installer"
RB_PYTHON="${RB_PYTHON:-}"
RB_PYTHONHOME="${RB_PYTHONHOME:-}"
RB_PYTHON_PROBLEM="${RB_PYTHON_PROBLEM:-}"
@@ -284,17 +284,17 @@ rb_online() {
}
# The system and BIOS folder install.py finds here, read without a network:
# detection is local, and the run stops where the file list would download.
# That download is sent to a loopback port nothing listens on, so it fails
# at once instead of retrying three times 30 s on a network already found
# missing.
# detection is local, and RETROBIOS_INSTALL_OFFLINE stops the run at once
# where the file list would download (reason offline), instead of three
# tries of 30 s on a network already found missing. Set for this run only:
# the interface's own run reads a pack, which makes no request either.
rb_detect() {
RB_PLATFORM=""
RB_PLATFORM_NAME=""
RB_BIOS_DIR=""
mkdir -p "$RB_STATE"
rb_pycmd
RETROBIOS_BASE_URL="http://127.0.0.1:9" rb_bounded "$RB_DETECT_LIMIT" \
RETROBIOS_INSTALL_OFFLINE=1 rb_bounded "$RB_DETECT_LIMIT" \
"${RB_PYCMD[@]}" "$RB_INSTALL" --check --events > "$RB_STATE/detect.jsonl" 2>> "$RB_LOG"
[ $? = 124 ] && rb_say "the detection run took over $RB_DETECT_LIMIT s and was stopped"
local kind a b c
@@ -415,6 +415,36 @@ rb_terminal_app() {
fi
}
# The only pad SDL may show muxterm: vendor 0, product 1, which no device
# can carry. SDL builds a GUID from the ids only when the vendor is not 0,
# else from the name, and reads a name GUID back as vendor 0 and product 0
# (SDL_CreateJoystickGUID and SDL_GetJoystickGUIDInfo, SDL_joystick.c), so
# a pad plugged in later is hidden too. The first list, 0001:0001, is the
# id gpio-keys gives the H700 pad on muOS before 2026-08-28: every button
# acted twice there.
RB_SDL_NO_PAD="0x0000/0x0001"
# muxterm, muOS's terminal, as the terminal app. It reads the pad itself
# (Y as Space, where the interface reads Y as Options), so with a mapper it
# is told to leave the pad alone and each button is sent once. muxterm also
# keeps PgUp and PgDn for its scrollback and never writes them to the
# interface (MustardOS/terminal input.c): L1 and R1 send nothing there,
# rather than scroll the interface out of sight. Run after rb_open_log.
rb_muxterm() {
if [ -z "${RB_MAPPER:-}" ]; then
RB_TERMAPP=("$1" --gl --)
RB_PAD="muxterm"
return 0
fi
RB_TERMAPP=(env "SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=$RB_SDL_NO_PAD" "$1" --gl --)
if awk '$1 == "l1" || $1 == "r1" { print $1 " = \\\""; next } { print }' \
"$RB_GPTK" > "$RB_STATE/muxterm.gptk" 2>> "$RB_LOG"; then
RB_GPTK="$RB_STATE/muxterm.gptk"
else
rb_say "$RB_STATE/muxterm.gptk cannot be written: L1 and R1 scroll muxterm"
fi
}
# A console a terminal can be read from. PortMaster's mod_muOS.txt sets
# CUR_TTY to /dev/null, a character device that is no console at all. A
# console this user cannot open yet (rb_vt_enter opens it up) is judged by
@@ -581,10 +611,13 @@ rb_vt_leave() {
# wrote. So the target is a sentinel, a process this launcher owns whose
# command line alone carries the token: when it dies, the launcher asks the
# interface to stop, and the interface gives a running install its 2 s.
# It also ends once the launcher is gone, which a SIGKILL leaves no trap
# to say.
rb_stop_start() {
RB_STOP_TOKEN="retrobios-stop-${$}x"
bash -c 'exec {f}<> <(:); while :; do read -r -t 3600 -u "$f" _; done' \
"$RB_STOP_TOKEN" > /dev/null 2>&1 &
# shellcheck disable=SC2016 # expanded by the inner bash
bash -c 'exec {f}<> <(:); while [ -d "/proc/$1" ]; do read -r -t 5 -u "$f" _; done' \
"$RB_STOP_TOKEN" "$$" > /dev/null 2>&1 &
RB_STOP_PID=$!
}
@@ -628,8 +661,26 @@ rb_mapper_stop() {
rb_stop_end
}
# The mapper's own name, gptokeyb or gptokeyb2, past sudo and its options.
rb_mapper_name() {
local word
# shellcheck disable=SC2086 # RB_MAPPER is a command and its flags
for word in $RB_MAPPER; do
case "$word" in
sudo|-*|*=*) ;;
*) printf '%s\n' "${word##*/}"; return 0 ;;
esac
done
printf 'gptokeyb\n'
}
# "run" when the mapper stopped during the run: the interface just went.
rb_mapper_text() {
printf '%s\n' "The controller cannot be read: gptokeyb, which turns the buttons into keys, stopped as soon as it started. What it said is in $RB_LOG. $RB_PC_ROUTE"
if [ "${1:-}" = "run" ]; then
printf '%s\n' "The controller stopped responding: $(rb_mapper_name), which turns the buttons into keys, stopped during the run, so RetroBIOS closed. What it said is in $RB_LOG. Start RetroBIOS again."
return 0
fi
printf '%s\n' "The controller cannot be read: $(rb_mapper_name), which turns the buttons into keys, stopped as soon as it started. What it said is in $RB_LOG. $RB_PC_ROUTE"
}
# --- Messages -------------------------------------------------------------
@@ -816,6 +867,10 @@ rb_here() {
if [ "${RB_FALLBACK:-0}" != "1" ]; then
rb_message "$(tail -n 1 "$RB_STATE/tui.err") The messages are in $RB_LOG."
fi
elif [ "$RB_STOPPED" = "mapper" ]; then
# The interface disappeared: why, for RB_DEAD_WAIT seconds, since no
# button is left to close it.
rb_message "$(rb_mapper_text run)"
elif [ -n "$RB_STOPPED" ] || [ "$status" -ge 128 ]; then
# Ended by Start + Select or a signal: the player asked to leave, and no
# button may be left to close a message.
@@ -891,6 +946,9 @@ rb_launch() {
printf '%s\n' "$RB_STATE_PROBLEM" >&2
if rb_is_console "$RB_CONSOLE"; then
printf '\r\n%s\r\n' "$RB_STATE_PROBLEM" > "$RB_CONSOLE" 2>/dev/null
# No mapper runs yet to close it with a button: left in view a while
# before the menu draws over it.
rb_nap "$RB_DEAD_WAIT"
fi
return 1
fi
@@ -898,6 +956,10 @@ rb_launch() {
export RB_DIR RB_INSTALL RB_EVENTS RB_STATE RB_LOG RB_PACK_PATH RB_FALLBACK
export RB_OTHER_CARDS RB_BIOS_DEST RB_STOP_GRACE RB_DEAD_WAIT RB_DETECT_LIMIT
export RB_PYTHON RB_PYTHONHOME RB_PYTHON_PROBLEM RB_MESSAGE_WAIT RB_ROUTE_TABLE
# A mapper that runs but never opens the pad (one SDL has no mapping for)
# leaves no button to move the interface or end the run: it closes by
# itself after this many seconds on its first screen with nothing pressed.
export RETROBIOS_IDLE_EXIT="${RETROBIOS_IDLE_EXIT:-300}"
# The interface names the buttons of this route in its hints.
if [ -z "$RB_PAD" ] && [ -n "${RB_MAPPER:-}" ]; then
RB_PAD="gptokeyb"
@@ -931,9 +993,10 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
case "${1:-}" in
here)
shift
# The terminal app closed or the launcher signalled: the interface
# gets its time to stop.
trap 'rb_ui_stop; exit 143' TERM HUP
# The terminal app closed (muxterm's Menu) or the launcher signalled:
# the interface gets its time to stop, and the status says the run
# ended, so the launcher opens no other route after it.
trap 'rb_ui_stop; printf "143\n" > "$RB_STATE/status"; exit 143' TERM HUP
rb_here "$@"
exit $?
;;
+10 -2
View File
@@ -82,8 +82,16 @@ else
# shellcheck source=/dev/null
source "$control"
declare -F get_controls >/dev/null && get_controls
# It carries its own kill flag (-sudokill).
[ -n "${GPTOKEYB:-}" ] && mapper="$GPTOKEYB" kill_flag=""
# Its kill flag is -sudokill (control-gen.cpp:31), which kills through
# sudo, and ROCKNIX's sudo is a stub that runs nothing (busybox
# scripts/sudo): Start + Select would end the run only 3 s later,
# through the mapper's exit. -1 kills as the user it runs as, root.
if [ -n "${GPTOKEYB:-}" ]; then
mapper="$GPTOKEYB"
case "$mapper" in
*" -sudokill"|*" -1"|*" -k") mapper="${mapper% *}" ;;
esac
fi
ESUDO="$saved_esudo" controlfolder="$saved_folder"
fi
fi
+437 -57
View File
@@ -177,13 +177,100 @@ class ControllerMappings(unittest.TestCase):
targets = {a["trigger"]: a["target"] for a in keys["actions_player1"]}
self.assertEqual((targets["x"], targets["y"]), (["KEY_SPACE"], ["KEY_TAB"]))
# muxterm sends Y as Space on its own (MustardOS/terminal input.c):
# where gptokeyb runs beside it, muxterm is told to leave the pad.
# where gptokeyb runs beside it, muxterm is told to leave the pad
# (assert_muxterm_hides_every_pad).
for launcher in (PORTS / "muos" / "mux_launch.sh", PORTS / "RetroBIOS" / "RetroBIOS.sh"):
text = launcher.read_text()
self.assertIn("SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT=0x0001/0x0001", text)
self.assertIn("rb_muxterm ", launcher.read_text())
self.assertFalse((PORTS / "muos" / "muxterm.gptk").exists())
def sdl_vidpid(bus: int, vendor: int, product: int, version: int, name: str) -> tuple[int, int]:
"""The vendor and product SDL 2 reads back from the GUID it builds for
a device: SDL_CreateJoystickGUID, then SDL_GetJoystickGUIDInfo
(libsdl-org/SDL release-2.30.x src/joystick/SDL_joystick.c). A vendor of
0 makes the GUID carry the name instead of the ids."""
data = bytearray(16)
struct.pack_into("<HH", data, 0, bus, 0x5a5a)
if vendor:
struct.pack_into("<HHHHH", data, 4, vendor, 0, product, 0, version)
else:
raw = name.encode()[:11]
data[4:4 + len(raw)] = raw
words = struct.unpack("<8H", bytes(data))
if (words[0] < 0x20 or words[0] == 0xFF) and words[3] == 0 and words[5] == 0:
return words[2], words[4]
return 0, 0
def sdl_vidpid_list(hint: str) -> list[int]:
"""SDL_LoadVIDPIDListFromHint: every pair of 0x numbers."""
entries = []
for vendor, product in re.findall(r"0x([0-9a-fA-F]+)[^x]*?0x([0-9a-fA-F]+)", hint):
entries.append((int(vendor, 16) & 0xFFFF) << 16 | (int(product, 16) & 0xFFFF))
return entries
# Input devices a muOS device lists in /proc/bus/input/devices: the H700
# pad before 2026-08-28 (gpio-keys, 0001:0001, MustardOS internal
# f6a14e3121 device/rg35xx-plus/control/gamecontrollerdb/modern.txt), the
# muinput pad after it (0x756d), a USB and a Bluetooth pad, gptokeyb2's
# uinput keyboard, and pads whose vendor is 0.
PADS = [
(0x19, 0x0001, 0x0001, 0x0100, "Deeplay-keys"),
(0x19, 0x0001, 0x0001, 0x0100, "muOS-Keys"),
(0x06, 0x756d, 0x6b6d, 0x0001, "muOS-Keys"),
(0x03, 0x045e, 0x028e, 0x0114, "Microsoft X-Box 360 pad"),
(0x05, 0x057e, 0x2009, 0x8111, "Pro Controller"),
(0x06, 0x0000, 0x0000, 0x0000, "Fake Keyboard"),
]
PADS += [(bus, 0, product, 1, "pad"[:length] + "x" * max(0, length - 3))
for bus in (0x03, 0x05, 0x06, 0x19, 0xFF) for product in (0, 1, 0x2009)
for length in range(13)]
PADS += [(bus, vendor, product, 1, "pad")
for bus in (0x03, 0x05, 0x19) for vendor in (1, 0x0079, 0xFFFF)
for product in (0, 1, 0xFFFF)]
def assert_muxterm_hides_every_pad(test: unittest.TestCase, hint: str) -> None:
"""muxterm reads the pad itself (T/input.c:303-317) while gptokeyb2
sends its keys, so every button would act twice. The launchers set
SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT, which hides from SDL every
device it does not name (SDL_gamecontroller.c:2145-2150, applied to the
joystick layer by SDL_ShouldIgnoreJoystick, SDL_joystick.c:3006)."""
allowed = sdl_vidpid_list(hint)
# An empty list allows every device.
test.assertTrue(allowed, hint)
for pad in PADS:
vendor, product = sdl_vidpid(*pad)
with test.subTest(pad=pad):
test.assertNotIn(vendor << 16 | product, allowed)
def assert_muxterm_mapping(test: unittest.TestCase, args: list[str], shared: Path) -> None:
"""muxterm keeps PgUp and PgDn for its scrollback and never writes them
to the terminal (T/input.c:352-360): in muxterm, L1 and R1 send nothing,
so they cannot scroll the interface away. Every other button is
retrobios.gptk's."""
test.assertEqual(args[-2], "-c")
mapping = read_gptk(Path(args[-1]))
expected = read_gptk(shared)
expected["l1"] = expected["r1"] = ['\\"']
test.assertEqual(mapping, expected)
class TheSdlModel(unittest.TestCase):
def test_it_reads_the_ids_as_sdl_does(self):
self.assertEqual(sdl_vidpid(0x19, 1, 1, 0x100, "Deeplay-keys"), (1, 1))
self.assertEqual(sdl_vidpid(0x05, 0, 0x2009, 1, "pad"), (0, 0))
self.assertEqual(sdl_vidpid(0x05, 0, 0x2009, 1, "ab"), (0x6261, 0))
self.assertEqual(sdl_vidpid_list("0x0001/0x0001, 0x045e/0x028e"),
[0x00010001, 0x045e028e])
def test_the_first_hint_allowed_the_h700_pad(self):
with self.assertRaises(AssertionError):
assert_muxterm_hides_every_pad(unittest.TestCase(), "0x0001/0x0001")
class TheArchives(unittest.TestCase):
LAYOUT = {
build_port.PORT_ZIP: ("RetroBIOS.sh", "retrobios"),
@@ -300,6 +387,8 @@ if "--engine" in sys.argv:
signal.signal(signal.SIGINT, summary)
signal.signal(signal.SIGTERM, other)
with open(os.path.join(state, "engine.pid"), "w") as fh:
fh.write(str(os.getpid()))
note("engine.ready")
time.sleep(60)
sys.exit(0)
@@ -309,17 +398,32 @@ with open(calls, "a") as fh:
"stdout": tty(1), "term": os.environ.get("TERM", ""),
"pythonhome": os.environ.get("PYTHONHOME", ""),
"base_url": os.environ.get("RETROBIOS_BASE_URL", ""),
"offline": os.environ.get("RETROBIOS_INSTALL_OFFLINE", ""),
"idle_exit": os.environ.get("RETROBIOS_IDLE_EXIT"),
"pad": os.environ.get("RETROBIOS_PAD")}) + "\n")
if "--check" in sys.argv:
# install.py --check --events: detection is local, then the file list.
# With RETROBIOS_INSTALL_OFFLINE the file list stops the run at once
# with reason offline. Otherwise it is downloaded: a closed loopback
# port refuses at once, any other address is three tries of 30 s on a
# device without a network (FAKE_CURL, the probe that found it so).
offline = os.environ.get("RETROBIOS_INSTALL_OFFLINE", "") not in ("", "0")
base_url = os.environ.get("RETROBIOS_BASE_URL", "")
no_network = os.environ.get("FAKE_CURL", "0") in ("5", "6", "7", "28")
if os.environ.get("FAKE_DETECT_HANG"):
time.sleep(60)
print(json.dumps({"event": "start", "mode": "check"}), flush=True)
print(json.dumps({"event": "detect", "os": "linux", "os_name": "Fake OS",
"platforms": [{"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"name": os.environ.get("FAKE_PLATFORM_NAME", "RetroArch"),
"bios_dir": "/roms/bios"}]}))
print(json.dumps({"event": "error", "stage": "manifest", "reason": "network",
"bios_dir": "/roms/bios"}]}), flush=True)
if no_network and not offline and not base_url.startswith("http://127.0.0.1"):
time.sleep(90)
print(json.dumps({"event": "error", "stage": "manifest",
"reason": "offline" if offline else "network",
"platform": os.environ.get("FAKE_PLATFORM", "retroarch"),
"message": "unreachable"}))
"message": "this run makes no network request (--offline)" if offline
else "unreachable"}))
print(json.dumps({"event": "end", "code": 1}))
sys.exit(1)
if os.environ.get("FAKE_REFUSE"):
@@ -329,6 +433,21 @@ if os.environ.get("FAKE_REFUSE"):
if os.environ.get("FAKE_RAW") and os.isatty(0):
import tty as _tty
_tty.setraw(0)
if os.environ.get("FAKE_DRAIN") and os.isatty(0):
# What the console still holds when the interface starts: bytes a
# question left behind are read as the interface's first keys.
# TCSANOW, as install.py sets its mode: TCSAFLUSH would discard them.
import select as _select
import termios as _termios
import tty as _tty
_tty.setraw(0, _termios.TCSANOW)
left = b""
while _select.select([0], [], [], 0.3)[0]:
chunk = os.read(0, 64)
if not chunk:
break
left += chunk
note("ui.input", left.hex())
if os.environ.get("FAKE_HOLD"):
# The interface up and waiting for a key until a signal ends it. With
# FAKE_ENGINE an install runs, and SIGTERM gives it 2 s to say what it
@@ -354,9 +473,14 @@ if os.environ.get("FAKE_HOLD"):
print("Error: " + os.environ["FAKE_TERM_ERROR"], file=sys.stderr)
sys.exit(128 + signum)
if not os.environ.get("FAKE_NO_HANDLER"):
if os.environ.get("FAKE_IGNORE_TERM"):
# An interface stuck where its signal handler never runs.
signal.signal(signal.SIGTERM, signal.SIG_IGN)
elif not os.environ.get("FAKE_NO_HANDLER"):
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGHUP, stop)
with open(os.path.join(state, "ui.pid"), "w") as fh:
fh.write(str(os.getpid()))
note("ui.ready")
time.sleep(60)
sys.exit(0)
@@ -371,12 +495,13 @@ PGREP = shutil.which("pgrep", path="/usr/bin:/bin") or "pgrep"
PKILL = shutil.which("pkill", path="/usr/bin:/bin") or "pkill"
# gptokeyb as PortsMaster/gptokeyb runs it: kill mode takes the argument
# after the flag (`-1`, `-k` or `-sudokill`) as its target. Start + Select (FAKE_PRESS, once the
# interface is up) is doKillMode in src/util.cpp: pkill -f on the target,
# which also matches gptokeyb's own command line (SDL turns that SIGTERM
# into an event it does not read there), 3 s, then pkill -9 -f and exit.
# Stopped otherwise by a SIGTERM, which it records. FAKE_MAPPER_DIES is a
# mapper that cannot start (no /dev/uinput).
# after the flag (`-1`, `-k` or `-sudokill`) as its target. Start + Select
# (FAKE_PRESS, once FAKE_PRESS_AT exists, by default once the interface is
# up) is doKillMode in src/util.cpp: pkill -f on the target, which also
# matches gptokeyb's own command line (SDL turns that SIGTERM into an event
# it does not read there), 3 s, then pkill -9 -f and exit. `-sudokill` runs
# both through sudo. Stopped otherwise by a SIGTERM, which it records.
# FAKE_MAPPER_DIES is a mapper that cannot start (no /dev/uinput).
FAKE_MAPPER = f'''#!/bin/bash
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
@@ -385,32 +510,39 @@ if [ -n "${{FAKE_MAPPER_DIES:-}}" ]; then
echo "gptokeyb: cannot open /dev/uinput" >&2
exit 1
fi
name=""
name="" sudo=""
while [ $# -gt 0 ]; do
case "$1" in
-1|-k|-sudokill) name="$2"; shift 2 ;;
-1|-k) name="$2"; shift 2 ;;
-sudokill) name="$2" sudo="sudo"; shift 2 ;;
-c) shift 2 ;;
*) shift ;;
esac
done
killing=""
trap '[ -n "$killing" ] || {{ echo stopped > "$FAKE_STATE/mapper.stopped"; exit 0; }}' TERM
until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/ui.ready" ]; do sleep 0.1; done
until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/${{FAKE_PRESS_AT:-ui.ready}}" ]; do sleep 0.1; done
killing=1
echo "kill $name" >> "$FAKE_STATE/mapper.log"
{PKILL} -f "$name"
$sudo {PKILL} -f "$name"
sleep 3
if {PGREP} -f "$name" > /dev/null; then {PKILL} -9 -f "$name"; fi
if {PGREP} -f "$name" > /dev/null; then $sudo {PKILL} -9 -f "$name"; fi
exit 0
'''
# gptokeyb2 as PortsMaster/gptokeyb2 src/util.c kills: SIGKILL only, by
# process name (pkill -9 NAME) unless -X, then pgrep -f and kill -9 on every
# match but its own ancestry and children. It quits after a kill, and keeps
# running when it has no process name.
# running when it has no process name. FAKE_MAPPER_STAYS is gptokeyb2
# before 2026-10-05, which keeps running after a -X kill (an inner
# `int status` shadows the result, 23581b62ec src/util.c:486-519).
FAKE_GPTOKEYB2 = f'''#!/bin/bash
printf '%s\\n' "$@" > "$FAKE_STATE/mapper.args"
echo $$ > "$FAKE_STATE/mapper.pid"
if [ -n "${{FAKE_MAPPER_DIES:-}}" ]; then
echo "gptokeyb2: cannot open /dev/uinput" >&2
exit 1
fi
want_kill="" name=""
while [ $# -gt 0 ]; do
case "$1" in
@@ -421,7 +553,7 @@ while [ $# -gt 0 ]; do
esac
done
trap 'echo stopped > "$FAKE_STATE/mapper.stopped"; exit 0' TERM
until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/ui.ready" ]; do sleep 0.1; done
until [ -n "${{FAKE_PRESS:-}}" ] && [ -e "$FAKE_STATE/${{FAKE_PRESS_AT:-ui.ready}}" ]; do sleep 0.1; done
echo "kill $name" >> "$FAKE_STATE/mapper.log"
if [ -z "$name" ]; then
while :; do sleep 0.1; done
@@ -442,9 +574,20 @@ if [ -n "$want_kill" ]; then
else
{PKILL} -9 "$name"
fi
if [ -n "${{FAKE_MAPPER_STAYS:-}}" ]; then
while :; do sleep 0.1; done
fi
exit 0
'''
# ROCKNIX's /usr/bin/sudo: a stub that runs nothing (ROCKNIX distribution
# packages/sysutils/busybox/scripts/sudo).
ROCKNIX_SUDO = """#!/bin/sh
printf '%s\\n' "sudo $*" >> "$FAKE_STATE/tools.log"
echo " You are 'root' so 'sudo' is not required or supported"
exit 1
"""
FAKE_RECORDER = """#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
"""
@@ -472,6 +615,8 @@ done
FAKE_TERMAPP = """#!/bin/sh
printf '%s\\n' "$(basename "$0") $*" >> "$FAKE_STATE/tools.log"
printf '%s\\n' "${SDL_GAMECONTROLLER_IGNORE_DEVICES_EXCEPT:-}" > "$FAKE_STATE/termapp.sdl"
printf '%s\\n' "${SDL_GAMECONTROLLER_IGNORE_DEVICES:-}" > "$FAKE_STATE/termapp.sdl_ignore"
echo $$ > "$FAKE_STATE/termapp.pid"
if [ -n "$FAKE_TERM_FAILS" ]; then
echo "$(basename "$0"): Can't open display" >&2
exit 1
@@ -540,7 +685,8 @@ class Device:
(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",
"XAUTHORITY", "XDG_RUNTIME_DIR")}
"RETROBIOS_INSTALL_OFFLINE", "RETROBIOS_IDLE_EXIT",
"RETROBIOS_PAD", "XAUTHORITY", "XDG_RUNTIME_DIR")}
self.env = {
**environ,
"PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}",
@@ -621,7 +767,7 @@ class Device:
def _reap(self) -> None:
"""A fake the launcher failed to stop must not outlive the test."""
for name in ("mapper.pid",):
for name in ("mapper.pid", "ui.pid", "engine.pid"):
path = self.state / name
if path.exists():
try:
@@ -671,6 +817,29 @@ class Device:
self.test.assertEqual(termios.tcgetattr(self.slave), self.before)
def sentinels(token: str) -> list[int]:
"""The Start + Select sentinels of a run: `bash -c SCRIPT TOKEN`, where
the mapper carries the token after its own path."""
found = []
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
try:
argv = (entry / "cmdline").read_bytes().split(b"\0")
except OSError:
continue
if len(argv) > 3 and argv[1] == b"-c" and argv[3] == token.encode():
found.append(int(entry.name))
return found
def wait_until(test: unittest.TestCase, check, timeout: float, what: str) -> None:
end = time.monotonic() + timeout
while not check():
test.assertLess(time.monotonic(), end, what)
time.sleep(0.1)
CONTROL_TXT = '''
directory="{roms}"
ESUDO=""
@@ -727,6 +896,7 @@ class ThePortMasterPort(unittest.TestCase):
installer = str(self.gamedir / "install.py")
self.assertEqual(call["argv0"], installer)
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
# Kill mode on the Start + Select sentinel, never on the installer's
# path, which the engine's command line carries too.
args = device.mapper_args()
@@ -734,6 +904,7 @@ class ThePortMasterPort(unittest.TestCase):
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
self.assertEqual(args[2:], ["-c", str(self.gamedir / "retrobios.gptk")])
device.assert_mapper_stopped()
self.assertEqual(sentinels(args[1]), [])
device.assert_console_restored()
# Reset when the console is taken and again when it is handed back.
self.assertEqual(device.screen.count("\x1bc"), 2)
@@ -790,9 +961,8 @@ class ThePortMasterPort(unittest.TestCase):
device.assert_console_restored()
def test_offline_says_so_at_once_and_bounds_the_detection(self):
"""The file-list download is three tries of 30 s: the detection run
is pointed at a closed loopback port and stopped after
RB_DETECT_LIMIT, with a line on the console from the start."""
"""A detection run that hangs is stopped after RB_DETECT_LIMIT, with
a line on the console from the start."""
device = self.device(FAKE_CURL="28")
pack = self.gamedir / "RetroArch_Lakka_v1.22.2_BIOS_Pack.zip"
pack.write_bytes(b"")
@@ -802,12 +972,35 @@ class ThePortMasterPort(unittest.TestCase):
screen = device.screen
self.assertIn("Checking the connection", screen)
self.assertIn("No connection. Looking for the BIOS folder", screen)
[check] = [c for c in device.calls() if "--check" in c["args"]]
self.assertEqual(check["base_url"], "http://127.0.0.1:9")
[call] = device.tui_calls()
# The detection was stopped: no platform, the first pack.
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack)])
self.assertEqual(call["base_url"], "")
log = (self.gamedir / "log.txt").read_text()
self.assertIn("the detection run took over 2 s and was stopped", log)
def test_the_detection_is_told_to_make_no_request(self):
"""RETROBIOS_INSTALL_OFFLINE: install.py detects, then stops where
the file list would download, at once and with reason offline
(install.py _network_allowed). The interface run does not inherit
it, and no address is borrowed for the detection."""
device = self.device(FAKE_CURL="7")
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())
self.assertLess(device.elapsed, 15)
[check] = [c for c in device.calls() if "--check" in c["args"]]
self.assertEqual(check["args"], ["--check", "--events"])
self.assertEqual((check["offline"], check["base_url"]), ("1", ""))
detected = (self.gamedir / "run" / "detect.jsonl").read_text().splitlines()
self.assertEqual([json.loads(line)["event"] for line in detected],
["start", "detect", "error", "end"])
self.assertEqual(json.loads(detected[2])["reason"], "offline")
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui", "--from-pack", str(pack),
"--platform", "retroarch"])
self.assertEqual((call["offline"], call["base_url"]), ("", ""))
self.assertNotIn("took over", (self.gamedir / "log.txt").read_text())
def test_a_certificate_failure_is_not_offline(self):
# curl 60: the network answered, install.py reports the certificate.
@@ -955,9 +1148,11 @@ class ThePortMasterPort(unittest.TestCase):
[call] = device.tui_calls()
self.assertEqual(call["args"], ["--tui"])
self.assertEqual(device.tools(), [f"muxterm --gl -- bash {self.gamedir}/tui.txt here"])
self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"])
self.assertEqual(device.mapper_args()[2:], ["-c", str(self.gamedir / "retrobios.gptk")])
[hint] = device.lines("termapp.sdl")
assert_muxterm_hides_every_pad(self, hint)
assert_muxterm_mapping(self, device.mapper_args(), self.gamedir / "retrobios.gptk")
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
self.assertEqual(self.dialog(device), ["FINISH"])
def test_on_muos_without_muxterm_the_log_says_why_the_screens_open(self):
@@ -1027,13 +1222,73 @@ class ThePortMasterPort(unittest.TestCase):
"""Nothing moves the interface once gptokeyb is gone: it is asked to
stop instead of waiting for a key forever."""
device = self.device()
proc = device.spawn(["bash", self.script], FAKE_HOLD="1")
proc = device.spawn(["bash", self.script], FAKE_HOLD="1", RB_DEAD_WAIT="1",
RB_MESSAGE_WAIT="300")
device.wait_for("ui.ready")
os.kill(int((device.state / "mapper.pid").read_text()), signal.SIGKILL)
self.assertEqual(proc.wait(timeout=20), 143)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
# The interface disappears: the console says why, for RB_DEAD_WAIT.
screen = " ".join(device._drain().split())
self.assertIn("gptokeyb, which turns the buttons into keys, stopped during the run",
screen)
device.assert_console_restored()
def test_start_select_with_an_interface_that_ignores_sigterm(self):
"""An interface that does not stop within RB_STOP_GRACE is killed,
and the engine it started in its own session with it."""
device = self.device()
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_ENGINE="1",
FAKE_IGNORE_TERM="1", FAKE_PRESS="1", RB_STOP_GRACE="1",
RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 137, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
engine = int((device.state / "engine.pid").read_text())
wait_until(self, lambda: not Path(f"/proc/{engine}").exists(), 5,
"the engine outlived the interface")
log = (self.gamedir / "log.txt").read_text()
self.assertIn("the interface did not stop within 1 s", log)
device.assert_console_restored()
def test_an_interface_that_ignores_sigterm_without_pkill(self):
"""With no working pkill, the engines cannot be found: the interface
itself is still killed after RB_STOP_GRACE."""
device = self.device()
device.tool("pkill", "#!/bin/sh\necho 'pkill: applet not found' >&2\nexit 127\n")
proc = device.run(["bash", self.script], FAKE_HOLD="1", FAKE_IGNORE_TERM="1",
FAKE_PRESS="1", RB_STOP_GRACE="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 137, proc.stderr.decode())
self.assertLess(device.elapsed, 15)
ui = int((device.state / "ui.pid").read_text())
self.assertFalse(Path(f"/proc/{ui}").exists())
def test_the_sentinel_ends_when_the_launcher_is_killed(self):
"""SIGKILL leaves the launcher no trap: the sentinel ends by itself
once the launcher is gone."""
device = self.device()
proc = device.spawn(["bash", self.script], FAKE_HOLD="1")
device.wait_for("ui.ready")
token = device.mapper_args()[1]
self.assertEqual(len(sentinels(token)), 1)
proc.kill()
proc.wait(timeout=5)
wait_until(self, lambda: not sentinels(token), 10, "the sentinel outlived the launcher")
def test_a_closed_terminal_ends_the_run(self):
"""The terminal app closed (muxterm's Menu, foot's window): the run
inside it stops the interface and ends the run, and no other route
or screen opens after it."""
device = self.device(WAYLAND_DISPLAY="wayland-1")
device.tool("foot", FAKE_TERMAPP)
proc = device.spawn(["bash", self.script], FAKE_HOLD="1")
device.wait_for("ui.ready")
# FAKE_TERMAPP execs its command: its pid is the run inside it.
os.kill(int((device.state / "termapp.pid").read_text()), signal.SIGHUP)
self.assertEqual(proc.wait(timeout=20), 143)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
self.assertEqual(self.dialog(device), ["FINISH"])
device.assert_mapper_stopped()
def test_sigterm_to_the_launcher_stops_the_interface_and_restores(self):
"""A firmware that signals the launcher alone: the trap stops the
interface, the mapper and puts the console back."""
@@ -1086,10 +1341,12 @@ class ThePortMasterPort(unittest.TestCase):
runtime = device.root / "runtime"
runtime.mkdir(mode=0o555)
self.addCleanup(runtime.chmod, 0o755)
proc = device.run(["bash", self.script])
proc = device.run(["bash", self.script], RB_DEAD_WAIT="2")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.calls(), [])
self.assertIn("the SD card may be read-only or full", " ".join(device.screen.split()))
# Left on the console long enough to read before the menu redraws.
self.assertGreaterEqual(device.elapsed, 2)
def test_the_previous_log_is_kept(self):
device = self.device()
@@ -1125,6 +1382,7 @@ class TheBatoceraPort(unittest.TestCase):
# evmapy reads RetroBIOS.sh.keys: no gptokeyb is started.
self.assertEqual(device.mapper_args(), [])
self.assertEqual(call["pad"], "evmapy")
self.assertEqual(call["idle_exit"], "300")
def test_the_x_cookie_goes_with_the_display(self):
"""S31emulationstation starts X with XAUTHORITY=/var/lib/.Xauthority:
@@ -1218,13 +1476,15 @@ class TheMuosApp(unittest.TestCase):
f"muxterm --gl -- bash {app}/tui.txt here"])
# muxterm leaves the pad to gptokeyb2, which sends each button once
# with the interface's mapping: Y is Tab (Options), not muxterm's Space.
self.assertEqual(device.lines("termapp.sdl"), ["0x0001/0x0001"])
[hint] = device.lines("termapp.sdl")
assert_muxterm_hides_every_pad(self, hint)
args = device.mapper_args()
self.assertEqual(args[0], "-X")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
self.assertEqual(args[2:], ["-c", str(app / "retrobios.gptk")])
self.assertEqual(read_gptk(app / "retrobios.gptk")["y"], ["tab"])
assert_muxterm_mapping(self, args, app / "retrobios.gptk")
self.assertEqual(read_gptk(Path(args[-1]))["y"], ["tab"])
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
device.assert_mapper_stopped()
def test_muxterm_alone_without_gptokeyb2(self):
@@ -1233,6 +1493,7 @@ class TheMuosApp(unittest.TestCase):
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertEqual(call["pad"], "muxterm")
self.assertEqual(call["idle_exit"], "300")
# muxterm then reads the pad itself.
self.assertEqual(device.lines("termapp.sdl"), [""])
self.assertEqual(device.mapper_args(), [])
@@ -1257,6 +1518,54 @@ class TheMuosApp(unittest.TestCase):
self.assertEqual(device.lines("ui.log"), ["signal 15"])
device.assert_console_restored()
def test_start_select_with_a_gptokeyb2_that_keeps_running(self):
"""gptokeyb2 before 2026-10-05 keeps running after its -X kill: only
the sentinel's end says Start + Select was pressed."""
device, app = self.device(muxterm=True)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_HOLD="1",
FAKE_PRESS="1", FAKE_MAPPER_STAYS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
self.assertIn("Start + Select", (app / "log.txt").read_text())
device.assert_mapper_stopped()
def test_a_gptokeyb2_that_cannot_start_is_named(self):
device, app = self.device(muxterm=False)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_MAPPER_DIES="1",
RB_CONSOLE=device.console, RB_DEAD_WAIT="1")
self.assertEqual(proc.returncode, 1)
self.assertEqual(device.tui_calls(), [])
screen = " ".join(device.screen.split())
self.assertIn("gptokeyb2, which turns the buttons into keys, stopped as soon as it "
"started", screen)
def test_an_arrow_at_the_question_leaves_nothing_behind(self):
"""An arrow is three bytes: the first answers the question, the rest
must not reach the interface as two more keys."""
device, app = self.device(muxterm=False)
_sd1, sd2, _pack = self.two_cards(device)
proc = device.run([str(app / "mux_launch.sh"), str(app)], keys="\x1b[B",
FAKE_CURL="7", FAKE_DRAIN="1", RB_CONSOLE=device.console,
RB_BIOS_DEST=str(sd2))
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
[call] = device.tui_calls()
self.assertNotIn("--from", call["args"])
self.assertEqual(device.lines("ui.input"), [""])
def test_start_select_at_the_question_starts_no_interface(self):
device, app = self.device(muxterm=False)
_sd1, sd2, _pack = self.two_cards(device)
proc = device.run([str(app / "mux_launch.sh"), str(app)], FAKE_CURL="7",
FAKE_PRESS="1", FAKE_PRESS_AT="calls.jsonl", RB_MESSAGE_WAIT="300",
RB_CONSOLE=device.console, RB_BIOS_DEST=str(sd2))
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.tui_calls(), [])
# Never started: an interface started then stopped at once would
# end the same way, before Python records anything.
self.assertFalse((app / "run" / "tui.err").exists())
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)
@@ -1376,6 +1685,7 @@ class TheToolsEntry(unittest.TestCase):
[call] = device.tui_calls()
self.assertEqual((call["stdin"], call["term"]), (device.console, "linux"))
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
args = device.mapper_args()
self.assertEqual(args[0], "-1")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
@@ -1454,26 +1764,11 @@ class TheToolsEntry(unittest.TestCase):
etc = device.root / "fs" / "etc"
etc.mkdir(parents=True)
(etc / "os-release").write_text('NAME="ROCKNIX"\n')
device.tool("sudo", ROCKNIX_SUDO)
def test_rocknix_runs_in_the_terminal_tools_opens(self):
device, tools = self.device()
self.rocknix(device)
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")
self.assertEqual(call["pad"], "gptokeyb")
# 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")], [])
def test_rocknix_loads_the_controller_list_its_own_tools_load(self):
"""control-gen_init.sh writes control.ini, which names the pad's
controller list and the GPTOKEYB to start (ROCKNIX fex-emu's Start
FEXConfig.sh). Its ESUDO is not this launcher's."""
device, tools = self.device()
self.rocknix(device)
def control_ini(self, device: Device) -> None:
"""control-gen_init.sh writes control.ini as control-gen.cpp:31
does: `ESUDOKILL="-sudokill"`, and GPTOKEYB with it."""
folder = device.root / "fs" / "storage" / ".config" / "gptokeyb"
shutil.copy(device.bin / "gptokeyb", device.root / "gptokeyb-rocknix")
device.tool("control-gen_init.sh", f'''#!/bin/sh
@@ -1490,15 +1785,57 @@ param_device="fake pad"
GPTOKEYB="$controlfolder/gptokeyb-rocknix $ESUDOKILL"
INI
''')
def test_rocknix_runs_in_the_terminal_tools_opens(self):
device, tools = self.device()
self.rocknix(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
args = device.mapper_args()
self.assertEqual(args[0], "-sudokill")
self.assertRegex(args[1], r"^retrobios-stop-\d+x$")
[call] = device.tui_calls()
self.assertEqual((call["stdin"], call["stdout"]), (device.console, device.console))
self.assertEqual(call["term"], "xterm")
self.assertEqual(call["pad"], "gptokeyb")
self.assertEqual(call["idle_exit"], "300")
# 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")], [])
def test_rocknix_loads_the_controller_list_its_own_tools_load(self):
"""control-gen_init.sh writes control.ini, which names the pad's
controller list and the GPTOKEYB to start (ROCKNIX fex-emu's Start
FEXConfig.sh). Its ESUDO is not this launcher's."""
device, tools = self.device()
self.rocknix(device)
self.control_ini(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm")
self.assertEqual(proc.returncode, 0, proc.stderr.decode())
self.assertEqual(device.lines("mapper.args")[0], "-1")
self.assertRegex(device.mapper_args()[1], r"^retrobios-stop-\d+x$")
self.assertEqual(device.lines("mapper.db"), [f"{device.root}/gamecontrollerdb.txt"])
started = [t for t in device.tools() if t.startswith("sudo")]
self.assertEqual(started, [])
# Stopped without the sudo control.ini names.
device.assert_mapper_stopped()
def test_rocknix_start_select_with_its_controller_setup(self):
"""control.ini's `-sudokill` runs the kill through sudo, which on
ROCKNIX runs nothing: the mapper is started with -1, and Start +
Select ends the run at once instead of 3 s later through the
liveness watch."""
device, tools = self.device()
self.rocknix(device)
self.control_ini(device)
proc = device.run(["bash", str(tools / "RetroBIOS.sh")], stdio_tty=True, TERM="xterm",
FAKE_HOLD="1", FAKE_PRESS="1", RB_MESSAGE_WAIT="300")
self.assertEqual(proc.returncode, 143, proc.stderr.decode())
self.assertLess(device.elapsed, 10)
self.assertEqual(device.lines("ui.log"), ["signal 15"])
log = (tools / "retrobios" / "log.txt").read_text()
self.assertIn("Start + Select: the interface is asked to stop", log)
self.assertNotIn("the controller mapper stopped", log)
self.assertNotIn("You are 'root'", log)
device.assert_mapper_gone()
def test_rocknix_offline_names_no_pack_the_releases_lack(self):
"""No release pack spells ROCKNIX, and install.py refuses one named
for another system: the RetroArch pack on the card is not offered,
@@ -1527,6 +1864,28 @@ INI
device.assert_mapper_gone()
class TheLiveness(unittest.TestCase):
def test_a_zombie_is_not_running(self):
"""A process that exited and that its parent has not reaped still
has /proc/PID: rb_alive reads its state."""
pid = os.fork()
if pid == 0:
os._exit(0)
self.addCleanup(os.waitpid, pid, 0)
wait_until(self, lambda: Path(f"/proc/{pid}/stat").read_text().split(") ")[1][0] == "Z",
5, "no zombie")
proc = subprocess.run(
["bash", "-c", 'source "$1"; rb_alive "$2" && echo alive || echo gone', "bash",
str(PORTS / "shared" / "tui.txt"), str(pid)],
capture_output=True, text=True, check=False)
self.assertEqual(proc.stdout.strip(), "gone", proc.stderr)
proc = subprocess.run(
["bash", "-c", 'source "$1"; rb_alive "$$" && echo alive || echo gone', "bash",
str(PORTS / "shared" / "tui.txt")],
capture_output=True, text=True, check=False)
self.assertEqual(proc.stdout.strip(), "alive", proc.stderr)
class TheDocs(unittest.TestCase):
def test_the_offline_packs_named_are_the_ones_that_serve(self):
"""The wiki names the pack each firmware takes offline: the release
@@ -1552,6 +1911,27 @@ class TheDocs(unittest.TestCase):
self.assertIn(f"| `{value}` |", text)
self.assertIn(value, tui)
def handheld(self) -> str:
text = (REPO_ROOT / "wiki" / "installer.md").read_text(encoding="utf-8")
start = text.index("## On a handheld")
end = text.find("\n## ", start + 1)
return text[start:end if end != -1 else len(text)]
def test_the_idle_limit_is_documented(self):
"""Every launcher sets RETROBIOS_IDLE_EXIT for the interface: a
mapper that runs but never opens the pad leaves no button to quit."""
self.assertIn("`RETROBIOS_IDLE_EXIT`", self.handheld())
self.assertIn("RETROBIOS_IDLE_EXIT", (PORTS / "shared" / "tui.txt").read_text())
def test_no_page_buttons_are_promised_in_muxterm(self):
"""muxterm keeps PgUp and PgDn for its scrollback (T/input.c:352-360)."""
readme = " ".join((PORTS / "muos" / "README.md").read_text().split())
self.assertNotIn("L1 and R1 to page", readme)
self.assertIn("L1 and R1 do nothing in muxterm", readme)
rows = [line for line in self.handheld().splitlines() if line.startswith("| L1, R1 |")]
self.assertEqual(len(rows), 1)
self.assertIn("not in muxterm", rows[0])
def test_muos_paths_are_the_card_muos_mounts(self):
"""muOS mounts the first card at /mnt/mmc (MustardOS internal
mux_launch.sh, GET_VAR storage/rom/mount)."""
+10 -4
View File
@@ -1061,7 +1061,7 @@ muxterm, as the muOS app does.
| B, Select | Back, and quit on the first screen |
| X | Tick or untick a system |
| Y | Options |
| L1, R1 | Page up, page down |
| L1, R1 | Page up, page down (not in muxterm, which keeps them for its own scrollback) |
| Hold Select, then Start | Quit at once (PortMaster port, muOS, Tools) |
Start pressed first also chooses the line in focus: hold Select first. A
@@ -1071,6 +1071,11 @@ ticks a system, Select shows its keyboard, and Menu quits. L1 and R1 do not
page there: muxterm keeps PgUp and PgDn for its own scrollback, so a list
moves with the D-pad.
Each launcher also sets `RETROBIOS_IDLE_EXIT` to 300: with no button pressed
on its first screen for 5 minutes, the interface closes by itself. A pad the
system has no mapping for sends nothing, and would otherwise leave the
console stuck on the interface with no way out.
Each launcher tells the interface which buttons reach it, in
`RETROBIOS_PAD`, so the hints can name them:
@@ -1126,9 +1131,10 @@ names each route it tried, and `log.prev.txt` keeps the run before.
**From a computer.** Onion, MinUI, NextUI, Allium, GarlicOS and the Switch
have no Python 3 to run the installer. Put the card in a computer and run
the [one-line command](#one-line) there with
`--platform <platform> --dest <BIOS folder on the card>`. This works for
every system above too.
the [one-line command](#one-line) there: it finds a mounted Onion,
spruceOS, MinUI, muOS, Knulli or ROCKNIX card by itself. For a card it does
not recognise, add `--platform <platform> --dest <BIOS folder on the card>`.
This works for every system above too.
## Where the files come from