From c213df1ad758ed81ae6b3bab680c08b8f12e57c7 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 14:08:59 -0500 Subject: [PATCH 01/21] Add AYN Thor second screen support (userspace) With the bottom panel enabled in the kernel, the top screen becomes DSI-2 and a second backlight (ae94000.dsi.0) appears that Steam picks up for brightness control instead of the top panel's. - ayn-thor.conf: primary connector is DSI-2; declare the primary backlight, secondary connector, and both touchscreen device names - steamos-priv-write: accept backlight brightness writes and steer them to ARMADA_PRIMARY_BACKLIGHT, rescaling between brightness ranges (bottom is 0-255, top is 0-4096) - desktop-bootstrap/setup-dual-screen: one-time desktop layout for dual-screen devices - primary on top at priority 1, secondary centered below, each touchscreen pinned to its own output (KWin otherwise maps both to the same screen) Requires the armada-packages kernel with the Thor bottom panel enabled; on the old kernel DSI-2 does not exist and gamescope falls back to the only connected output, so ordering is safe. Fixes #28 (phase 1: no bottom-screen UI in gaming mode yet) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- .../steamos-polkit-helpers/steamos-priv-write | 25 ++++ .../usr/lib/armada/devices/ayn-thor.conf | 9 +- .../usr/lib/armada/devices/defaults.conf | 9 ++ .../usr/libexec/armada/desktop-bootstrap | 11 +- system_files/usr/libexec/armada/device-env | 4 + .../usr/libexec/armada/setup-dual-screen | 121 ++++++++++++++++++ 6 files changed, 177 insertions(+), 2 deletions(-) create mode 100755 system_files/usr/libexec/armada/setup-dual-screen diff --git a/system_files/usr/bin/steamos-polkit-helpers/steamos-priv-write b/system_files/usr/bin/steamos-polkit-helpers/steamos-priv-write index 412df77..a039f53 100755 --- a/system_files/usr/bin/steamos-polkit-helpers/steamos-priv-write +++ b/system_files/usr/bin/steamos-polkit-helpers/steamos-priv-write @@ -72,5 +72,30 @@ if [[ "$WRITE_PATH" == /sys/class/hwmon/hwmon*/power*_cap ]]; then exit 1 fi +if [[ "$WRITE_PATH" == /sys/class/backlight/*/brightness ]]; then + [[ "$WRITE_VALUE" =~ ^[0-9]+$ ]] || exit 1 + # Steam adjusts the first backlight it finds; on dual-screen devices + # that can be the secondary panel. Steer writes to the primary + # backlight, rescaling between the two devices' brightness ranges. + eval "$(/usr/libexec/armada/device-env)" + src_dir="$(dirname "$WRITE_PATH")" + tgt_dir="$src_dir" + if [[ -n "${ARMADA_PRIMARY_BACKLIGHT:-}" ]]; then + tgt_dir="/sys/class/backlight/${ARMADA_PRIMARY_BACKLIGHT}" + fi + [[ -e "$tgt_dir/brightness" ]] || exit 1 + val="$WRITE_VALUE" + if [[ "$tgt_dir" != "$src_dir" && -e "$src_dir/max_brightness" ]]; then + src_max="$(cat "$src_dir/max_brightness")" + tgt_max="$(cat "$tgt_dir/max_brightness")" + if [[ "$src_max" -gt 0 && "$src_max" -ne "$tgt_max" ]]; then + val=$(( val * tgt_max / src_max )) + fi + fi + echo "$val" > "$tgt_dir/brightness" + log "backlight: $WRITE_VALUE -> $tgt_dir/brightness ($val)" + exit 0 +fi + echo "refusing unsupported write: $WRITE_VALUE -> $WRITE_PATH" >&2 exit 1 diff --git a/system_files/usr/lib/armada/devices/ayn-thor.conf b/system_files/usr/lib/armada/devices/ayn-thor.conf index b063aab..0a84172 100644 --- a/system_files/usr/lib/armada/devices/ayn-thor.conf +++ b/system_files/usr/lib/armada/devices/ayn-thor.conf @@ -2,7 +2,14 @@ ARMADA_DEVICE_ID=ayn-thor ARMADA_DEVICE_NAME='AYN Thor' ARMADA_SOC_CLASS=SM8550 -ARMADA_PRIMARY_CONNECTOR=DSI-1 +# Dual screen: top panel is DSI-2 (mdss_dsi1), bottom panel is DSI-1 +# (mdss_dsi0). Gamescope drives only the top panel; the desktop session +# uses both (see desktop-bootstrap). +ARMADA_PRIMARY_CONNECTOR=DSI-2 +ARMADA_PRIMARY_BACKLIGHT=ae96000.dsi.0 +ARMADA_PRIMARY_TOUCHSCREEN=top_touchscreen +ARMADA_SECONDARY_CONNECTOR=DSI-1 +ARMADA_SECONDARY_TOUCHSCREEN=bottom_touchscreen ARMADA_PANEL_ORIENTATION=right ARMADA_PANEL_NATIVE_WIDTH=1080 ARMADA_PANEL_NATIVE_HEIGHT=1920 diff --git a/system_files/usr/lib/armada/devices/defaults.conf b/system_files/usr/lib/armada/devices/defaults.conf index 1b162c5..9125821 100644 --- a/system_files/usr/lib/armada/devices/defaults.conf +++ b/system_files/usr/lib/armada/devices/defaults.conf @@ -3,6 +3,15 @@ ARMADA_DEVICE_NAME=unknown ARMADA_SOC_CLASS= ARMADA_PRIMARY_CONNECTOR= +# Backlight Steam brightness writes are steered to. Only needed on devices +# with more than one backlight (Steam grabs the first one it finds). +ARMADA_PRIMARY_BACKLIGHT= +# Set on dual-screen devices; the desktop session extends onto this output. +ARMADA_SECONDARY_CONNECTOR= +# Input device names used to pin each touchscreen to its own output in the +# desktop session (only needed when a secondary connector is set). +ARMADA_PRIMARY_TOUCHSCREEN= +ARMADA_SECONDARY_TOUCHSCREEN= ARMADA_PANEL_TYPE=internal ARMADA_PANEL_ORIENTATION=normal ARMADA_PANEL_NATIVE_WIDTH= diff --git a/system_files/usr/libexec/armada/desktop-bootstrap b/system_files/usr/libexec/armada/desktop-bootstrap index 1bd9420..e6fa2d3 100755 --- a/system_files/usr/libexec/armada/desktop-bootstrap +++ b/system_files/usr/libexec/armada/desktop-bootstrap @@ -23,8 +23,9 @@ fi config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/armada" rotation_done="${config_dir}/desktop-rotation.done" scale_done="${config_dir}/desktop-scale.done" +dual_done="${config_dir}/desktop-dualscreen.done" -[[ ! -e "${rotation_done}" || ! -e "${scale_done}" ]] || exit 0 +[[ ! -e "${rotation_done}" || ! -e "${scale_done}" || ! -e "${dual_done}" ]] || exit 0 mkdir -p "${config_dir}" if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then @@ -57,3 +58,11 @@ fi if [[ ! -e "${scale_done}" ]] && kscreen-doctor "output.${display_connector}.scale.1.5" >/dev/null 2>&1; then touch "${scale_done}" fi + +if [[ ! -e "${dual_done}" ]]; then + if [[ -z "${ARMADA_SECONDARY_CONNECTOR:-}" ]]; then + touch "${dual_done}" + elif /usr/libexec/armada/setup-dual-screen; then + touch "${dual_done}" + fi +fi diff --git a/system_files/usr/libexec/armada/device-env b/system_files/usr/libexec/armada/device-env index faf005f..239d4ee 100755 --- a/system_files/usr/libexec/armada/device-env +++ b/system_files/usr/libexec/armada/device-env @@ -36,6 +36,10 @@ vars=( ARMADA_DEVICE_NAME ARMADA_SOC_CLASS ARMADA_PRIMARY_CONNECTOR + ARMADA_PRIMARY_BACKLIGHT + ARMADA_SECONDARY_CONNECTOR + ARMADA_PRIMARY_TOUCHSCREEN + ARMADA_SECONDARY_TOUCHSCREEN ARMADA_PANEL_TYPE ARMADA_PANEL_ORIENTATION ARMADA_PANEL_NATIVE_WIDTH diff --git a/system_files/usr/libexec/armada/setup-dual-screen b/system_files/usr/libexec/armada/setup-dual-screen new file mode 100755 index 0000000..54ca33c --- /dev/null +++ b/system_files/usr/libexec/armada/setup-dual-screen @@ -0,0 +1,121 @@ +#!/usr/bin/python3 +"""One-time desktop layout for dual-screen devices, run by desktop-bootstrap. + +Places the secondary output below the primary (centered), makes the primary +the priority-1 output, and pins each touchscreen to its own output so taps +land on the screen being touched. +""" +import glob +import json +import shlex +import subprocess +import sys + + +def device_env(): + out = subprocess.run( + ["/usr/libexec/armada/device-env"], + capture_output=True, text=True, check=True, + ).stdout + env = {} + for line in out.splitlines(): + key, _, value = line.partition("=") + env[key] = shlex.split(value)[0] if value else "" + return env + + +def logical_size(output): + mode = next( + (m for m in output.get("modes", []) + if str(m.get("id")) == str(output.get("currentModeId"))), + None, + ) + if mode is None: + return None + width = mode["size"]["width"] + height = mode["size"]["height"] + # KScreen rotation flags: 2 (left) and 8 (right) swap the axes. + if output.get("rotation") in (2, 8): + width, height = height, width + scale = output.get("scale") or 1 + return round(width / scale), round(height / scale) + + +def input_devices(): + """Map input device name -> (vendor, product, event node).""" + devices = {} + for sysdir in glob.glob("/sys/class/input/input*"): + try: + name = open(f"{sysdir}/name").read().strip() + vendor = int(open(f"{sysdir}/id/vendor").read(), 16) + product = int(open(f"{sysdir}/id/product").read(), 16) + event = next( + e.rsplit("/", 1)[-1] for e in glob.glob(f"{sysdir}/event*") + ) + except (OSError, StopIteration, ValueError): + continue + devices[name] = (vendor, product, event) + return devices + + +def pin_touchscreen(name, connector, devices): + if name not in devices: + return + vendor, product, event = devices[name] + # Persisted mapping, applied by KWin when the device is added. + subprocess.run( + ["kwriteconfig6", "--file", "kcminputrc", + "--group", "Libinput", "--group", str(vendor), + "--group", str(product), "--group", name, + "--key", "OutputName", connector], + check=False, + ) + # Live mapping for the current session. + subprocess.run( + ["gdbus", "call", "--session", "--dest", "org.kde.KWin", + "--object-path", f"/org/kde/KWin/InputDevice/{event}", + "--method", "org.freedesktop.DBus.Properties.Set", + "org.kde.KWin.InputDevice", "outputName", f"<'{connector}'>"], + check=False, capture_output=True, + ) + + +def main(): + env = device_env() + primary = env.get("ARMADA_PRIMARY_CONNECTOR") + secondary = env.get("ARMADA_SECONDARY_CONNECTOR") + if not primary or not secondary: + return 0 + + outputs = json.loads(subprocess.run( + ["kscreen-doctor", "-o", "-j"], + capture_output=True, text=True, check=True, + ).stdout)["outputs"] + by_name = {o["name"]: o for o in outputs} + if primary not in by_name or secondary not in by_name: + print(f"setup-dual-screen: outputs not found: {primary}, {secondary}", + file=sys.stderr) + return 1 + + primary_size = logical_size(by_name[primary]) + secondary_size = logical_size(by_name[secondary]) + if primary_size is None or secondary_size is None: + return 1 + x = max(0, round((primary_size[0] - secondary_size[0]) / 2)) + subprocess.run( + ["kscreen-doctor", + f"output.{primary}.priority.1", + f"output.{secondary}.priority.2", + f"output.{primary}.position.0,0", + f"output.{secondary}.position.{x},{primary_size[1]}"], + check=True, + ) + + devices = input_devices() + pin_touchscreen(env.get("ARMADA_PRIMARY_TOUCHSCREEN"), primary, devices) + pin_touchscreen(env.get("ARMADA_SECONDARY_TOUCHSCREEN"), secondary, devices) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5bac94cea134f7101da7200302a5e4aa71e9cbff Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 14:13:20 -0500 Subject: [PATCH 02/21] Inhibit secondary touchscreen in gaming mode Gamescope only lights up the primary panel, but the secondary touchscreen still feeds it input - fingers resting on the dark glass move Steam's cursor. Inhibit the device (via /sys/class/input/*/inhibited, made wheel-writable like the other session-managed sysfs knobs) when the gaming session starts and re-enable it when the desktop session starts. A future Armada Control toggle could expose this as a user choice. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- .../etc/gamescope-session-plus/sessions.d/steam | 6 ++++++ .../usr/lib/udev/rules.d/60-armada-perf-acls.rules | 3 +++ system_files/usr/libexec/armada/desktop-bootstrap | 14 ++++++++++---- .../usr/libexec/armada/touchscreen-inhibit | 12 ++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) create mode 100755 system_files/usr/libexec/armada/touchscreen-inhibit diff --git a/system_files/etc/gamescope-session-plus/sessions.d/steam b/system_files/etc/gamescope-session-plus/sessions.d/steam index 258bf80..8a6e958 100644 --- a/system_files/etc/gamescope-session-plus/sessions.d/steam +++ b/system_files/etc/gamescope-session-plus/sessions.d/steam @@ -14,6 +14,12 @@ if [[ -n "${ARMADA_PANEL_REFRESH_RATES:-}" ]]; then unset STEAM_DISPLAY_REFRESH_LIMITS fi fi +# Gamescope only lights up the primary panel; silence the secondary +# touchscreen so fingers on the dark glass don't move Steam's cursor. +# The desktop session re-enables it (see desktop-bootstrap). +if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then + /usr/libexec/armada/touchscreen-inhibit "$ARMADA_SECONDARY_TOUCHSCREEN" 1 2>/dev/null || true +fi USE_ROTATION_SHADER="${ARMADA_GAMESCOPE_USE_ROTATION_SHADER:-0}" if [[ -n "${ARMADA_GAMESCOPE_FAKE_OUTPUT_MM:-}" ]]; then export GAMESCOPE_FAKE_OUTPUT_MM="$ARMADA_GAMESCOPE_FAKE_OUTPUT_MM" diff --git a/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules b/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules index 306a54e..8187b68 100644 --- a/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules +++ b/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules @@ -6,3 +6,6 @@ SUBSYSTEM=="devfreq", KERNEL=="*.gpu", \ SUBSYSTEM=="backlight", \ RUN+="/bin/sh -c 'chgrp wheel /sys/class/backlight/%k/brightness 2>/dev/null; chmod g+w /sys/class/backlight/%k/brightness 2>/dev/null'" + +SUBSYSTEM=="input", KERNEL=="input[0-9]*", \ + RUN+="/bin/sh -c 'chgrp wheel /sys/class/input/%k/inhibited 2>/dev/null; chmod g+w /sys/class/input/%k/inhibited 2>/dev/null'" diff --git a/system_files/usr/libexec/armada/desktop-bootstrap b/system_files/usr/libexec/armada/desktop-bootstrap index e6fa2d3..badbec7 100755 --- a/system_files/usr/libexec/armada/desktop-bootstrap +++ b/system_files/usr/libexec/armada/desktop-bootstrap @@ -20,6 +20,16 @@ else systemctl --user import-environment "${activation_env[@]}" 2>/dev/null || true fi +if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then + eval "$(/usr/libexec/armada/device-env)" +fi + +# The gaming session inhibits the secondary touchscreen (the desktop is the +# only session that lights up the secondary panel); re-enable it every start. +if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then + /usr/libexec/armada/touchscreen-inhibit "$ARMADA_SECONDARY_TOUCHSCREEN" 0 2>/dev/null || true +fi + config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/armada" rotation_done="${config_dir}/desktop-rotation.done" scale_done="${config_dir}/desktop-scale.done" @@ -28,10 +38,6 @@ dual_done="${config_dir}/desktop-dualscreen.done" [[ ! -e "${rotation_done}" || ! -e "${scale_done}" || ! -e "${dual_done}" ]] || exit 0 mkdir -p "${config_dir}" -if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then - eval "$(/usr/libexec/armada/device-env)" -fi - display_connector="${ARMADA_PRIMARY_CONNECTOR:-}" panel_orientation="${ARMADA_PANEL_ORIENTATION:-normal}" diff --git a/system_files/usr/libexec/armada/touchscreen-inhibit b/system_files/usr/libexec/armada/touchscreen-inhibit new file mode 100755 index 0000000..7ebc8ab --- /dev/null +++ b/system_files/usr/libexec/armada/touchscreen-inhibit @@ -0,0 +1,12 @@ +#!/usr/bin/bash +# Inhibit (1) or re-enable (0) an input device by name. Used to silence the +# secondary touchscreen while gamescope owns the only visible screen. +set -euo pipefail + +name="${1:?usage: touchscreen-inhibit <1|0>}" +state="${2:?usage: touchscreen-inhibit <1|0>}" + +for dev in /sys/class/input/input*/; do + [[ "$(cat "${dev}name" 2>/dev/null)" == "${name}" ]] || continue + echo "${state}" > "${dev}inhibited" +done From 0de405ebb716b650792e1e669fafb1ba501ba1c1 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Wed, 8 Jul 2026 18:53:55 -0500 Subject: [PATCH 03/21] setup-dual-screen: tolerate trailing text in kscreen-doctor JSON kscreen-doctor -o -j prints the human-readable listing after the JSON document, so json.loads raised JSONDecodeError and the script died before applying the layout or pinning the touchscreens. On first boot that left both touchscreens mapped to the same output: taps on the top screen landed on the bottom desktop, where a stray double-tap on the Return to Game Mode icon logged the session out. Request JSON alone and parse only the first document so trailing text can never take the script down again. Verified on an AYN Thor: layout applied, both touchscreens pinned to their own panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LeRkgMBMwWNcnwMk4j5kVx --- system_files/usr/libexec/armada/setup-dual-screen | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/system_files/usr/libexec/armada/setup-dual-screen b/system_files/usr/libexec/armada/setup-dual-screen index 54ca33c..08afbb3 100755 --- a/system_files/usr/libexec/armada/setup-dual-screen +++ b/system_files/usr/libexec/armada/setup-dual-screen @@ -87,10 +87,13 @@ def main(): if not primary or not secondary: return 0 - outputs = json.loads(subprocess.run( - ["kscreen-doctor", "-o", "-j"], + # Some kscreen-doctor versions append human-readable text after the + # JSON document, so parse only the first document. + doc = subprocess.run( + ["kscreen-doctor", "-j"], capture_output=True, text=True, check=True, - ).stdout)["outputs"] + ).stdout + outputs = json.JSONDecoder().raw_decode(doc)[0]["outputs"] by_name = {o["name"]: o for o in outputs} if primary not in by_name or secondary not in by_name: print(f"setup-dual-screen: outputs not found: {primary}, {secondary}", From 7f640c935fcfb951f472d502d9752891c46ca428 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Fri, 10 Jul 2026 22:51:26 -0500 Subject: [PATCH 04/21] Withhold direct backlight access on dual-screen devices The perf-ACL udev rule made every backlight's brightness group-writable, so Steam's direct sysfs write (its first choice before falling back to steamos-polkit-helpers/steamos-priv-write) succeeded on whichever backlight it enumerated first - on Thor the secondary bottom panel. The priv-write steering to ARMADA_PRIMARY_BACKLIGHT never engaged except for out-of-range values, so the brightness slider dimmed the desktop screen instead of the gaming one. Observed on hardware: at Steam startup a saved 0-4096-scale value hit the 255-max bottom panel (EINVAL, then rescaled to 166 and correctly steered 2666 -> top panel via priv-write), but ordinary slider writes were in-range for the bottom panel and landed there directly. The udev grant now goes through backlight-acl, which withholds it from every backlight when ARMADA_PRIMARY_BACKLIGHT is set: all Steam writes fail EACCES and funnel through the steering helper, the same path Steam uses on stock SteamOS where backlight sysfs is not user-writable. Single-backlight devices keep the direct grant. Part of #28 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011roK8EGErjD8eEjjZNNW9w --- .../lib/udev/rules.d/60-armada-perf-acls.rules | 3 +-- system_files/usr/libexec/armada/backlight-acl | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100755 system_files/usr/libexec/armada/backlight-acl diff --git a/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules b/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules index 8187b68..1c0be0f 100644 --- a/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules +++ b/system_files/usr/lib/udev/rules.d/60-armada-perf-acls.rules @@ -4,8 +4,7 @@ SUBSYSTEM=="cpu", KERNEL=="cpu[0-9]*", \ SUBSYSTEM=="devfreq", KERNEL=="*.gpu", \ RUN+="/bin/sh -c 'chgrp wheel /sys/class/devfreq/%k/governor /sys/class/devfreq/%k/max_freq /sys/class/devfreq/%k/min_freq 2>/dev/null; chmod g+w /sys/class/devfreq/%k/governor /sys/class/devfreq/%k/max_freq /sys/class/devfreq/%k/min_freq 2>/dev/null'" -SUBSYSTEM=="backlight", \ - RUN+="/bin/sh -c 'chgrp wheel /sys/class/backlight/%k/brightness 2>/dev/null; chmod g+w /sys/class/backlight/%k/brightness 2>/dev/null'" +SUBSYSTEM=="backlight", RUN+="/usr/libexec/armada/backlight-acl %k" SUBSYSTEM=="input", KERNEL=="input[0-9]*", \ RUN+="/bin/sh -c 'chgrp wheel /sys/class/input/%k/inhibited 2>/dev/null; chmod g+w /sys/class/input/%k/inhibited 2>/dev/null'" diff --git a/system_files/usr/libexec/armada/backlight-acl b/system_files/usr/libexec/armada/backlight-acl new file mode 100755 index 0000000..a718874 --- /dev/null +++ b/system_files/usr/libexec/armada/backlight-acl @@ -0,0 +1,16 @@ +#!/usr/bin/bash +# udev helper: grant Steam (wheel) direct write access to a backlight. +# +# On devices with more than one panel (ARMADA_PRIMARY_BACKLIGHT set) the +# grant is withheld from every backlight: Steam writes the first backlight +# it finds directly whenever it can, which bypasses the steamos-priv-write +# steering to the primary panel. Without write access Steam falls back to +# the polkit helper (its standard path on SteamOS), which steers and +# rescales the write. +set -euo pipefail + +eval "$(/usr/libexec/armada/device-env)" +[[ -n "${ARMADA_PRIMARY_BACKLIGHT:-}" ]] && exit 0 + +chgrp wheel "/sys/class/backlight/$1/brightness" 2>/dev/null || true +chmod g+w "/sys/class/backlight/$1/brightness" 2>/dev/null || true From db9fb85bad1296e2fd390687814bc5500ccb36cf Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 20:19:20 -0500 Subject: [PATCH 05/21] Add nested gaming session for dual-screen devices With ARMADA_GAMING_SESSION=nested (Thor), gaming mode runs as a nested gamescope fullscreen on the primary screen inside the persistent desktop session, with the plasma desktop live on the secondary screen. Mode switches become near-instant: SwitchToDesktopMode just ends the nested gamescope (the desktop is already underneath) and SwitchToGameMode launches it via a transient user unit. steamos-manager handles the switch in its session-bus instance only; the embedded session-swap path is unchanged for single-screen devices and for the system-bus instance. Validated on AYN Thor: instant switch both directions through Steam's UI, game performance indistinguishable from embedded gaming mode. Part of #28 phase 2 (design doc in docs/superpowers/specs/) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- .../usr/lib/armada/devices/ayn-thor.conf | 1 + .../usr/lib/armada/devices/defaults.conf | 4 +++ system_files/usr/libexec/armada/device-env | 1 + system_files/usr/libexec/armada/nested-gaming | 21 +++++++++++++ .../usr/libexec/armada/steamos-manager | 31 +++++++++++++++++++ 5 files changed, 58 insertions(+) create mode 100755 system_files/usr/libexec/armada/nested-gaming diff --git a/system_files/usr/lib/armada/devices/ayn-thor.conf b/system_files/usr/lib/armada/devices/ayn-thor.conf index 0a84172..8ac6497 100644 --- a/system_files/usr/lib/armada/devices/ayn-thor.conf +++ b/system_files/usr/lib/armada/devices/ayn-thor.conf @@ -10,6 +10,7 @@ ARMADA_PRIMARY_BACKLIGHT=ae96000.dsi.0 ARMADA_PRIMARY_TOUCHSCREEN=top_touchscreen ARMADA_SECONDARY_CONNECTOR=DSI-1 ARMADA_SECONDARY_TOUCHSCREEN=bottom_touchscreen +ARMADA_GAMING_SESSION=nested ARMADA_PANEL_ORIENTATION=right ARMADA_PANEL_NATIVE_WIDTH=1080 ARMADA_PANEL_NATIVE_HEIGHT=1920 diff --git a/system_files/usr/lib/armada/devices/defaults.conf b/system_files/usr/lib/armada/devices/defaults.conf index 9125821..d4fab41 100644 --- a/system_files/usr/lib/armada/devices/defaults.conf +++ b/system_files/usr/lib/armada/devices/defaults.conf @@ -12,6 +12,10 @@ ARMADA_SECONDARY_CONNECTOR= # desktop session (only needed when a secondary connector is set). ARMADA_PRIMARY_TOUCHSCREEN= ARMADA_SECONDARY_TOUCHSCREEN= +# embedded (default): gamescope owns the GPU in gaming mode. +# nested: gaming mode runs as a nested gamescope inside the desktop session +# (dual-screen devices), making mode switches near-instant. +ARMADA_GAMING_SESSION=embedded ARMADA_PANEL_TYPE=internal ARMADA_PANEL_ORIENTATION=normal ARMADA_PANEL_NATIVE_WIDTH= diff --git a/system_files/usr/libexec/armada/device-env b/system_files/usr/libexec/armada/device-env index 239d4ee..f505ceb 100755 --- a/system_files/usr/libexec/armada/device-env +++ b/system_files/usr/libexec/armada/device-env @@ -40,6 +40,7 @@ vars=( ARMADA_SECONDARY_CONNECTOR ARMADA_PRIMARY_TOUCHSCREEN ARMADA_SECONDARY_TOUCHSCREEN + ARMADA_GAMING_SESSION ARMADA_PANEL_TYPE ARMADA_PANEL_ORIENTATION ARMADA_PANEL_NATIVE_WIDTH diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming new file mode 100755 index 0000000..9c55435 --- /dev/null +++ b/system_files/usr/libexec/armada/nested-gaming @@ -0,0 +1,21 @@ +#!/usr/bin/bash +# Gaming mode on dual-screen devices (ARMADA_GAMING_SESSION=nested): run +# gamescope+Steam as a nested fullscreen window on the primary screen of the +# already-running desktop session. steamos-manager starts and stops this; +# see docs/superpowers/specs/2026-07-07-thor-second-screen-phase2-design.md. +set -euo pipefail + +pgrep -x gamescope-wl >/dev/null && exit 0 + +eval "$(/usr/libexec/armada/device-env)" + +# The desktop shows rotated panels in landscape; gamescope renders landscape. +width="${ARMADA_PANEL_NATIVE_WIDTH:-1080}" +height="${ARMADA_PANEL_NATIVE_HEIGHT:-1920}" +case "${ARMADA_PANEL_ORIENTATION:-normal}" in + left|right) gs_w="$height" gs_h="$width" ;; + *) gs_w="$width" gs_h="$height" ;; +esac + +exec gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam -- \ + /usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles diff --git a/system_files/usr/libexec/armada/steamos-manager b/system_files/usr/libexec/armada/steamos-manager index 2a54634..221d26f 100755 --- a/system_files/usr/libexec/armada/steamos-manager +++ b/system_files/usr/libexec/armada/steamos-manager @@ -246,7 +246,38 @@ class ArmadaSteamOSManager: return raise KeyError(prop) + @staticmethod + def _process_running(name): + return ( + subprocess.run( + ["pgrep", "-x", name], stdout=subprocess.DEVNULL + ).returncode + == 0 + ) + def switch_session(self, target): + # On dual-screen devices the desktop compositor stays up and hosts + # gaming mode as a nested gamescope on the primary screen; switching + # starts or stops that gamescope instead of swapping SDDM sessions. + # Only the session-bus instance can manage the user's session. + if ( + self.env.get("ARMADA_GAMING_SESSION") == "nested" + and os.geteuid() != 0 + and self._process_running("kwin_wayland") + ): + if target == "desktop": + if self._process_running("gamescope-wl"): + subprocess.run(["pkill", "-x", "gamescope-wl"], timeout=10) + return + if target == "gamemode": + subprocess.run( + ["systemd-run", "--user", "--collect", + "/usr/libexec/armada/nested-gaming"], + check=True, + timeout=10, + ) + return + commands = { "desktop": "switch-desktop", "gamemode": "switch-gamemode", From 82826d0df77ea1fa906c4bbd5b33316287ed218c Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 21:05:37 -0500 Subject: [PATCH 06/21] Boot nested-gaming devices into the persistent desktop session armada-session-default resets the SDDM session to "gamemode" each boot; on ARMADA_GAMING_SESSION=nested devices that session is now the plasma session, and desktop-bootstrap launches the nested gamescope on a fresh session start. Boot lands in gaming mode with the desktop already live on the secondary screen, and the embedded gamescope session is no longer used on these devices. Known limitation: nested gaming runs at the mode KWin selected for the primary output (120Hz on Thor); Steam's per-game refresh switching is not bridged yet. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- system_files/usr/libexec/armada/desktop-bootstrap | 7 +++++++ system_files/usr/libexec/armada/session-control | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/system_files/usr/libexec/armada/desktop-bootstrap b/system_files/usr/libexec/armada/desktop-bootstrap index badbec7..b6dda54 100755 --- a/system_files/usr/libexec/armada/desktop-bootstrap +++ b/system_files/usr/libexec/armada/desktop-bootstrap @@ -30,6 +30,13 @@ if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then /usr/libexec/armada/touchscreen-inhibit "$ARMADA_SECONDARY_TOUCHSCREEN" 0 2>/dev/null || true fi +# Nested-gaming devices boot into this session with gaming on the primary +# screen; a fresh session start is either boot or an explicit return to +# gamemode (Switch to Desktop keeps the session and never re-runs this). +if [[ ${ARMADA_GAMING_SESSION:-} == nested ]] && ! pgrep -x gamescope-wl >/dev/null; then + systemd-run --user --collect /usr/libexec/armada/nested-gaming 2>/dev/null || true +fi + config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/armada" rotation_done="${config_dir}/desktop-rotation.done" scale_done="${config_dir}/desktop-scale.done" diff --git a/system_files/usr/libexec/armada/session-control b/system_files/usr/libexec/armada/session-control index 3111c85..95f5fdb 100755 --- a/system_files/usr/libexec/armada/session-control +++ b/system_files/usr/libexec/armada/session-control @@ -2,10 +2,19 @@ # zz- sorts after armada.conf so its Session= wins (User=/Relogin= stay there). set -euo pipefail +# On nested-gaming devices the desktop session hosts gaming mode as a +# nested gamescope (launched by desktop-bootstrap), so "gamemode" boots +# into the plasma session too. +gamemode_session=gamescope-session-steam.desktop +if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then + eval "$(/usr/libexec/armada/device-env)" + [[ ${ARMADA_GAMING_SESSION:-} == nested ]] && gamemode_session=armada-plasma.desktop +fi + case "${1:-}" in switch-desktop) session=armada-plasma.desktop ;; - switch-gamemode) session=gamescope-session-steam.desktop ;; - default-gamemode) session=gamescope-session-steam.desktop; default_only=1 ;; + switch-gamemode) session=$gamemode_session ;; + default-gamemode) session=$gamemode_session; default_only=1 ;; *) echo "usage: $0 {switch-desktop|switch-gamemode|default-gamemode}" >&2; exit 1 ;; esac From 4399134050bcf8de49d78faaa1d5556d23c36576 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 21:21:00 -0500 Subject: [PATCH 07/21] Bridge Steam refresh-rate requests to KWin in nested gaming Steam communicates refresh switching by setting GAMESCOPE_DYNAMIC_REFRESH on gamescope's Xwayland root; the embedded DRM backend applies it but nested gamescope cannot modeset the host output. nested-refresh-bridge watches the atom on the nested Xwayland (stdbuf -oL: xprop block-buffers into pipes) and applies the nearest supported panel rate via kscreen-doctor, restoring the max rate when dynamic refresh is disabled or the gaming session ends. nested-gaming exports STEAM_DISPLAY_REFRESH_LIMITS so Steam offers the refresh UI, and Steam's in-between requests (e.g. 90 on a 60/120 panel) snap to the nearest mode, ties to the higher. Validated on hardware via the exact atom writes Steam performs: 60 -> panel 60Hz, 90 -> 120Hz, 0 -> 120Hz. Closes the known limitation from the previous commit. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- system_files/usr/libexec/armada/nested-gaming | 10 +++ .../usr/libexec/armada/nested-refresh-bridge | 71 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100755 system_files/usr/libexec/armada/nested-refresh-bridge diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index 9c55435..eedce38 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -17,5 +17,15 @@ case "${ARMADA_PANEL_ORIENTATION:-normal}" in *) gs_w="$width" gs_h="$height" ;; esac +# Expose the refresh range so Steam offers per-game refresh switching; +# nested-refresh-bridge applies requests to the host compositor. +if [[ -n "${ARMADA_PANEL_REFRESH_RATES:-}" ]]; then + IFS=',' read -r -a _rates <<< "$ARMADA_PANEL_REFRESH_RATES" + if (( ${#_rates[@]} > 1 )); then + export STEAM_DISPLAY_REFRESH_LIMITS="${_rates[0]},${_rates[-1]}" + fi + /usr/libexec/armada/nested-refresh-bridge & +fi + exec gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam -- \ /usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles diff --git a/system_files/usr/libexec/armada/nested-refresh-bridge b/system_files/usr/libexec/armada/nested-refresh-bridge new file mode 100755 index 0000000..cfc7daa --- /dev/null +++ b/system_files/usr/libexec/armada/nested-refresh-bridge @@ -0,0 +1,71 @@ +#!/usr/bin/bash +# Bridge Steam's refresh-rate requests to KWin for the nested gaming session. +# +# In the embedded session gamescope owns the display and applies +# GAMESCOPE_DYNAMIC_REFRESH (set by Steam on gamescope's Xwayland root) +# itself; nested gamescope cannot modeset the host output. Watch the atom on +# the nested Xwayland and apply the nearest supported panel rate through +# kscreen-doctor. Spawned by nested-gaming; exits with the session. +set -euo pipefail + +eval "$(/usr/libexec/armada/device-env)" + +[[ ${ARMADA_GAMING_SESSION:-} == nested ]] || exit 0 +[[ -n ${ARMADA_PRIMARY_CONNECTOR:-} && -n ${ARMADA_PANEL_REFRESH_RATES:-} ]] || exit 0 +[[ -n ${ARMADA_PANEL_NATIVE_WIDTH:-} && -n ${ARMADA_PANEL_NATIVE_HEIGHT:-} ]] || exit 0 + +IFS=',' read -r -a rates <<< "$ARMADA_PANEL_REFRESH_RATES" +max_rate="${rates[-1]}" +mode_res="${ARMADA_PANEL_NATIVE_WIDTH}x${ARMADA_PANEL_NATIVE_HEIGHT}" + +# The nested gamescope's Xwayland is where Steam sets the atom; find its +# display number from Steam's environment. +nested_display="" +for _ in $(seq 1 60); do + steam_pid=$(pgrep -x steam | head -1 || true) + if [[ -n "$steam_pid" ]]; then + nested_display=$(tr '\0' '\n' < "/proc/${steam_pid}/environ" 2>/dev/null \ + | sed -n 's/^DISPLAY=//p' | head -1) + [[ -n "$nested_display" ]] && break + fi + sleep 2 +done +[[ -n "$nested_display" ]] || exit 0 + +snap_rate() { + local want=$1 best="${rates[0]}" best_delta=-1 rate delta + for rate in "${rates[@]}"; do + delta=$(( want > rate ? want - rate : rate - want )) + # Ties go to the higher rate: rates are listed ascending. + if (( best_delta < 0 || delta <= best_delta )); then + best=$rate best_delta=$delta + fi + done + printf '%s' "$best" +} + +current="" +apply() { + local hz=$1 + [[ "$hz" =~ ^[0-9]+$ ]] || hz=0 + (( hz == 0 )) && hz=$max_rate + hz=$(snap_rate "$hz") + [[ "$hz" == "$current" ]] && return 0 + if kscreen-doctor "output.${ARMADA_PRIMARY_CONNECTOR}.mode.${mode_res}@${hz}" >/dev/null 2>&1; then + current=$hz + fi +} + +# Seed the atom so xprop -spy has a property to watch from the start. +xprop -display "$nested_display" -root -f GAMESCOPE_DYNAMIC_REFRESH 32c \ + -set GAMESCOPE_DYNAMIC_REFRESH 0 2>/dev/null || exit 0 + +# stdbuf: xprop block-buffers when piped, which would delay events forever. +stdbuf -oL xprop -display "$nested_display" -root -spy GAMESCOPE_DYNAMIC_REFRESH 2>/dev/null \ + | while read -r line; do + apply "${line##*= }" + done + +# Nested Xwayland is gone (gaming session ended): restore the default rate. +current="" +apply "$max_rate" From 6b5ffb92ca63c9f9250cdd3c786f13ea7779d542 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 21:33:20 -0500 Subject: [PATCH 08/21] nested-gaming: advertise panel refresh rates to nested gamescope Pairs with the armada-packages gamescope patch that reads GAMESCOPE_NESTED_REFRESH_RATES: without it Steam's refresh slider collapses to the single current rate because the nested backend cannot enumerate the host output's modes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- system_files/usr/libexec/armada/nested-gaming | 3 +++ 1 file changed, 3 insertions(+) diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index eedce38..383cbfd 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -24,6 +24,9 @@ if [[ -n "${ARMADA_PANEL_REFRESH_RATES:-}" ]]; then if (( ${#_rates[@]} > 1 )); then export STEAM_DISPLAY_REFRESH_LIMITS="${_rates[0]},${_rates[-1]}" fi + # Advertised to Steam via gamescope_control (armada gamescope patch); + # the Wayland backend cannot enumerate the host output's modes itself. + export GAMESCOPE_NESTED_REFRESH_RATES="$ARMADA_PANEL_REFRESH_RATES" /usr/libexec/armada/nested-refresh-bridge & fi From 74843192d97a25c6b112e699a187636ed028efdb Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Tue, 7 Jul 2026 22:18:45 -0500 Subject: [PATCH 09/21] nested-gaming: pin the gamescope window to the primary screen Pass --prefer-output with the device's primary connector; the armada gamescope patch makes the nested Wayland backend fullscreen onto that output instead of whichever one the host compositor considered active. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- system_files/usr/libexec/armada/nested-gaming | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index 383cbfd..ce6dec7 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -30,5 +30,11 @@ if [[ -n "${ARMADA_PANEL_REFRESH_RATES:-}" ]]; then /usr/libexec/armada/nested-refresh-bridge & fi -exec gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam -- \ +# --prefer-output pins the fullscreen window to the primary screen (armada +# gamescope patch: the Wayland backend passes it to the fullscreen request; +# otherwise the host compositor picks whichever output was last active). +prefer_output=() +[[ -n "${ARMADA_PRIMARY_CONNECTOR:-}" ]] && prefer_output=(--prefer-output "$ARMADA_PRIMARY_CONNECTOR") + +exec gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam "${prefer_output[@]}" -- \ /usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles From 19325a4b921992855936593da6b6ff489b5c469e Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Wed, 8 Jul 2026 04:23:23 -0500 Subject: [PATCH 10/21] Disable PowerDevil idle suspend PowerDevil's idle detection does not count gamepad input as activity; with the persistent desktop session hosting nested gaming it suspends the device mid-game (fake-suspend blanks both screens and everything looks wedged until the power button or a reboot). This also explains an intermittent all-black-screens report during prototype testing. Ship a system default that disables automatic suspend on AC and battery; suspend stays available through the power button (powerbuttond -> fake-suspend), and users can re-enable idle suspend in System Settings. Low-battery behavior keeps the upstream default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XMbeBSJJG1pNfdqkUcG1gn --- system_files/etc/xdg/powerdevilrc | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 system_files/etc/xdg/powerdevilrc diff --git a/system_files/etc/xdg/powerdevilrc b/system_files/etc/xdg/powerdevilrc new file mode 100644 index 0000000..4e2fd7b --- /dev/null +++ b/system_files/etc/xdg/powerdevilrc @@ -0,0 +1,10 @@ +# Armada: no automatic idle suspend. PowerDevil's idle detection does not +# count gamepad input as activity, so with the persistent desktop session +# (nested gaming) it would suspend mid-game. Suspend stays available via the +# power button (powerbuttond -> fake-suspend). Users can still override this +# per-user in System Settings. +[AC][SuspendAndShutdown] +AutoSuspendAction=0 + +[Battery][SuspendAndShutdown] +AutoSuspendAction=0 From e4cd1491a943e49c392037bf026950a4c5ef10f3 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Fri, 10 Jul 2026 22:40:53 -0500 Subject: [PATCH 11/21] nested-gaming: supervise Steam and fast-path all mode switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two clean-install bugs, one root cause each: Steam applies client updates by exiting and relying on its wrapper to relaunch it (sometimes twice in a row, as during first-run onboarding). The embedded session gets this from SDDM Relogin restarting the whole gamescope session; nested mode had no equivalent, so the update left a Steam-less gamescope on screen and a dead gaming mode. Gaming mode now runs as armada-nested-gaming.service (user unit) with Restart=always: nested-gaming launches gamescope with the -R readiness pipe (the same handshake gamescope-session-plus uses), runs Steam as its own child with the environment gamescope would hand it (DISPLAY, GAMESCOPE_WAYLAND_DISPLAY, host WAYLAND_DISPLAY unset), and exits when Steam does so systemd brings the pair back up. StartLimit caps crash loops and PartOf= graphical-session.target stops the unit at logout instead of letting it thrash without a compositor. The desktop "Return to Game Mode" icon went steamos-session-select → os-session-select → sudo session-control switch-gamemode: a full plasma logout + SDDM relogin (~20s of desktop) even on nested devices, so impatient taps killed the Steam that was already starting - mid update-install during onboarding. os-session-select now starts/stops the unit directly when the desktop compositor is alive, like steamos-manager does; both switch directions are instant and idempotent, which also makes the icon's double-activation per tap harmless. steamos-manager and desktop-bootstrap converge on the same unit, replacing the transient systemd-run instances and the pgrep guard they needed. Validated on AYN Thor: Steam exit auto-relaunches in ~2s into a fresh gamescope; icon and D-Bus switches instant both ways including double-fired taps; explicit stop is never restarted. Part of #28 phase 2 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011roK8EGErjD8eEjjZNNW9w --- .../systemd/user/armada-nested-gaming.service | 18 ++++++++ .../usr/libexec/armada/desktop-bootstrap | 9 ++-- system_files/usr/libexec/armada/nested-gaming | 45 ++++++++++++++++--- .../usr/libexec/armada/steamos-manager | 23 +++++++--- system_files/usr/libexec/os-session-select | 22 ++++++++- 5 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 system_files/usr/lib/systemd/user/armada-nested-gaming.service diff --git a/system_files/usr/lib/systemd/user/armada-nested-gaming.service b/system_files/usr/lib/systemd/user/armada-nested-gaming.service new file mode 100644 index 0000000..5ade17c --- /dev/null +++ b/system_files/usr/lib/systemd/user/armada-nested-gaming.service @@ -0,0 +1,18 @@ +[Unit] +Description=Nested gaming mode (gamescope + Steam on the desktop session) +# Steam exits to apply client updates and expects its wrapper to bring it +# back (the embedded session gets this from SDDM Relogin); Restart=always +# is that wrapper here. Stopping this unit is the one clean way to leave +# gaming mode (steamos-manager and os-session-select do that), so an +# explicit stop is never restarted. PartOf ties it to the desktop session: +# logout stops it instead of leaving it thrashing without a compositor. +PartOf=graphical-session.target +After=graphical-session.target +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +ExecStart=/usr/libexec/armada/nested-gaming +Restart=always +RestartSec=2 +TimeoutStopSec=15 diff --git a/system_files/usr/libexec/armada/desktop-bootstrap b/system_files/usr/libexec/armada/desktop-bootstrap index b6dda54..4ed114a 100755 --- a/system_files/usr/libexec/armada/desktop-bootstrap +++ b/system_files/usr/libexec/armada/desktop-bootstrap @@ -31,10 +31,11 @@ if [[ -n "${ARMADA_SECONDARY_TOUCHSCREEN:-}" ]]; then fi # Nested-gaming devices boot into this session with gaming on the primary -# screen; a fresh session start is either boot or an explicit return to -# gamemode (Switch to Desktop keeps the session and never re-runs this). -if [[ ${ARMADA_GAMING_SESSION:-} == nested ]] && ! pgrep -x gamescope-wl >/dev/null; then - systemd-run --user --collect /usr/libexec/armada/nested-gaming 2>/dev/null || true +# screen. reset-failed clears a start-limit lockout from a previous session +# (e.g. restart churn while the compositor was going down at logout). +if [[ ${ARMADA_GAMING_SESSION:-} == nested ]]; then + systemctl --user reset-failed armada-nested-gaming.service 2>/dev/null || true + systemctl --user start armada-nested-gaming.service 2>/dev/null || true fi config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/armada" diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index ce6dec7..a19ad33 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -1,12 +1,17 @@ #!/usr/bin/bash # Gaming mode on dual-screen devices (ARMADA_GAMING_SESSION=nested): run # gamescope+Steam as a nested fullscreen window on the primary screen of the -# already-running desktop session. steamos-manager starts and stops this; -# see docs/superpowers/specs/2026-07-07-thor-second-screen-phase2-design.md. +# already-running desktop session. Runs as armada-nested-gaming.service (user +# unit); steamos-manager and os-session-select start/stop that unit; see +# docs/superpowers/specs/2026-07-07-thor-second-screen-phase2-design.md. +# +# Steam runs as our child rather than gamescope's so that its exit ends the +# unit and Restart= brings the whole stack back up. Steam applies client +# updates by exiting and relying on its wrapper to relaunch it, sometimes +# twice in a row (the embedded session gets the same effect from SDDM +# Relogin restarting the whole gamescope session). set -euo pipefail -pgrep -x gamescope-wl >/dev/null && exit 0 - eval "$(/usr/libexec/armada/device-env)" # The desktop shows rotated panels in landscape; gamescope renders landscape. @@ -36,5 +41,33 @@ fi prefer_output=() [[ -n "${ARMADA_PRIMARY_CONNECTOR:-}" ]] && prefer_output=(--prefer-output "$ARMADA_PRIMARY_CONNECTOR") -exec gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam "${prefer_output[@]}" -- \ - /usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles +# Readiness handshake: gamescope writes its X and Wayland display names to +# the -R pipe once it is up (same mechanism gamescope-session-plus uses). +socket=$(mktemp --tmpdir -u armada-nested-gaming.XXXXXX.socket) +mkfifo -- "$socket" +trap 'rm -f -- "$socket"' EXIT + +gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam "${prefer_output[@]}" \ + -R "$socket" & +gamescope_pid=$! + +if ! read -r -t 15 x_display wl_display <>"$socket"; then + echo "nested-gaming: gamescope not ready after 15s, giving up" >&2 + kill -9 "$gamescope_pid" 2>/dev/null || true + exit 1 +fi + +# Match the environment gamescope gives a child it launches itself: X11 +# clients target the nested Xwayland, and WAYLAND_DISPLAY must not leak the +# host compositor to games. +export DISPLAY="$x_display" +export GAMESCOPE_WAYLAND_DISPLAY="$wl_display" +unset WAYLAND_DISPLAY + +status=0 +/usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles || status=$? + +# Steam is gone (client-update restart or crash); take gamescope down with +# us so the unit restart brings up a fresh pair. +kill "$gamescope_pid" 2>/dev/null || true +exit "$status" diff --git a/system_files/usr/libexec/armada/steamos-manager b/system_files/usr/libexec/armada/steamos-manager index 221d26f..8f64055 100755 --- a/system_files/usr/libexec/armada/steamos-manager +++ b/system_files/usr/libexec/armada/steamos-manager @@ -257,22 +257,33 @@ class ArmadaSteamOSManager: def switch_session(self, target): # On dual-screen devices the desktop compositor stays up and hosts - # gaming mode as a nested gamescope on the primary screen; switching - # starts or stops that gamescope instead of swapping SDDM sessions. + # gaming mode as armada-nested-gaming.service (user unit); switching + # starts or stops that unit instead of swapping SDDM sessions. # Only the session-bus instance can manage the user's session. if ( self.env.get("ARMADA_GAMING_SESSION") == "nested" and os.geteuid() != 0 and self._process_running("kwin_wayland") ): + unit = "armada-nested-gaming.service" if target == "desktop": - if self._process_running("gamescope-wl"): - subprocess.run(["pkill", "-x", "gamescope-wl"], timeout=10) + # --no-block: Steam awaits this D-Bus reply from inside the + # gamescope the stop is about to tear down. + subprocess.run( + ["systemctl", "--user", "--no-block", "stop", unit], + check=True, + timeout=10, + ) return if target == "gamemode": + # A crash-looped unit stays failed (start limit) until reset. + subprocess.run( + ["systemctl", "--user", "reset-failed", unit], + stderr=subprocess.DEVNULL, + timeout=10, + ) subprocess.run( - ["systemd-run", "--user", "--collect", - "/usr/libexec/armada/nested-gaming"], + ["systemctl", "--user", "start", unit], check=True, timeout=10, ) diff --git a/system_files/usr/libexec/os-session-select b/system_files/usr/libexec/os-session-select index 6d5a6ea..d945bcc 100644 --- a/system_files/usr/libexec/os-session-select +++ b/system_files/usr/libexec/os-session-select @@ -1,7 +1,27 @@ #!/usr/bin/bash -# Terra's steamos-session-select execs this hook (Steam "Switch to Desktop"). +# Terra's steamos-session-select execs this hook (Steam "Switch to Desktop", +# desktop "Return to Game Mode" icon). set -euo pipefail +if command -v /usr/libexec/armada/device-env >/dev/null 2>&1; then + eval "$(/usr/libexec/armada/device-env)" +fi + +# Nested-gaming devices keep the desktop session running and host gaming +# mode as a user unit; switch by starting/stopping it instead of the +# logout+relogin cycle (mirrors steamos-manager switch_session()). The +# unit start/stop is idempotent, so repeated taps on the desktop icon +# while Steam is still coming up are harmless. +if [[ ${ARMADA_GAMING_SESSION:-} == nested ]] && pgrep -x kwin_wayland >/dev/null; then + case "${1:-gamescope}" in + desktop|plasma) + exec systemctl --user --no-block stop armada-nested-gaming.service ;; + gamescope) + systemctl --user reset-failed armada-nested-gaming.service 2>/dev/null || true + exec systemctl --user start armada-nested-gaming.service ;; + esac +fi + case "${1:-gamescope}" in desktop|plasma) exec sudo /usr/libexec/armada/session-control switch-desktop ;; gamescope) exec sudo /usr/libexec/armada/session-control switch-gamemode ;; From 65886d90d79e8469e57307a1f8b205c8c76c985b Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Fri, 10 Jul 2026 23:08:27 -0500 Subject: [PATCH 12/21] nested-gaming: kill stray gamescopes when switching to desktop A gamescope-wl the unit does not own (stale instance from an older deployment, manual launch) survived SwitchToDesktopMode's unit stop and left Steam waiting on "Switching to desktop..." indefinitely. Both switch paths now pkill gamescope-wl after stopping the unit; for unit-owned processes this duplicates the SIGTERM systemd already sent, so it is harmless in the normal case. Part of #28 phase 2 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011roK8EGErjD8eEjjZNNW9w --- system_files/usr/libexec/armada/steamos-manager | 4 ++++ system_files/usr/libexec/os-session-select | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/system_files/usr/libexec/armada/steamos-manager b/system_files/usr/libexec/armada/steamos-manager index 8f64055..ff2a352 100755 --- a/system_files/usr/libexec/armada/steamos-manager +++ b/system_files/usr/libexec/armada/steamos-manager @@ -274,6 +274,10 @@ class ArmadaSteamOSManager: check=True, timeout=10, ) + # Safety net for a gamescope the unit does not own (stale + # instance, manual launch): without this it survives the + # stop and Steam hangs on "Switching to desktop...". + subprocess.run(["pkill", "-x", "gamescope-wl"], timeout=10) return if target == "gamemode": # A crash-looped unit stays failed (start limit) until reset. diff --git a/system_files/usr/libexec/os-session-select b/system_files/usr/libexec/os-session-select index d945bcc..df6a9f2 100644 --- a/system_files/usr/libexec/os-session-select +++ b/system_files/usr/libexec/os-session-select @@ -15,7 +15,10 @@ fi if [[ ${ARMADA_GAMING_SESSION:-} == nested ]] && pgrep -x kwin_wayland >/dev/null; then case "${1:-gamescope}" in desktop|plasma) - exec systemctl --user --no-block stop armada-nested-gaming.service ;; + systemctl --user --no-block stop armada-nested-gaming.service + # Same stray-gamescope safety net as steamos-manager. + pkill -x gamescope-wl || true + exit 0 ;; gamescope) systemctl --user reset-failed armada-nested-gaming.service 2>/dev/null || true exec systemctl --user start armada-nested-gaming.service ;; From 139d8de8d13c7ebe06fcab07442f97cbdf69ce65 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sat, 11 Jul 2026 20:43:06 -0500 Subject: [PATCH 13/21] Silence the kscreen screen-layout picker OSD KWin asks the kscreen OSD service to show its layout picker whenever two outputs appear in a combination it has not stored yet (kwin workspace.cpp, updateOutputConfiguration). On dual-screen devices that is the very first boot, so the picker pops up over Steam onboarding. The kded kscreen module is not involved on Wayland; KWin has no config knob for this. Mask the user service instead: KWin's D-Bus call is fire-and-forget, so the only trace is a one-line activation failure from dbus-broker. Display Settings still work; only the picker OSD (and the output identify overlay) go away. Validated on the AYN Thor: with kwinoutputconfig.json absent, the journal shows the activation request refused and no OSD appears. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ASwEJacP64yJTjo66FRdu9 --- build_files/40-vendor-system-files.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build_files/40-vendor-system-files.sh b/build_files/40-vendor-system-files.sh index 59776c4..727e4e7 100755 --- a/build_files/40-vendor-system-files.sh +++ b/build_files/40-vendor-system-files.sh @@ -53,3 +53,10 @@ systemctl mask irqbalance.service # systemd-suspend.service is overridden (drop-in) to run fake-suspend; mask the # other sleep ops so nothing reaches real suspend (it hangs this SoC). systemctl mask systemd-hibernate.service systemd-hybrid-sleep.service systemd-suspend-then-hibernate.service + +# KWin pops the kscreen screen-layout picker OSD whenever two outputs show up +# in a combination it has not stored yet - on dual-screen devices that is the +# very first boot. The layout is handled by desktop-bootstrap; the picker is +# noise on a handheld. KWin's D-Bus call to the OSD service is fire-and-forget, +# so masking the service is safe and Display Settings keep working. +systemctl --global mask plasma-kscreen-osd.service From 4c642ff7dfdac5cd9abe5e64711d520d48cd922f Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sat, 11 Jul 2026 20:43:17 -0500 Subject: [PATCH 14/21] setup-dual-screen: make the bottom screen the desktop primary Gaming mode covers the top screen, so the desktop panel and icons belong on the bottom one, DS-style. Make the secondary output the priority-1 display and scale it to 150% to match the primary. Rotate it per the new ARMADA_SECONDARY_ORIENTATION (the Thor's bottom panel is portrait-native like the top), and center it below the primary in post-scale logical coordinates. Validated on the AYN Thor: DSI-1 lands at priority 1, scale 1.5, rotation right, position 226,720 (centered under the 1280x720 top screen). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ASwEJacP64yJTjo66FRdu9 --- .../usr/lib/armada/devices/ayn-thor.conf | 1 + .../usr/lib/armada/devices/defaults.conf | 3 ++ system_files/usr/libexec/armada/device-env | 1 + .../usr/libexec/armada/setup-dual-screen | 50 +++++++++++++------ 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/system_files/usr/lib/armada/devices/ayn-thor.conf b/system_files/usr/lib/armada/devices/ayn-thor.conf index 8ac6497..c170193 100644 --- a/system_files/usr/lib/armada/devices/ayn-thor.conf +++ b/system_files/usr/lib/armada/devices/ayn-thor.conf @@ -9,6 +9,7 @@ ARMADA_PRIMARY_CONNECTOR=DSI-2 ARMADA_PRIMARY_BACKLIGHT=ae96000.dsi.0 ARMADA_PRIMARY_TOUCHSCREEN=top_touchscreen ARMADA_SECONDARY_CONNECTOR=DSI-1 +ARMADA_SECONDARY_ORIENTATION=right ARMADA_SECONDARY_TOUCHSCREEN=bottom_touchscreen ARMADA_GAMING_SESSION=nested ARMADA_PANEL_ORIENTATION=right diff --git a/system_files/usr/lib/armada/devices/defaults.conf b/system_files/usr/lib/armada/devices/defaults.conf index d4fab41..fa6950c 100644 --- a/system_files/usr/lib/armada/devices/defaults.conf +++ b/system_files/usr/lib/armada/devices/defaults.conf @@ -8,6 +8,9 @@ ARMADA_PRIMARY_CONNECTOR= ARMADA_PRIMARY_BACKLIGHT= # Set on dual-screen devices; the desktop session extends onto this output. ARMADA_SECONDARY_CONNECTOR= +# Physical mounting of the secondary panel (normal/left/right/inverted); +# setup-dual-screen rotates the output to compensate. +ARMADA_SECONDARY_ORIENTATION=normal # Input device names used to pin each touchscreen to its own output in the # desktop session (only needed when a secondary connector is set). ARMADA_PRIMARY_TOUCHSCREEN= diff --git a/system_files/usr/libexec/armada/device-env b/system_files/usr/libexec/armada/device-env index f505ceb..837e24a 100755 --- a/system_files/usr/libexec/armada/device-env +++ b/system_files/usr/libexec/armada/device-env @@ -38,6 +38,7 @@ vars=( ARMADA_PRIMARY_CONNECTOR ARMADA_PRIMARY_BACKLIGHT ARMADA_SECONDARY_CONNECTOR + ARMADA_SECONDARY_ORIENTATION ARMADA_PRIMARY_TOUCHSCREEN ARMADA_SECONDARY_TOUCHSCREEN ARMADA_GAMING_SESSION diff --git a/system_files/usr/libexec/armada/setup-dual-screen b/system_files/usr/libexec/armada/setup-dual-screen index 08afbb3..224eb22 100755 --- a/system_files/usr/libexec/armada/setup-dual-screen +++ b/system_files/usr/libexec/armada/setup-dual-screen @@ -1,9 +1,10 @@ #!/usr/bin/python3 """One-time desktop layout for dual-screen devices, run by desktop-bootstrap. -Places the secondary output below the primary (centered), makes the primary -the priority-1 output, and pins each touchscreen to its own output so taps -land on the screen being touched. +Makes the secondary (bottom) output the desktop's priority-1 display — the +panel and desktop icons belong on the lower touch screen, since gaming mode +covers the top screen — scaled and centered below the primary, and pins each +touchscreen to its own output so taps land on the screen being touched. """ import glob import json @@ -11,6 +12,12 @@ import shlex import subprocess import sys +# Matches the primary-output scale applied by desktop-bootstrap. +SECONDARY_SCALE = 1.5 + +# KScreen rotation flags. +ROTATIONS = {"normal": 1, "left": 2, "inverted": 4, "right": 8} + def device_env(): out = subprocess.run( @@ -24,7 +31,9 @@ def device_env(): return env -def logical_size(output): +def logical_size(output, scale=None, rotation=None): + """Logical size of an output, with optional overrides for a scale and + rotation that are about to be applied rather than currently in effect.""" mode = next( (m for m in output.get("modes", []) if str(m.get("id")) == str(output.get("currentModeId"))), @@ -34,10 +43,13 @@ def logical_size(output): return None width = mode["size"]["width"] height = mode["size"]["height"] + if rotation is None: + rotation = output.get("rotation") # KScreen rotation flags: 2 (left) and 8 (right) swap the axes. - if output.get("rotation") in (2, 8): + if rotation in (2, 8): width, height = height, width - scale = output.get("scale") or 1 + if scale is None: + scale = output.get("scale") or 1 return round(width / scale), round(height / scale) @@ -100,19 +112,27 @@ def main(): file=sys.stderr) return 1 + orientation = env.get("ARMADA_SECONDARY_ORIENTATION") or "normal" primary_size = logical_size(by_name[primary]) - secondary_size = logical_size(by_name[secondary]) + secondary_size = logical_size( + by_name[secondary], + scale=SECONDARY_SCALE, + rotation=ROTATIONS.get(orientation, 1), + ) if primary_size is None or secondary_size is None: return 1 x = max(0, round((primary_size[0] - secondary_size[0]) / 2)) - subprocess.run( - ["kscreen-doctor", - f"output.{primary}.priority.1", - f"output.{secondary}.priority.2", - f"output.{primary}.position.0,0", - f"output.{secondary}.position.{x},{primary_size[1]}"], - check=True, - ) + command = [ + "kscreen-doctor", + f"output.{secondary}.priority.1", + f"output.{primary}.priority.2", + f"output.{secondary}.scale.{SECONDARY_SCALE}", + f"output.{primary}.position.0,0", + f"output.{secondary}.position.{x},{primary_size[1]}", + ] + if orientation in ("left", "right", "inverted"): + command.append(f"output.{secondary}.rotation.{orientation}") + subprocess.run(command, check=True) devices = input_devices() pin_touchscreen(env.get("ARMADA_PRIMARY_TOUCHSCREEN"), primary, devices) From 931f4f4aecea91122bf1680ffcf6d004ffccdbcb Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 00:36:29 -0500 Subject: [PATCH 15/21] docs: design nested gaming MangoApp integration --- ...026-07-12-nested-gaming-mangoapp-design.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md diff --git a/docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md b/docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md new file mode 100644 index 0000000..c119722 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md @@ -0,0 +1,85 @@ +# Nested Gaming MangoApp Design + +## Problem + +The dual-screen nested gaming session starts Gamescope and Steam directly +instead of using `gamescope-session-plus`. That preserves the second screen, +but it bypasses the reference session's MangoApp initialization. Steam therefore +reports `Using mangoapp: 0`, falls back to per-game `mangohud` injection, and its +performance overlay is not displayed. + +Live diagnosis on the AYN Thor confirmed that MangoHud 0.8.4 and MangoApp run on +the aarch64 Adreno stack. A foreground MangoApp probe remained healthy inside +the active nested Gamescope session. The missing session integration, rather +than a Vulkan or architecture incompatibility, is the root cause. + +## Considered Approaches + +### 1. Integrate MangoApp into `nested-gaming` (selected) + +Mirror the relevant `gamescope-session-plus` behavior in Armada's nested +launcher: advertise MangoApp support to Steam, create the initial hidden +MangoHud configuration, and supervise MangoApp for the life of the gaming +session. This restores SteamOS behavior at the session boundary where it was +lost and applies to every game. + +### 2. Install a user-level override on the current device + +A separate user service could start MangoApp and inject variables into Steam. +This would be device-specific, vulnerable to image updates, and would split one +session lifecycle across multiple units. + +### 3. Force `mangohud` into every game launch + +Steam already attempts this fallback. It does not provide the Gamescope +external-overlay behavior expected by the Steam performance menu and is less +compatible with mixed native and containerized ARM games. + +## Design + +`system_files/usr/libexec/armada/nested-gaming` will initialize the MangoApp +contract before starting Steam: + +- Set `STEAM_USE_MANGOAPP=1` so Steam uses the external MangoApp path. +- Set `STEAM_MANGOAPP_PRESETS_SUPPORTED=1` and + `STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1` so Steam exposes the supported + preset and horizontal layouts. +- Set `STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1`, matching the packaged Steam + session because current MangoApp manages its Gamescope overlay property. +- Create a session-private configuration file under `/tmp`, export it as + `MANGOHUD_CONFIGFILE`, and initialize it with `no_display` so MangoApp does + not appear before Steam applies the selected performance-overlay level. + +After Gamescope completes its readiness handshake and the nested display +variables are exported, the launcher will start MangoApp in a restart loop. +MangoApp therefore receives the same `DISPLAY` and +`GAMESCOPE_WAYLAND_DISPLAY` as Steam. If MangoApp exits unexpectedly, it is +restarted without taking down Steam or Gamescope. + +The launcher's exit trap will remove the readiness FIFO and MangoHud +configuration file. Background processes remain in the systemd service's +cgroup and are terminated with the unit, matching the existing Gamescope and +refresh-bridge lifecycle. + +## Testing + +A shell regression test will inspect the launcher contract without requiring a +real compositor. It will fail on the current branch because the capability +variables, hidden initial configuration, and MangoApp supervision are absent. +After implementation it will verify those behaviors and run Bash syntax +validation. + +Live verification on the Thor will then: + +1. Deploy the launcher to `/usr/libexec/armada/nested-gaming` with a backup. +2. Restart only `armada-nested-gaming.service`. +3. Confirm Steam logs `Using mangoapp: 1`, the MangoApp process remains alive, + and the Gamescope external-overlay window exists. +4. Launch Mina the Hollower (Steam app 1875580), select a nonzero performance + overlay level, and confirm the overlay remains active for the game. + +## Scope + +This change fixes only MangoApp integration in the nested dual-screen gaming +session. It does not alter the embedded Gamescope session, MangoHud packaging, +Steam's game launch commands, or refresh-rate bridging. From dd60d39fcd9fbbbeb07f726d482c90f0a88faf57 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 00:39:46 -0500 Subject: [PATCH 16/21] docs: add nested gaming MangoApp implementation plan --- .../2026-07-12-nested-gaming-mangoapp.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md diff --git a/docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md b/docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md new file mode 100644 index 0000000..2b4ab84 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md @@ -0,0 +1,190 @@ +# Nested Gaming MangoApp Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore Steam's performance overlay in the dual-screen nested gaming session by starting MangoApp with the same session contract as `gamescope-session-plus`. + +**Architecture:** The existing `nested-gaming` launcher remains the lifecycle owner for Gamescope, MangoApp, and Steam. It will advertise MangoApp support before Steam starts, create a private initially-hidden MangoHud config, and run MangoApp after Gamescope supplies the nested display names. A shell contract test locks down those integration points without requiring a compositor in CI. + +**Tech Stack:** Bash, systemd user services, Gamescope, MangoHud/MangoApp, Steam for Linux ARM64 + +## Global Constraints + +- Make the durable change directly on `thor-nested-gaming`. +- Match the MangoApp behavior already shipped by `gamescope-session-plus`. +- Do not modify embedded Gamescope sessions, game-specific launch options, packages, or refresh-rate bridging. +- Preserve unrelated untracked files under `docs/` and `hack/`. + +--- + +### Task 1: Add the nested-session MangoApp contract + +**Files:** +- Create: `tests/test-nested-gaming-mangoapp.sh` +- Modify: `system_files/usr/libexec/armada/nested-gaming` + +**Interfaces:** +- Consumes: Gamescope readiness values `x_display` and `wl_display`; the installed `mangoapp` command; Steam's `MANGOHUD_CONFIGFILE` control protocol. +- Produces: Steam environment flags for MangoApp support, a session-private initially-hidden MangoHud config, and a supervised MangoApp process using the nested display. + +- [ ] **Step 1: Write the failing contract test** + +```bash +#!/usr/bin/bash +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +launcher="${repo_root}/system_files/usr/libexec/armada/nested-gaming" +bash -n "$launcher" + +require() { + local contract=$1 literal=$2 + grep -Fq -- "$literal" "$launcher" || { + printf 'missing MangoApp contract: %s\n' "$contract" >&2 + exit 1 + } +} + +require 'Steam uses MangoApp' 'export STEAM_USE_MANGOAPP=1' +require 'Steam exposes MangoApp presets' 'export STEAM_MANGOAPP_PRESETS_SUPPORTED=1' +require 'Steam exposes horizontal MangoApp' 'export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1' +require 'Steam uses MangoApp overlay property handling' 'export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1' +require 'session exports a private MangoHud config' 'export MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)' +require 'MangoApp starts hidden' 'echo "no_display" >"$MANGOHUD_CONFIGFILE"' +require 'MangoApp is supervised' 'while true; do' +require 'MangoApp is launched' 'mangoapp' +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +chmod +x tests/test-nested-gaming-mangoapp.sh +tests/test-nested-gaming-mangoapp.sh +``` + +Expected: exit 1 with `missing MangoApp contract: Steam uses MangoApp`. + +- [ ] **Step 3: Add the minimal session integration** + +Immediately after `device-env`, add: + +```bash +export STEAM_USE_MANGOAPP=1 +export STEAM_MANGOAPP_PRESETS_SUPPORTED=1 +export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1 +export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1 + +export MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX) +echo "no_display" >"$MANGOHUD_CONFIGFILE" +``` + +Extend the existing exit trap: + +```bash +trap 'rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"' EXIT +``` + +Immediately after exporting the nested display variables, add: + +```bash +if command -v mangoapp >/dev/null; then + (while true; do + mangoapp + done) & +fi +``` + +- [ ] **Step 4: Run focused verification and verify GREEN** + +```bash +tests/test-nested-gaming-mangoapp.sh +bash -n system_files/usr/libexec/armada/nested-gaming +git diff --check +``` + +Expected: all commands exit 0 with no output. + +- [ ] **Step 5: Commit the regression fix** + +```bash +git add tests/test-nested-gaming-mangoapp.sh system_files/usr/libexec/armada/nested-gaming +git commit -m "fix: enable MangoApp in nested gaming" +``` + +### Task 2: Deploy and verify on the AYN Thor + +**Files:** +- Deploy: `system_files/usr/libexec/armada/nested-gaming` to `/usr/libexec/armada/nested-gaming` +- Backup: device path `/tmp/nested-gaming.before-mangoapp` + +**Interfaces:** +- Consumes: passwordless SSH and sudo; `armada-nested-gaming.service`; Steam app 1875580. +- Produces: a live session where Steam reports MangoApp enabled and Mina displays the selected overlay. + +- [ ] **Step 1: Back up and deploy the launcher** + +```bash +ssh -F /dev/null armada@192.168.86.35 'cp /usr/libexec/armada/nested-gaming /tmp/nested-gaming.before-mangoapp' +scp -F /dev/null system_files/usr/libexec/armada/nested-gaming armada@192.168.86.35:/tmp/nested-gaming.with-mangoapp +ssh -F /dev/null armada@192.168.86.35 'sudo install -o root -g root -m 0755 /tmp/nested-gaming.with-mangoapp /usr/libexec/armada/nested-gaming' +``` + +Expected: all commands exit 0. + +- [ ] **Step 2: Restart only nested gaming and assert session startup** + +```bash +ssh -F /dev/null armada@192.168.86.35 'systemctl --user restart armada-nested-gaming.service' +ssh -F /dev/null armada@192.168.86.35 'for i in $(seq 1 30); do pgrep -x mangoapp >/dev/null && pgrep -x steam >/dev/null && exit 0; sleep 1; done; exit 1' +``` + +Expected: MangoApp and Steam become live within 30 seconds. + +- [ ] **Step 3: Assert Steam selected the MangoApp path** + +```bash +ssh -F /dev/null armada@192.168.86.35 'grep -F "Using mangoapp: 1" "$HOME/.local/share/Steam/logs/systemperfmanager.txt" | tail -1' +``` + +Expected: a line from the new session containing `Using mangoapp: 1`. + +- [ ] **Step 4: Launch Mina and assert MangoApp remains active** + +```bash +ssh -F /dev/null armada@192.168.86.35 ' +steam_pid=$(pgrep -x steam | head -1) +display=$(tr "\0" "\n" <"/proc/${steam_pid}/environ" | sed -n "s/^DISPLAY=//p") +gamescope_display=$(tr "\0" "\n" <"/proc/${steam_pid}/environ" | sed -n "s/^GAMESCOPE_WAYLAND_DISPLAY=//p") +DISPLAY="$display" GAMESCOPE_WAYLAND_DISPLAY="$gamescope_display" /usr/libexec/armada/launch-steam steam://rungameid/1875580 +' +``` + +Then poll: + +```bash +ssh -F /dev/null armada@192.168.86.35 ' +for i in $(seq 1 60); do + if pgrep -f "/Mina the Hollower/MinaTheHollower" >/dev/null; then + pgrep -x mangoapp >/dev/null + exit + fi + sleep 1 +done +exit 1 +' +``` + +Expected: Mina launches within 60 seconds, MangoApp stays alive, and the device visibly shows the persisted nonzero overlay. + +- [ ] **Step 5: Run final verification** + +```bash +tests/test-nested-gaming-mangoapp.sh +bash -n system_files/usr/libexec/armada/nested-gaming +git diff --check +git status --short +``` + +Expected: checks exit 0 and status preserves only unrelated untracked files. From dba8f88ef8df237b0a159247ff39cb13497494fe Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 00:41:57 -0500 Subject: [PATCH 17/21] fix: enable MangoApp in nested gaming --- system_files/usr/libexec/armada/nested-gaming | 23 ++++++++++++++- tests/test-nested-gaming-mangoapp.sh | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100755 tests/test-nested-gaming-mangoapp.sh diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index a19ad33..2b9d42c 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -14,6 +14,19 @@ set -euo pipefail eval "$(/usr/libexec/armada/device-env)" +# Advertise the external MangoApp overlay path and the layouts supported by +# the installed MangoHud package. These mirror gamescope-session-steam. +export STEAM_USE_MANGOAPP=1 +export STEAM_MANGOAPP_PRESETS_SUPPORTED=1 +export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1 +export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1 + +# Steam rewrites this file when the performance-overlay level changes. Keep +# MangoApp hidden until Steam has restored the selected level. +MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX) +export MANGOHUD_CONFIGFILE +echo "no_display" >"$MANGOHUD_CONFIGFILE" + # The desktop shows rotated panels in landscape; gamescope renders landscape. width="${ARMADA_PANEL_NATIVE_WIDTH:-1080}" height="${ARMADA_PANEL_NATIVE_HEIGHT:-1920}" @@ -45,7 +58,7 @@ prefer_output=() # the -R pipe once it is up (same mechanism gamescope-session-plus uses). socket=$(mktemp --tmpdir -u armada-nested-gaming.XXXXXX.socket) mkfifo -- "$socket" -trap 'rm -f -- "$socket"' EXIT +trap 'rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"' EXIT gamescope -e --fullscreen -W "$gs_w" -H "$gs_h" --steam "${prefer_output[@]}" \ -R "$socket" & @@ -64,6 +77,14 @@ export DISPLAY="$x_display" export GAMESCOPE_WAYLAND_DISPLAY="$wl_display" unset WAYLAND_DISPLAY +# MangoApp must target Gamescope's nested Xwayland. Restart it if it exits so +# one overlay failure does not require restarting Steam or Gamescope. +if command -v mangoapp >/dev/null; then + (while true; do + mangoapp + done) & +fi + status=0 /usr/libexec/armada/launch-steam -gamepadui -steamos3 -steampal -steamdeck -noverifyfiles || status=$? diff --git a/tests/test-nested-gaming-mangoapp.sh b/tests/test-nested-gaming-mangoapp.sh new file mode 100755 index 0000000..66c1d07 --- /dev/null +++ b/tests/test-nested-gaming-mangoapp.sh @@ -0,0 +1,29 @@ +#!/usr/bin/bash +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +launcher="${repo_root}/system_files/usr/libexec/armada/nested-gaming" +bash -n "$launcher" + +require() { + local contract=$1 literal=$2 + grep -Fq -- "$literal" "$launcher" || { + printf 'missing MangoApp contract: %s\n' "$contract" >&2 + exit 1 + } +} + +require 'Steam uses MangoApp' 'export STEAM_USE_MANGOAPP=1' +require 'Steam exposes MangoApp presets' 'export STEAM_MANGOAPP_PRESETS_SUPPORTED=1' +require 'Steam exposes horizontal MangoApp' 'export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1' +require 'Steam uses MangoApp overlay property handling' 'export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1' +# These are literal source contracts, not expressions to expand in this test. +# shellcheck disable=SC2016 +require 'session creates a private MangoHud config' 'MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)' +require 'session exports its MangoHud config' 'export MANGOHUD_CONFIGFILE' +# shellcheck disable=SC2016 +require 'MangoApp starts hidden' 'echo "no_display" >"$MANGOHUD_CONFIGFILE"' +# shellcheck disable=SC2016 +require 'session removes the private MangoHud config' 'trap '\''rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"'\'' EXIT' +require 'MangoApp is supervised' 'while true; do' +require 'MangoApp is launched' 'mangoapp' From f5270c4a923ce9824391abd3d0fec38d53dc1829 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 00:45:04 -0500 Subject: [PATCH 18/21] fix: wait for nested Xwayland before MangoApp --- system_files/usr/libexec/armada/nested-gaming | 12 +++++++++--- tests/test-nested-gaming-mangoapp.sh | 5 ++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index 2b9d42c..d50a5e5 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -80,9 +80,15 @@ unset WAYLAND_DISPLAY # MangoApp must target Gamescope's nested Xwayland. Restart it if it exits so # one overlay failure does not require restarting Steam or Gamescope. if command -v mangoapp >/dev/null; then - (while true; do - mangoapp - done) & + ( + until xprop -display "$DISPLAY" -root >/dev/null 2>&1; do + sleep 0.1 + done + while true; do + mangoapp || true + sleep 1 + done + ) & fi status=0 diff --git a/tests/test-nested-gaming-mangoapp.sh b/tests/test-nested-gaming-mangoapp.sh index 66c1d07..a2df59c 100755 --- a/tests/test-nested-gaming-mangoapp.sh +++ b/tests/test-nested-gaming-mangoapp.sh @@ -25,5 +25,8 @@ require 'session exports its MangoHud config' 'export MANGOHUD_CONFIGFILE' require 'MangoApp starts hidden' 'echo "no_display" >"$MANGOHUD_CONFIGFILE"' # shellcheck disable=SC2016 require 'session removes the private MangoHud config' 'trap '\''rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"'\'' EXIT' +# shellcheck disable=SC2016 +require 'MangoApp waits for nested Xwayland' 'until xprop -display "$DISPLAY" -root >/dev/null 2>&1; do' require 'MangoApp is supervised' 'while true; do' -require 'MangoApp is launched' 'mangoapp' +require 'MangoApp failures do not defeat supervision' 'mangoapp || true' +require 'MangoApp restart is rate limited' 'sleep 1' From 3d56b1a4316a0a564319a13c5008a7491487b2b6 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 00:48:49 -0500 Subject: [PATCH 19/21] fix: force X11 for nested gamescope clients --- system_files/usr/libexec/armada/nested-gaming | 7 ++++--- tests/test-nested-gaming-mangoapp.sh | 3 +-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/system_files/usr/libexec/armada/nested-gaming b/system_files/usr/libexec/armada/nested-gaming index d50a5e5..5cad90e 100755 --- a/system_files/usr/libexec/armada/nested-gaming +++ b/system_files/usr/libexec/armada/nested-gaming @@ -14,6 +14,10 @@ set -euo pipefail eval "$(/usr/libexec/armada/device-env)" +# The host desktop session is Wayland, but clients inside nested Gamescope use +# Xwayland. This also prevents GLFW applications from selecting host Wayland. +export XDG_SESSION_TYPE=x11 + # Advertise the external MangoApp overlay path and the layouts supported by # the installed MangoHud package. These mirror gamescope-session-steam. export STEAM_USE_MANGOAPP=1 @@ -81,9 +85,6 @@ unset WAYLAND_DISPLAY # one overlay failure does not require restarting Steam or Gamescope. if command -v mangoapp >/dev/null; then ( - until xprop -display "$DISPLAY" -root >/dev/null 2>&1; do - sleep 0.1 - done while true; do mangoapp || true sleep 1 diff --git a/tests/test-nested-gaming-mangoapp.sh b/tests/test-nested-gaming-mangoapp.sh index a2df59c..f01c6f9 100755 --- a/tests/test-nested-gaming-mangoapp.sh +++ b/tests/test-nested-gaming-mangoapp.sh @@ -17,6 +17,7 @@ require 'Steam uses MangoApp' 'export STEAM_USE_MANGOAPP=1' require 'Steam exposes MangoApp presets' 'export STEAM_MANGOAPP_PRESETS_SUPPORTED=1' require 'Steam exposes horizontal MangoApp' 'export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1' require 'Steam uses MangoApp overlay property handling' 'export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1' +require 'nested clients select X11' 'export XDG_SESSION_TYPE=x11' # These are literal source contracts, not expressions to expand in this test. # shellcheck disable=SC2016 require 'session creates a private MangoHud config' 'MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)' @@ -25,8 +26,6 @@ require 'session exports its MangoHud config' 'export MANGOHUD_CONFIGFILE' require 'MangoApp starts hidden' 'echo "no_display" >"$MANGOHUD_CONFIGFILE"' # shellcheck disable=SC2016 require 'session removes the private MangoHud config' 'trap '\''rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"'\'' EXIT' -# shellcheck disable=SC2016 -require 'MangoApp waits for nested Xwayland' 'until xprop -display "$DISPLAY" -root >/dev/null 2>&1; do' require 'MangoApp is supervised' 'while true; do' require 'MangoApp failures do not defeat supervision' 'mangoapp || true' require 'MangoApp restart is rate limited' 'sleep 1' From 990855f126163b242ead76f5da66abbb2c9d2694 Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 01:18:53 -0500 Subject: [PATCH 20/21] Delete docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md --- .../2026-07-12-nested-gaming-mangoapp.md | 190 ------------------ 1 file changed, 190 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md diff --git a/docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md b/docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md deleted file mode 100644 index 2b4ab84..0000000 --- a/docs/superpowers/plans/2026-07-12-nested-gaming-mangoapp.md +++ /dev/null @@ -1,190 +0,0 @@ -# Nested Gaming MangoApp Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Restore Steam's performance overlay in the dual-screen nested gaming session by starting MangoApp with the same session contract as `gamescope-session-plus`. - -**Architecture:** The existing `nested-gaming` launcher remains the lifecycle owner for Gamescope, MangoApp, and Steam. It will advertise MangoApp support before Steam starts, create a private initially-hidden MangoHud config, and run MangoApp after Gamescope supplies the nested display names. A shell contract test locks down those integration points without requiring a compositor in CI. - -**Tech Stack:** Bash, systemd user services, Gamescope, MangoHud/MangoApp, Steam for Linux ARM64 - -## Global Constraints - -- Make the durable change directly on `thor-nested-gaming`. -- Match the MangoApp behavior already shipped by `gamescope-session-plus`. -- Do not modify embedded Gamescope sessions, game-specific launch options, packages, or refresh-rate bridging. -- Preserve unrelated untracked files under `docs/` and `hack/`. - ---- - -### Task 1: Add the nested-session MangoApp contract - -**Files:** -- Create: `tests/test-nested-gaming-mangoapp.sh` -- Modify: `system_files/usr/libexec/armada/nested-gaming` - -**Interfaces:** -- Consumes: Gamescope readiness values `x_display` and `wl_display`; the installed `mangoapp` command; Steam's `MANGOHUD_CONFIGFILE` control protocol. -- Produces: Steam environment flags for MangoApp support, a session-private initially-hidden MangoHud config, and a supervised MangoApp process using the nested display. - -- [ ] **Step 1: Write the failing contract test** - -```bash -#!/usr/bin/bash -set -euo pipefail - -repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -launcher="${repo_root}/system_files/usr/libexec/armada/nested-gaming" -bash -n "$launcher" - -require() { - local contract=$1 literal=$2 - grep -Fq -- "$literal" "$launcher" || { - printf 'missing MangoApp contract: %s\n' "$contract" >&2 - exit 1 - } -} - -require 'Steam uses MangoApp' 'export STEAM_USE_MANGOAPP=1' -require 'Steam exposes MangoApp presets' 'export STEAM_MANGOAPP_PRESETS_SUPPORTED=1' -require 'Steam exposes horizontal MangoApp' 'export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1' -require 'Steam uses MangoApp overlay property handling' 'export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1' -require 'session exports a private MangoHud config' 'export MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX)' -require 'MangoApp starts hidden' 'echo "no_display" >"$MANGOHUD_CONFIGFILE"' -require 'MangoApp is supervised' 'while true; do' -require 'MangoApp is launched' 'mangoapp' -``` - -- [ ] **Step 2: Run the test and verify RED** - -Run: - -```bash -chmod +x tests/test-nested-gaming-mangoapp.sh -tests/test-nested-gaming-mangoapp.sh -``` - -Expected: exit 1 with `missing MangoApp contract: Steam uses MangoApp`. - -- [ ] **Step 3: Add the minimal session integration** - -Immediately after `device-env`, add: - -```bash -export STEAM_USE_MANGOAPP=1 -export STEAM_MANGOAPP_PRESETS_SUPPORTED=1 -export STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1 -export STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1 - -export MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX) -echo "no_display" >"$MANGOHUD_CONFIGFILE" -``` - -Extend the existing exit trap: - -```bash -trap 'rm -f -- "$socket" "$MANGOHUD_CONFIGFILE"' EXIT -``` - -Immediately after exporting the nested display variables, add: - -```bash -if command -v mangoapp >/dev/null; then - (while true; do - mangoapp - done) & -fi -``` - -- [ ] **Step 4: Run focused verification and verify GREEN** - -```bash -tests/test-nested-gaming-mangoapp.sh -bash -n system_files/usr/libexec/armada/nested-gaming -git diff --check -``` - -Expected: all commands exit 0 with no output. - -- [ ] **Step 5: Commit the regression fix** - -```bash -git add tests/test-nested-gaming-mangoapp.sh system_files/usr/libexec/armada/nested-gaming -git commit -m "fix: enable MangoApp in nested gaming" -``` - -### Task 2: Deploy and verify on the AYN Thor - -**Files:** -- Deploy: `system_files/usr/libexec/armada/nested-gaming` to `/usr/libexec/armada/nested-gaming` -- Backup: device path `/tmp/nested-gaming.before-mangoapp` - -**Interfaces:** -- Consumes: passwordless SSH and sudo; `armada-nested-gaming.service`; Steam app 1875580. -- Produces: a live session where Steam reports MangoApp enabled and Mina displays the selected overlay. - -- [ ] **Step 1: Back up and deploy the launcher** - -```bash -ssh -F /dev/null armada@192.168.86.35 'cp /usr/libexec/armada/nested-gaming /tmp/nested-gaming.before-mangoapp' -scp -F /dev/null system_files/usr/libexec/armada/nested-gaming armada@192.168.86.35:/tmp/nested-gaming.with-mangoapp -ssh -F /dev/null armada@192.168.86.35 'sudo install -o root -g root -m 0755 /tmp/nested-gaming.with-mangoapp /usr/libexec/armada/nested-gaming' -``` - -Expected: all commands exit 0. - -- [ ] **Step 2: Restart only nested gaming and assert session startup** - -```bash -ssh -F /dev/null armada@192.168.86.35 'systemctl --user restart armada-nested-gaming.service' -ssh -F /dev/null armada@192.168.86.35 'for i in $(seq 1 30); do pgrep -x mangoapp >/dev/null && pgrep -x steam >/dev/null && exit 0; sleep 1; done; exit 1' -``` - -Expected: MangoApp and Steam become live within 30 seconds. - -- [ ] **Step 3: Assert Steam selected the MangoApp path** - -```bash -ssh -F /dev/null armada@192.168.86.35 'grep -F "Using mangoapp: 1" "$HOME/.local/share/Steam/logs/systemperfmanager.txt" | tail -1' -``` - -Expected: a line from the new session containing `Using mangoapp: 1`. - -- [ ] **Step 4: Launch Mina and assert MangoApp remains active** - -```bash -ssh -F /dev/null armada@192.168.86.35 ' -steam_pid=$(pgrep -x steam | head -1) -display=$(tr "\0" "\n" <"/proc/${steam_pid}/environ" | sed -n "s/^DISPLAY=//p") -gamescope_display=$(tr "\0" "\n" <"/proc/${steam_pid}/environ" | sed -n "s/^GAMESCOPE_WAYLAND_DISPLAY=//p") -DISPLAY="$display" GAMESCOPE_WAYLAND_DISPLAY="$gamescope_display" /usr/libexec/armada/launch-steam steam://rungameid/1875580 -' -``` - -Then poll: - -```bash -ssh -F /dev/null armada@192.168.86.35 ' -for i in $(seq 1 60); do - if pgrep -f "/Mina the Hollower/MinaTheHollower" >/dev/null; then - pgrep -x mangoapp >/dev/null - exit - fi - sleep 1 -done -exit 1 -' -``` - -Expected: Mina launches within 60 seconds, MangoApp stays alive, and the device visibly shows the persisted nonzero overlay. - -- [ ] **Step 5: Run final verification** - -```bash -tests/test-nested-gaming-mangoapp.sh -bash -n system_files/usr/libexec/armada/nested-gaming -git diff --check -git status --short -``` - -Expected: checks exit 0 and status preserves only unrelated untracked files. From afa3a49e6034e3343c03ce6c326c571194453c8a Mon Sep 17 00:00:00 2001 From: Bryce Thomas Date: Sun, 12 Jul 2026 01:19:06 -0500 Subject: [PATCH 21/21] Delete docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md --- ...026-07-12-nested-gaming-mangoapp-design.md | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md diff --git a/docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md b/docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md deleted file mode 100644 index c119722..0000000 --- a/docs/superpowers/specs/2026-07-12-nested-gaming-mangoapp-design.md +++ /dev/null @@ -1,85 +0,0 @@ -# Nested Gaming MangoApp Design - -## Problem - -The dual-screen nested gaming session starts Gamescope and Steam directly -instead of using `gamescope-session-plus`. That preserves the second screen, -but it bypasses the reference session's MangoApp initialization. Steam therefore -reports `Using mangoapp: 0`, falls back to per-game `mangohud` injection, and its -performance overlay is not displayed. - -Live diagnosis on the AYN Thor confirmed that MangoHud 0.8.4 and MangoApp run on -the aarch64 Adreno stack. A foreground MangoApp probe remained healthy inside -the active nested Gamescope session. The missing session integration, rather -than a Vulkan or architecture incompatibility, is the root cause. - -## Considered Approaches - -### 1. Integrate MangoApp into `nested-gaming` (selected) - -Mirror the relevant `gamescope-session-plus` behavior in Armada's nested -launcher: advertise MangoApp support to Steam, create the initial hidden -MangoHud configuration, and supervise MangoApp for the life of the gaming -session. This restores SteamOS behavior at the session boundary where it was -lost and applies to every game. - -### 2. Install a user-level override on the current device - -A separate user service could start MangoApp and inject variables into Steam. -This would be device-specific, vulnerable to image updates, and would split one -session lifecycle across multiple units. - -### 3. Force `mangohud` into every game launch - -Steam already attempts this fallback. It does not provide the Gamescope -external-overlay behavior expected by the Steam performance menu and is less -compatible with mixed native and containerized ARM games. - -## Design - -`system_files/usr/libexec/armada/nested-gaming` will initialize the MangoApp -contract before starting Steam: - -- Set `STEAM_USE_MANGOAPP=1` so Steam uses the external MangoApp path. -- Set `STEAM_MANGOAPP_PRESETS_SUPPORTED=1` and - `STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1` so Steam exposes the supported - preset and horizontal layouts. -- Set `STEAM_DISABLE_MANGOAPP_ATOM_WORKAROUND=1`, matching the packaged Steam - session because current MangoApp manages its Gamescope overlay property. -- Create a session-private configuration file under `/tmp`, export it as - `MANGOHUD_CONFIGFILE`, and initialize it with `no_display` so MangoApp does - not appear before Steam applies the selected performance-overlay level. - -After Gamescope completes its readiness handshake and the nested display -variables are exported, the launcher will start MangoApp in a restart loop. -MangoApp therefore receives the same `DISPLAY` and -`GAMESCOPE_WAYLAND_DISPLAY` as Steam. If MangoApp exits unexpectedly, it is -restarted without taking down Steam or Gamescope. - -The launcher's exit trap will remove the readiness FIFO and MangoHud -configuration file. Background processes remain in the systemd service's -cgroup and are terminated with the unit, matching the existing Gamescope and -refresh-bridge lifecycle. - -## Testing - -A shell regression test will inspect the launcher contract without requiring a -real compositor. It will fail on the current branch because the capability -variables, hidden initial configuration, and MangoApp supervision are absent. -After implementation it will verify those behaviors and run Bash syntax -validation. - -Live verification on the Thor will then: - -1. Deploy the launcher to `/usr/libexec/armada/nested-gaming` with a backup. -2. Restart only `armada-nested-gaming.service`. -3. Confirm Steam logs `Using mangoapp: 1`, the MangoApp process remains alive, - and the Gamescope external-overlay window exists. -4. Launch Mina the Hollower (Steam app 1875580), select a nonzero performance - overlay level, and confirm the overlay remains active for the game. - -## Scope - -This change fixes only MangoApp integration in the nested dual-screen gaming -session. It does not alter the embedded Gamescope session, MangoHud packaging, -Steam's game launch commands, or refresh-rate bridging.