From d207b35d9468f09cc996c82dc405f9e3454e7602 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Sun, 11 Oct 2026 15:53:01 -0400 Subject: [PATCH] Red Dead Redemption 2: launches again, no screensaver over games, hidden launcher held to one core, /tmp cleaned (#585) * fix: Red Dead Redemption 2 launches again, its Vulkan runtime installer is left to run droiddeck-seed-redists marked every process in every game's install script as already run, Red Dead Redemption 2's VulkanRT-1.1.108.0 installer included. Steam's compat config for the game says nativevulkanloader, so Proton loads vulkan-1.dll native only, and Khronos' loader is in the prefix only once that installer has run. Without it the Rockstar launcher starts RDR2.exe and it exits with STATUS_DLL_NOT_FOUND (0xc0000135) before it opens a window. A Vulkan runtime installer is now left out of the seeding, and a marker an earlier seeding wrote for one comes out of each prefix and Proton template once (dates kept), so the client runs the installer on the next launch. The installer finishes in a few seconds under FEX. * fix: Steam's screensaver no longer covers a running game The client's defaults show screensavers while a game runs, after 300 s on AC power without a button press - and a held stick or trigger is no press. Red Dead Redemption 2's intro runs longer than that: the slideshow covered the game, and the button that woke it reached the game too (B asked to quit to the desktop). Customization/Screensaver/WhileInGame is seeded "0" in config.vdf once, like the UI scale and native resolution, so turning it back on in Settings stays the user's choice. On the RedMagic: 18 minutes from the last button press through the intro and riding, focus never left the game and no screensaver was created. * fix: Rockstar's hidden launcher no longer takes cores from its game The Rockstar Games Launcher keeps drawing its Chromium interface (SocialClubHelper.exe: renderers, and a GPU process compositing through DXVK) for as long as the game runs, hidden behind it - and Wine passes Chromium's priorities on, so those threads ran above the game's (the GPU process at nice -10), across every core. With Red Dead Redemption 2 in front that was 1.3 to 2.5 cores under FEX for a page nobody sees. droiddeck-launcher-cores follows gamescope's focus from the agent's focus.json: while a game window has it, every SocialClubHelper thread runs on the last core of the slowest cluster at nice 19; when the launcher's own window has focus again each thread gets its own mask and priority back (also on exit). Stopping the helpers would not hold - proot resumes a stopped tracee. Started for Steam sessions; BL_LAUNCHER_CORES=0 turns it off. RedMagic, RDR2 all Low, riding at the thermal limit, same spot and session: released 46.5 fps, 1% low 27.5, p99 31.8 ms, CPU 89%, 2200 MHz; held 48.7 fps, 1% low 32.6, p99 27.6 ms, CPU 79%, 2638 MHz. * fix: sessions clean up what earlier ones left in /tmp The guest's /tmp is a directory in the rootfs that no boot ever empties, and Android ends sessions by killing them. Every session left a /tmp/mangohud.XXXXXXXX (mktemp, never removed), and the Steam client, its web helper, Chromium apps, dbus-run-session, AppImage runs and KDE their own temporary files and sockets: 1,422 entries on the RedMagic. mangoapp's config is now one fixed file rewritten each session, and the session's first pass sweeps each kind of leftover while nothing that could own one is running, with a single find. On the device that cleared 1,391 entries over two sessions, leaving 38. --- .../launcher/session/SessionFiles.kt | 1 + .../overlay/usr/local/bin/droiddeck-game-env | 5 +- .../usr/local/bin/droiddeck-launcher-cores | 230 ++++++++++++++++++ .../usr/local/bin/droiddeck-seed-redists | 72 +++++- .../overlay/usr/local/bin/droiddeck-session | 41 +++- .../usr/local/bin/droiddeck-steam-ui-scale | 98 +++++--- tools/tests/test_launcher_cores.py | 220 +++++++++++++++++ tools/tests/test_seed_redists.py | 63 +++++ tools/tests/test_session_tmp.py | 95 ++++++++ tools/tests/test_steam_ui_scale.py | 59 +++++ 10 files changed, 841 insertions(+), 43 deletions(-) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores create mode 100644 tools/tests/test_launcher_cores.py create mode 100644 tools/tests/test_session_tmp.py diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index f69e4c837..9bc9fc195 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -72,6 +72,7 @@ object SessionFiles { "usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui", "usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library", "usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists", + "usr/local/bin/droiddeck-launcher-cores" to "usr/local/bin/droiddeck-launcher-cores", "usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra", "usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager", "usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1", diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 067212e34..792a7676a 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -255,8 +255,9 @@ EVALUATOR_SCRIPT = re.compile(r"(?:^|[\\/])evaluatorscript_[0-9]+\.vdf$", re.IGN def mark_install_scripts(command, env): """The client is about to run a game's install scripts (iscriptevaluator.exe): mark every process - in them as already run first, so no redistributable installer starts - on first launch the - shared ones may still be downloading, and on ARM64 Proton the VC++ ones stop with an error.""" + in them but a Vulkan runtime installer as already run first, so no redistributable installer + starts - on first launch the shared ones may still be downloading, and on ARM64 Proton the VC++ + ones stop with an error.""" if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command): return root = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores b/tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores new file mode 100755 index 000000000..dad3d1033 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-launcher-cores @@ -0,0 +1,230 @@ +#!/usr/bin/python3 +"""Holds a game launcher's hidden web interface on one slow core while something else has focus. + +Rockstar's launcher keeps drawing its Chromium interface (SocialClubHelper.exe: a renderer, and a +GPU process compositing through DXVK) for as long as it runs, hidden behind the game too, and at a +higher priority than the game's (Wine passes Chromium's on: the GPU process ran at nice -10). With +Red Dead Redemption 2 in front on the RedMagic that was 1.3 to 2.5 cores under FEX, on a phone +already held back by its temperature, for a page nobody sees. Held, riding at the throttle limit +went from 46.5 to 48.7 fps, 1% lows from 27.5 to 32.6. So while the focused window belongs to +anything but the launcher, every helper thread runs on one core of the slowest cluster at nice 19; +when the launcher's own window has focus again (signing in, the game closed) each thread gets its +own mask and priority back. Stopping the helpers instead would not hold: proot, the tracer of every +guest process, resumes a stopped tracee. + +Focus comes from droiddeck-agent's focus.json (the path is the argument), which the agent rewrites +whenever gamescope's focus changes. droiddeck-session starts this for Steam sessions; +BL_LAUNCHER_CORES=0 leaves the launchers alone.""" +import json +import os +import signal +import sys +import time +from pathlib import Path + +# Image paths (lower case, backslashes), matched at the end: the helpers held back, and the +# programs whose window having focus means the user is looking at the launcher. +HELPERS = ("\\rockstar games\\social club\\socialclubhelper.exe",) +FRONTS = HELPERS + ("\\rockstar games\\launcher\\launcher.exe",) +NICE = 19 +POLL = 2.0 +# How often a held launcher is looked over again: for a helper the unheld Launcher.exe started +# since, and for threads the program has moved back. +RESCAN = 10.0 +STEAM_CLIENT = 769 + + +def log(message): + print("droiddeck-launcher-cores: " + message, flush=True) + + +def image(proc, pid): + """A process's program as Wine names it (argv[0]), lower case with backslashes, or None.""" + try: + with open(proc / str(pid) / "cmdline", "rb") as stream: + first = stream.read(4096).split(b"\0", 1)[0] + except OSError: + return None + return first.decode("utf-8", "replace").lower().replace("/", "\\") + + +def is_one_of(path, suffixes): + return path is not None and path.endswith(suffixes) + + +def tasks(proc, pid): + try: + return [int(tid) for tid in os.listdir(proc / str(pid) / "task") if tid.isdigit()] + except OSError: + return [] + + +class Cores: + """The kernel calls, apart so a test can stand in for them.""" + + def own(self): + return os.sched_getaffinity(0) + + def get(self, tid): + return os.sched_getaffinity(tid), os.getpriority(os.PRIO_PROCESS, tid) + + def set(self, tid, cpus, nice): + try: + os.sched_setaffinity(tid, cpus) + except OSError: + # A mask outside the app's cpuset now (Android shrinks it in the background) is + # EINVAL; the session's own is always allowed. + os.sched_setaffinity(tid, self.own()) + os.setpriority(os.PRIO_PROCESS, tid, nice) + + +class Holder: + def __init__(self, proc=Path("/proc"), cpufreq=Path("/sys/devices/system/cpu"), cores=None): + self.proc = proc + self.cpufreq = cpufreq + self.cores = cores or Cores() + self.held = {} # pid -> {tid: (cpus, nice) before the hold} + self.core = None + + def slow_core(self): + """The last core of the slowest cluster the session has - not of the game's cores, which + the launcher inherits when BL_GAME_CPUS is set: the core the game misses least.""" + def top(cpu): + try: + return int((self.cpufreq / f"cpu{cpu}/cpufreq/cpuinfo_max_freq").read_text()) + except (OSError, ValueError): + return 0 + cpus = self.cores.own() + slowest = min(top(cpu) for cpu in cpus) + return max(cpu for cpu in cpus if top(cpu) == slowest) + + def helpers(self): + for name in os.listdir(self.proc): + if name.isdigit() and is_one_of(image(self.proc, name), HELPERS): + yield int(name) + + def hold(self): + """Every helper thread onto the slow core at NICE: new ones, and held ones the program has + moved since (Chromium sets its threads' priorities, and Wine passes that on).""" + if self.core is None: + self.core = self.slow_core() + hold = ({self.core}, NICE) + for pid in self.helpers(): + before = self.held.setdefault(pid, {}) + for tid in tasks(self.proc, pid): + try: + now = self.cores.get(tid) + if now != hold: + self.cores.set(tid, *hold) + except OSError: + continue # the thread ended + if tid not in before: + # A thread that already has the hold inherited it from a held parent: it gets + # what its process had, or the helpers' first. + before[tid] = now if now != hold else before.get(pid) or self.first() or now + for pid in [pid for pid in self.held if not (self.proc / str(pid)).exists()]: + del self.held[pid] + + def first(self): + for before in self.held.values(): + for state in before.values(): + if state != ({self.core}, NICE): + return state + return None + + def release(self): + """Every held thread back to its own mask and priority; one started during the hold gets + what its process had.""" + for pid, before in self.held.items(): + default = before.get(pid) or self.first() + for tid in tasks(self.proc, pid): + state = before.get(tid, default) + if state is None: + continue + try: + self.cores.set(tid, *state) + except OSError: + pass + self.held.clear() + + +def focused(snapshot): + """(app id, pid) of the window gamescope has focused, from droiddeck-agent's snapshot.""" + window = snapshot.get("focusedWindow") + for entry in snapshot.get("focusableWindows") or []: + if entry.get("window") == window: + return snapshot.get("focusedApp"), entry.get("pid") + return snapshot.get("focusedApp"), None + + +class Governor: + def __init__(self, focus_path, holder=None, clock=time.monotonic): + self.focus_path = Path(focus_path) + self.holder = holder or Holder() + self.clock = clock + self.seen = None + self.app = self.pid = None + self.scanned = None + self.reported = None + + def read_focus(self): + try: + stamp = self.focus_path.stat().st_mtime_ns + except OSError: + return False + if stamp == self.seen: + return False + try: + self.app, self.pid = focused(json.loads(self.focus_path.read_text())) + except (OSError, ValueError, AttributeError): + return False + self.seen = stamp + return True + + def tick(self): + changed = self.read_focus() + if self.pid is None: + return + launcher_in_front = is_one_of(image(self.holder.proc, self.pid), FRONTS) + if launcher_in_front: + if self.holder.held: + self.holder.release() + self.report("released: the launcher has focus") + return + # Nothing to look for until a game has had focus: launchers only run for one, and a launcher + # left running after its game stays held through the Steam client's own focus. + if self.app in (None, 0, STEAM_CLIENT) and not self.holder.held: + return + now = self.clock() + if changed or self.scanned is None or now - self.scanned >= RESCAN: + self.scanned = now + self.holder.hold() + if self.holder.held: + self.report(f"{len(self.holder.held)} SocialClubHelper process(es) on core {self.holder.core} " + f"at nice {NICE} while the launcher is hidden") + + def report(self, message): + if message != self.reported: + self.reported = message + log(message) + + def stop(self, *_): + self.holder.release() + sys.exit(0) + + +def main(argv): + if len(argv) != 1: + print("usage: droiddeck-launcher-cores ", file=sys.stderr) + return 2 + governor = Governor(argv[0]) + signal.signal(signal.SIGTERM, governor.stop) + signal.signal(signal.SIGHUP, governor.stop) + signal.signal(signal.SIGINT, governor.stop) + while True: + governor.tick() + time.sleep(POLL) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index 7b430422a..053df0e93 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -17,6 +17,11 @@ # 2015-2022 runtime is recorded as installed the way Microsoft's installers look for it: on ARM64 # Proton the x64 bundle otherwise stops with "Microsoft Visual C++ 2015-2022 Redistributable (arm64) # is required", while Proton's own msvcp140/vcruntime140 already serve the game. +# +# A Vulkan runtime installer is the one left to run. Proton has no stand-in for it: for a game whose +# Steam compat config says nativevulkanloader (Red Dead Redemption 2) it loads vulkan-1.dll native +# only, and Khronos' loader is in the prefix only once that installer has run - without it the game +# exits with STATUS_DLL_NOT_FOUND before it opens a window. The installer finishes in a second. set -u steam_root=${1:?steam root} LIBRARIES=${DROIDDECK_LIBRARIES-/mnt/droiddeck /mnt/droiddeck-sd} @@ -25,14 +30,15 @@ redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist" # Every process an install script would run, as the registry key and value the client writes once # it has run it. Names sit one level inside "Run Process", the key they share a level further in. -read_scripts() { xargs -0 -r awk ' +# "read_scripts vulkan" lists only the Vulkan runtime installers, which the plain call leaves out. +read_scripts() { xargs -0 -r awk -v want="${1:-redists}" ' # A block whose HasRunKey is set is one process; its name is the key the block opened under, at # whatever depth - an install script nests it under "Run Process", the client'"'"'s evaluator script # for a game wraps the same blocks once more. FNR == 1 { depth = 0; pending = "" } - /^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; pending = ""; next } + /^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; vulkan[depth] = 0; pending = ""; next } /^[[:space:]]*\}/ { - if (depth > 0 && key[depth] != "" && name[depth] != "") + if (depth > 0 && key[depth] != "" && name[depth] != "" && vulkan[depth] == (want == "vulkan")) printf "%s\t%s\n", key[depth], name[depth] if (depth > 0) depth-- next @@ -40,6 +46,12 @@ read_scripts() { xargs -0 -r awk ' { n = split($0, part, "\"") if (n >= 5 && tolower(part[2]) == "hasrunkey") { key[depth] = part[4]; next } + if (n >= 5 && tolower(part[2]) ~ /^process [0-9]+$/) { + program = tolower(part[4]) + sub(/.*[\\\/]/, "", program) + if (program ~ /vulkan/) vulkan[depth] = 1 + next + } if (n == 3) pending = part[2] } '; } @@ -84,13 +96,17 @@ if [ "$(head -n 1 "$cache" 2>/dev/null)" != "$sig" ]; then done <<<"$libraries" | sort -u } >"$cache.tmp" && mv -f "$cache.tmp" "$cache" fi -game_pairs=$(tail -n +2 "$cache" 2>/dev/null | tr '\n' '\0' | read_scripts) # DROIDDECK_SEED_SCRIPTS: more scripts, one path a line - droiddeck-game-env passes the client's # evaluator script for the game about to run its installers, read before the client looks. -if [ -n "${DROIDDECK_SEED_SCRIPTS:-}" ]; then - game_pairs=$(printf '%s\n%s\n' "$game_pairs" "$(printf '%s\n' "$DROIDDECK_SEED_SCRIPTS" | tr '\n' '\0' | read_scripts)") -fi +game_scripts() { { tail -n +2 "$cache" 2>/dev/null; printf '%s\n' "${DROIDDECK_SEED_SCRIPTS:-}"; } | sed '/^$/d' | tr '\n' '\0'; } +game_pairs=$(game_scripts | read_scripts) [ -n "$(printf '%s' "$game_pairs" | tr -d '[:space:]')" ] && pairs=$(printf '%s\n%s\n' "$pairs" "$game_pairs" | sort -u | sed '/^$/d') +# A Vulkan runtime installer marked as run by an earlier seeding still has its marker: those come out +# again below, so the client runs the installer on the game's next launch. +declare -A vulkan=() +while IFS= read -r line; do + [[ $line == *$'\t'* ]] && vulkan[$line]=1 +done < <(game_scripts | read_scripts vulkan) # The VC++ 2015-2022 runtime as its installers check for it, for each architecture and both registry # views. The version is the newest the scripts above know, so any 2015-2022 bundle finds itself @@ -138,6 +154,38 @@ block_for() { printf '%s\n' "$@" | awk -F'\t' -v now="$now" ' for line; do [[ $line == vcrt$'\t'* ]] && vcrt "$now"; done } +# Takes the given markers out of a registry file again, in both views, keeping its date. +unseed() { + local reg=$1 out + shift + out=$(mktemp "${reg%/*}/.system.reg.XXXXXX") || return 1 + if printf '%s\n' "$@" | awk -F'\t' ' + NR == FNR { + path = $1 + sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path) + if (path == $1) + next + path = tolower(path) + drop["[" path "]" SUBSEP tolower($2)] = 1 + if (substr(path, 1, 10) == "software\\\\") + drop["[software\\\\wow6432node\\\\" substr(path, 11) "]" SUBSEP tolower($2)] = 1 + next + } + /^\[/ { section = tolower(substr($0, 1, index($0, "]"))) } + /^"/ { + value = substr($0, 2) + value = tolower(substr(value, 1, index(value, "\"=") - 1)) + if ((section SUBSEP value) in drop) + next + } + { print } + ' - "$reg" >"$out" && touch -r "$reg" "$out" && mv -f "$out" "$reg"; then + return 0 + fi + rm -f "$out" + return 1 +} + # Proton builds a new prefix from its template, and the client runs the install scripts in that # prefix moments later, so the template carries the markers too. Its date is kept: Proton reads a # changed one as a new template and upgrades every prefix made from the old. @@ -158,6 +206,16 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste if [ -f "$seeded" ]; then while IFS= read -r line; do [[ $line == *$'\t'* ]] && had[$line]=1; done <"$seeded" fi + stale=() kept=() + for line in "${!had[@]}"; do + if [ -n "${vulkan[$line]:-}" ]; then stale+=("$line"); else kept+=("$line"); fi + done + if [ ${#stale[@]} -gt 0 ] && unseed "$reg" "${stale[@]}"; then + had=() + for line in "${kept[@]}"; do had[$line]=1; done + printf '%s\n' "${kept[@]}" >"$seeded" + echo "== took Vulkan runtime markers out of $reg" + fi missing=() for line in "${want[@]}"; do [ -n "${had[$line]:-}" ] || missing+=("$line"); done [ ${#missing[@]} -eq 0 ] && continue diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 94d9a6937..0e3284a57 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -50,6 +50,37 @@ prepare_locale() { } [ -z "${BL_INSIDE:-}" ] && prepare_locale +# What earlier sessions left in /tmp. The guest's /tmp is a directory in the rootfs, never emptied +# by a boot, and Android ends sessions by killing them: each left its mangoapp config, the client's +# temporary directories and shared-memory sockets, Chromium's singleton directory (the web +# helper's, an Electron app's), a session bus socket per run and a run's start stamp - 1,400 +# entries on a device after a few weeks. Each kind is swept while nothing that could own one runs, +# with one find for the lot. +clear_stale_tmp() { + local running names=() n + running=$(pgrep -af 'steamwebhelper|steamrtarm64/steam|--type=|droiddeck-appimage-run|dbus-run-session|dbus-daemon.*--session|dbus-launch|kwin|plasmashell' 2>/dev/null) + case $running in + *steamwebhelper* | *steamrtarm64/steam*) ;; + *) names+=(-o -name 'mangohud.????????' -o -name '.com.valvesoftware.Steam.??????' + -o -name 'steam_chrome_shmem_uid*' -o \( -type d -empty -name 'steam??????' \)) ;; + esac + case $running in *--type=*) ;; *) names+=(-o \( -type d -name 'scoped_dir??????' \)) ;; esac + case $running in *droiddeck-appimage-run*) ;; *) names+=(-o -name 'droiddeck-appimage.??????') ;; esac + case $running in + *dbus-run-session* | *dbus-daemon*--session* | *dbus-launch*) ;; + *) names+=(-o \( -type s -name 'dbus-??????????' \)) ;; + esac + case $running in + *kwin* | *plasmashell*) ;; + *) names+=(-o \( -type d -empty -name 'plasma-csd-generator.??????' \)) ;; + esac + [ ${#names[@]} -gt 0 ] || return 0 + n=$(find /tmp -mindepth 1 -maxdepth 1 \( "${names[@]:1}" \) -print -exec rm -rf {} + 2>/dev/null | wc -l) + [ "$n" -gt 0 ] && echo "== /tmp: cleared $n entries earlier sessions left" + return 0 +} +[ -z "${BL_INSIDE:-}" ] && clear_stale_tmp + # Until the runtime is stable, Steam's own logs are copied beside the session log at exit so a # failed launch can be handed over as one folder. collect_steam_logs() { @@ -271,7 +302,8 @@ if [ -z "${BL_INSIDE:-}" ]; then # BL_MANGOAPP=0 turns it off (one community build found Valve's mangoapp crashing on Turnip). if [ "$mode" = steam ] && [ "${BL_MANGOAPP:-1}" = 1 ] \ && [ "${BL_STEAM_UI:-}" != desktop ] && command -v mangoapp >/dev/null 2>&1; then - MANGOHUD_CONFIGFILE=$(mktemp /tmp/mangohud.XXXXXXXX) + # One fixed file, rewritten each session: a mktemp name per session was never removed. + MANGOHUD_CONFIGFILE=/tmp/droiddeck-mangohud.conf echo no_display > "$MANGOHUD_CONFIGFILE" export MANGOHUD_CONFIGFILE STEAM_USE_MANGOAPP=1 STEAM_MANGOAPP_PRESETS_SUPPORTED=1 STEAM_MANGOAPP_HORIZONTAL_SUPPORTED=1 args+=(--mangoapp) @@ -434,6 +466,13 @@ case $mode in done ) & fi + # Rockstar's launcher keeps drawing its hidden web interface for as long as a game of theirs + # runs; droiddeck-launcher-cores holds it on one slow core while it is out of sight, from + # gamescope's focus as the agent publishes it. BL_LAUNCHER_CORES=0 leaves launchers alone. + if [ "${BL_LAUNCHER_CORES:-1}" != 0 ] && [ -n "${BL_LAUNCH_DIR:-}" ] && [ -z "${BL_DESKTOP:-}" ] \ + && [ "${BL_STEAM_UI:-}" != desktop ] && [ -x /usr/local/bin/droiddeck-launcher-cores ]; then + /usr/local/bin/droiddeck-launcher-cores "$BL_LAUNCH_DIR/agent/focus.json" 2>&1 & + fi step "registering the app's game library with Steam" droiddeck-steam-library "$steam_root" || true diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale index e7f41383f..b3e009e60 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale @@ -1,10 +1,16 @@ #!/usr/bin/python3 -"""Initialize Steam's own Gamescope display settings once; subsequent choices belong to Steam. +"""Initialize Steam's own display settings once; subsequent choices belong to Steam. The UI scale, and native resolution: the client treats our gamescope as an external display and, unless "Enable Native Resolution on External Displays" is on, draws itself at 1920x1080 on any larger output - a 2340x1080 session came up as 16:9 with bars. The session's size is already the -user's Resolution choice, so the client should use all of it.""" +user's Resolution choice, so the client should use all of it. + +And the screensaver over a running game. The client's defaults (steamui.so's settings descriptor) +show screensavers while a game runs, after 300 s on AC power with no button pressed - and a stick or +trigger held is no press. Red Dead Redemption 2's cutscenes run longer than that: the slideshow +covered the game, and the button that woke it reached the game as well (B asked to quit to the +desktop). Customization > Screensaver > "Show screensavers when playing a game" still turns it on.""" import math import os from pathlib import Path @@ -15,6 +21,8 @@ import tempfile DISPLAY = "External: gamescope|||Windowed" NATIVE_RESOLUTION = "EnableSteamNativeExternalResolution" +# screensaver_while_ingame: kept in config.vdf like the Gamescope settings, under this path. +SCREENSAVER_IN_GAME = ("Customization", "Screensaver", "WhileInGame") TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]') @@ -108,25 +116,43 @@ def seed_text(text, scale): return text[:at] + insertion + text[at:], True -def seed_native_resolution(text): +def seed_value(text, path, value): + """InstallConfigStore/ = value, unless the client already has a value there (the + user's), adding only the blocks that are missing.""" root = parse(text).get('InstallConfigStore') if not isinstance(root, Block): raise ValueError("missing InstallConfigStore") - gamescope = root.get('Gamescope') - if gamescope is not None and not isinstance(gamescope, Block): - raise ValueError("invalid Gamescope settings") - if gamescope is not None and gamescope.get(NATIVE_RESOLUTION) is not None: + *sections, name = path + block, depth = root, 1 + while sections: + child = block.get(sections[0]) + if child is None: + break + if not isinstance(child, Block): + raise ValueError(f"invalid {sections[0]} settings") + block, depth = child, depth + 1 + sections.pop(0) + if not sections and block.get(name) is not None: return text, False - entry = f'"{NATIVE_RESOLUTION}" "1"' - if gamescope is not None: - at, indent = gamescope.end, 2 - else: - entry = '"Gamescope"\n{\n' + entry + '\n}' - at, indent = root.end, 1 - insertion = '\n' + '\n'.join('\t' * indent + line for line in entry.splitlines()) + '\n' + lines = [(len(sections), f'"{name}"\t\t"{value}"')] + for level, section in reversed(list(enumerate(sections))): + lines = [(level, f'"{section}"'), (level, '{')] + lines + [(level, '}')] + insertion = ''.join('\t' * (depth + level) + line + '\n' for level, line in lines) + # Before the line the block closes on, when it has one of its own; else beside the brace. + at = text.rfind('\n', 0, block.end) + 1 + if text[at:block.end].strip(): + at, insertion = block.end, '\n' + insertion return text[:at] + insertion + text[at:], True +def seed_native_resolution(text): + return seed_value(text, ('Gamescope', NATIVE_RESOLUTION), '1') + + +def seed_no_screensaver_in_game(text): + return seed_value(text, SCREENSAVER_IN_GAME, '0') + + def atomic_write(path, text): path.parent.mkdir(parents=True, exist_ok=True) fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent) @@ -140,31 +166,36 @@ def atomic_write(path, text): def initialize(steam_root, width, height): + scale = initial_scale(width, height) + return seed_once(steam_root, 'droiddeck-ui-scale-initialized', lambda text: seed_text(text, scale), + f'== steam: initial handheld UI scale {scale:.3f}') + + +def seed_once(steam_root, marker_name, seed, message): + """Applies seed to config.vdf the first time only: the marker keeps a later choice - including + a return to the client's own default, which the client may store by dropping the key - the + user's.""" config = Path(steam_root) / 'config/config.vdf' - marker = config.with_name('droiddeck-ui-scale-initialized') + marker = config.with_name(marker_name) if marker.exists(): return False text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n' - updated, changed = seed_text(text, initial_scale(width, height)) + updated, changed = seed(text) if changed: atomic_write(config, updated) - print(f'== steam: initial handheld UI scale {initial_scale(width, height):.3f}') + print(message) atomic_write(marker, '1\n') return changed def initialize_native_resolution(steam_root): - config = Path(steam_root) / 'config/config.vdf' - marker = config.with_name('droiddeck-native-resolution-initialized') - if marker.exists(): - return False - text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n' - updated, changed = seed_native_resolution(text) - if changed: - atomic_write(config, updated) - print('== steam: native resolution on the session display') - atomic_write(marker, '1\n') - return changed + return seed_once(steam_root, 'droiddeck-native-resolution-initialized', seed_native_resolution, + '== steam: native resolution on the session display') + + +def initialize_screensaver(steam_root): + return seed_once(steam_root, 'droiddeck-screensaver-initialized', seed_no_screensaver_in_game, + '== steam: no screensaver over a running game') if __name__ == '__main__': @@ -174,9 +205,10 @@ if __name__ == '__main__': except (OSError, ValueError, RecursionError) as error: print('== steam: left UI scale unchanged: ' + str(error), file=sys.stderr) status = 1 - try: - initialize_native_resolution(sys.argv[1]) - except (OSError, ValueError, RecursionError) as error: - print('== steam: left native resolution unchanged: ' + str(error), file=sys.stderr) - status = 1 + for what, step in (('native resolution', initialize_native_resolution), ('screensaver', initialize_screensaver)): + try: + step(sys.argv[1]) + except (OSError, ValueError, RecursionError) as error: + print(f'== steam: left {what} unchanged: {error}', file=sys.stderr) + status = 1 sys.exit(status) diff --git a/tools/tests/test_launcher_cores.py b/tools/tests/test_launcher_cores.py new file mode 100644 index 000000000..98656b745 --- /dev/null +++ b/tools/tests/test_launcher_cores.py @@ -0,0 +1,220 @@ +import json +import os +from pathlib import Path +import runpy +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +MODULE = runpy.run_path(str(BIN / 'droiddeck-launcher-cores')) +Holder = MODULE['Holder'] +Governor = MODULE['Governor'] +NICE = MODULE['NICE'] + +HELPER = 'C:\\Program Files\\Rockstar Games\\Social Club\\SocialClubHelper.exe' +LAUNCHER = 'C:\\Program Files\\Rockstar Games\\Launcher\\Launcher.exe' +GAME = 'S:\\steamapps\\common\\Red Dead Redemption 2\\RDR2.exe' +ALL = set(range(8)) + + +class FakeCores: + """Per-thread masks and priorities, with every change recorded.""" + + def __init__(self): + self.state = {} + self.calls = [] + + def own(self): + return set(ALL) + + def get(self, tid): + if tid not in self.state: + raise ProcessLookupError(tid) + return self.state[tid] + + def set(self, tid, cpus, nice): + if tid not in self.state: + raise ProcessLookupError(tid) + self.calls.append((tid, set(cpus), nice)) + self.state[tid] = (set(cpus), nice) + + +class LauncherCoresTest(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + cpufreq = self.root / 'cpu' + for cpu in ALL: # 0-5 one cluster, 6-7 the fast one + (cpufreq / f'cpu{cpu}/cpufreq').mkdir(parents=True) + (cpufreq / f'cpu{cpu}/cpufreq/cpuinfo_max_freq').write_text('3532800\n' if cpu < 6 else '4320000\n') + self.cores = FakeCores() + self.holder = Holder(self.proc, cpufreq, self.cores) + self.focus = self.root / 'focus.json' + self.now = 0.0 + self.governor = Governor(self.focus, self.holder, clock=lambda: self.now) + + def process(self, pid, argv0, threads=(), cpus=ALL, nice=0): + directory = self.proc / str(pid) + (directory / 'task').mkdir(parents=True) + (directory / 'cmdline').write_bytes(argv0.encode() + b'\0--type=renderer\0') + for tid in (pid, *threads): + self.thread(pid, tid, cpus, nice) + + def thread(self, pid, tid, cpus=ALL, nice=0): + (self.proc / str(pid) / 'task' / str(tid)).mkdir() + self.cores.state[tid] = (set(cpus), nice) + + def end(self, pid): + for tid in os.listdir(self.proc / str(pid) / 'task'): + self.cores.state.pop(int(tid), None) + (self.proc / str(pid) / 'task' / tid).rmdir() + (self.proc / str(pid) / 'task').rmdir() + (self.proc / str(pid) / 'cmdline').unlink() + (self.proc / str(pid)).rmdir() + + def focus_on(self, pid, app=1174180): + stamp = getattr(self, 'stamp', 1_000_000_000) + 1_000_000_000 + self.stamp = stamp + self.focus.write_text(json.dumps({ + 'focusedApp': app, 'focusedWindow': 0x200001, + 'focusableWindows': [{'window': 0x100001, 'appId': 769, 'pid': 9}, + {'window': 0x200001, 'appId': app, 'pid': pid}]})) + os.utime(self.focus, ns=(stamp, stamp)) + + def held(self, tid): + return self.cores.state[tid] == ({5}, NICE) + + def test_helpers_held_on_the_slow_clusters_last_core_while_the_game_has_focus(self): + self.process(100, LAUNCHER, (101,)) + self.process(200, HELPER, (201, 202), nice=-2) + self.process(300, HELPER, (301,)) + self.process(400, GAME, (401,)) + self.focus_on(400) + self.governor.tick() + for tid in (200, 201, 202, 300, 301): + self.assertTrue(self.held(tid), tid) + for tid in (100, 101, 400, 401): + self.assertEqual((ALL, 0), self.cores.state[tid]) + + def test_launcher_focus_gives_each_thread_its_own_mask_and_priority_back(self): + self.process(100, LAUNCHER) + self.process(200, HELPER, (201,), cpus={0, 1, 2, 3}, nice=-2) + self.cores.state[201] = ({6, 7}, 4) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + self.assertTrue(self.held(201)) + self.focus_on(100) + self.governor.tick() + self.assertEqual(({0, 1, 2, 3}, -2), self.cores.state[200]) + self.assertEqual(({6, 7}, 4), self.cores.state[201]) + self.assertEqual({}, self.holder.held) + + def test_nothing_is_touched_before_a_game_has_focus(self): + self.process(100, LAUNCHER) + self.process(200, HELPER) + self.process(9, '/root/.local/share/Steam/steamrtarm64/steam') + self.governor.tick() # no focus file yet + self.focus_on(9, app=769) + self.governor.tick() + self.focus_on(100) + self.governor.tick() + self.assertEqual([], self.cores.calls) + + def test_a_launcher_left_after_its_game_stays_held_behind_the_client(self): + self.process(200, HELPER) + self.process(400, GAME) + self.process(9, '/root/.local/share/Steam/steamrtarm64/steam') + self.focus_on(400) + self.governor.tick() + self.end(400) + self.focus_on(9, app=769) + self.governor.tick() + self.assertTrue(self.held(200)) + + def test_a_thread_inheriting_the_hold_is_released_to_what_its_process_had(self): + self.process(200, HELPER, cpus={0, 1, 2, 3, 4, 5}, nice=1) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + # A new thread, and a new helper process the held one started: both born held. + self.thread(200, 203, {5}, NICE) + self.process(500, HELPER, (501,), cpus={5}, nice=NICE) + self.now += 10 + self.governor.tick() + self.focus_on(200) + self.governor.tick() + for tid in (200, 203, 500, 501): + self.assertEqual(({0, 1, 2, 3, 4, 5}, 1), self.cores.state[tid], tid) + + def test_a_held_thread_the_program_moves_back_is_held_again_on_the_next_look(self): + self.process(200, HELPER, (201,)) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + self.cores.state[201] = (ALL, -5) # the program raised a thread's priority itself + self.now += 5 + self.governor.tick() + self.assertFalse(self.held(201)) # not looked at again before the rescan + self.now += 5 + self.governor.tick() + self.assertTrue(self.held(201)) + self.focus_on(200) + self.governor.tick() + self.assertEqual((ALL, 0), self.cores.state[201]) # its first state, not the program's + + def test_a_steady_hold_is_not_rewritten(self): + self.process(200, HELPER, (201,)) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + calls = len(self.cores.calls) + for _ in range(5): + self.now += 10 + self.governor.tick() + self.assertEqual(calls, len(self.cores.calls)) + + def test_threads_that_end_are_skipped_and_ended_helpers_forgotten(self): + self.process(200, HELPER, (201,)) + self.process(300, HELPER) + self.process(400, GAME) + del self.cores.state[201] # ended between the listing and the call + self.focus_on(400) + self.governor.tick() + self.assertNotIn(201, self.holder.held[200]) + self.end(300) + self.now += 10 + self.governor.tick() + self.assertEqual([200], list(self.holder.held)) + + def test_stop_releases(self): + self.process(200, HELPER) + self.process(400, GAME) + self.focus_on(400) + self.governor.tick() + with self.assertRaises(SystemExit): + self.governor.stop() + self.assertEqual((ALL, 0), self.cores.state[200]) + + def test_unreadable_focus_is_ignored(self): + self.process(200, HELPER) + self.focus.write_text('{"focusedApp": 1174') + self.governor.tick() + self.focus.write_text('[]') + os.utime(self.focus, ns=(5, 5)) + self.governor.tick() + self.assertEqual([], self.cores.calls) + + def test_window_without_a_pid_is_left_alone(self): + self.process(200, HELPER) + self.focus.write_text(json.dumps({'focusedApp': 1174180, 'focusedWindow': 7, 'focusableWindows': []})) + self.governor.tick() + self.assertEqual([], self.cores.calls) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_seed_redists.py b/tools/tests/test_seed_redists.py index abc77ab07..1f9e2ae71 100644 --- a/tools/tests/test_seed_redists.py +++ b/tools/tests/test_seed_redists.py @@ -22,6 +22,30 @@ GAME_SCRIPT = r'''"InstallScript" } ''' +# Red Dead Redemption 2's own script: Proton needs Khronos' vulkan-1.dll for it, which only the +# Vulkan runtime installer puts in the prefix. +VULKAN_SCRIPT = r'''"InstallScript" +{ + "Run Process" + { + "Vulkan111080" + { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Valve\\Steam\\Apps\\1174180" + "process 1" "%INSTALLDIR%\\Redistributables\\VulkanRT-1.1.108.0-Installer.exe" + "command 1" "/S" + "NoCleanUp" "0" + } + "RockstarLauncher" + { + "HasRunKey" "HKEY_LOCAL_MACHINE\\Software\\Rockstar Games\\Steam\\Launcher" + "process 1" "%INSTALLDIR%\\Redistributables\\Rockstar-Games-Launcher.exe" + "command 1" "/s /t" + "NoCleanUp" "0" + } + } +} +''' + class SeedRedistsTest(unittest.TestCase): def setUp(self): @@ -91,6 +115,45 @@ class SeedRedistsTest(unittest.TestCase): self.run_seed() self.assertEqual(int(self.reg.stat().st_mtime), 1_000_000_000) + def rdr2(self): + path = self.steam / "steamapps/common/Red Dead Redemption 2/installscript.vdf" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(VULKAN_SCRIPT) + (self.steam / "steamapps/appmanifest_1174180.acf").write_text('"AppState" { "installdir" "Red Dead Redemption 2" }') + + def test_a_vulkan_runtime_installer_is_left_to_run(self): + self.rdr2() + self.run_seed() + reg = self.reg.read_text() + self.assertNotIn("Vulkan111080", reg) + self.assertIn('"RockstarLauncher"=dword:00000001', reg) + self.assertNotIn("Vulkan111080", (self.reg.parent / ".droiddeck-redists").read_text()) + + def test_a_vulkan_marker_an_earlier_seeding_wrote_comes_out_once(self): + self.rdr2() + marker = "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180\tVulkan111080" + self.reg.write_text( + "WINE REGISTRY Version 2\n" + "\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791729070\n#time=1dd598d29161f72\n" + '"Other"=dword:00000001\n"Vulkan111080"=dword:00000001\n' + "\n[Software\\\\Wow6432Node\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791729070\n" + '"Vulkan111080"=dword:00000001\n' + "\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\22380] 1791311644\n" + '"Vulkan111080"=dword:00000001\n') + (self.reg.parent / ".droiddeck-redists").write_text(marker + "\n") + os.utime(self.reg, (1_000_000_000, 1_000_000_000)) + self.run_seed() + reg = self.reg.read_text() + self.assertEqual(reg.count('"Vulkan111080"'), 1, "only the other app's value stays") + self.assertIn('"Other"=dword:00000001', reg) + self.assertNotIn("Vulkan111080", (self.reg.parent / ".droiddeck-redists").read_text()) + self.assertEqual(int(self.reg.stat().st_mtime), 1_000_000_000) + # The client writes the marker itself once the installer has run; that one is left alone. + with self.reg.open("a") as out: + out.write('\n[Software\\\\Valve\\\\Steam\\\\Apps\\\\1174180] 1791730000\n"Vulkan111080"=dword:00000001\n') + self.run_seed() + self.assertEqual(self.reg.read_text().count('"Vulkan111080"'), 2) + if __name__ == "__main__": unittest.main() diff --git a/tools/tests/test_session_tmp.py b/tools/tests/test_session_tmp.py new file mode 100644 index 000000000..58607911d --- /dev/null +++ b/tools/tests/test_session_tmp.py @@ -0,0 +1,95 @@ +import os +import re +import shutil +import socket +import subprocess +import tempfile +import unittest +from pathlib import Path + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" + +STEAM = ["mangohud.Ab3dE6gH", ".com.valvesoftware.Steam.x1Y2z3/", "steam_chrome_shmem_uid10480_0", "steamADfAnd/"] +CHROMIUM = ["scoped_dir3yu9Xt/SingletonSocket"] +APPIMAGE = ["droiddeck-appimage.19PoOD"] +BUS = ["dbus-1zWfOP1IaR="] # = a socket +KDE = ["plasma-csd-generator.BVWIWA/"] +STALE = STEAM + CHROMIUM + APPIMAGE + BUS + KDE +KEPT = ["droiddeck-mangohud.conf", ".X11-unix/", "steamworks", "steamABCDEF/inside", "notes.txt", + "mangohud.conf", ".wine-10480/", "dbus-1zWfOP1IaR.log", "dbus-notasocket", + "plasma-csd-generator.CQGZSw/kept"] + + +def sweep_function(tmp): + match = re.search(r"^clear_stale_tmp\(\) \{\n.*?^\}\n", SESSION.read_text(), re.M | re.S) + return match.group(0).replace("find /tmp ", "find %s " % tmp) + + +class SessionTmpTest(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root, True) + self.tmp = self.root / "tmp" + self.tmp.mkdir() + for name in STALE + KEPT: + path = self.tmp / name + if name.endswith("/"): + path.mkdir(parents=True) + elif name.endswith("="): + listener = socket.socket(socket.AF_UNIX) + listener.bind(str(self.tmp / name[:-1])) + listener.close() + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("x") + self.bin = self.root / "bin" + self.bin.mkdir() + + def sweep(self, running=""): + pgrep = self.bin / "pgrep" + pgrep.write_text("#!/bin/sh\nprintf '%s' '" + running + "'\n") + pgrep.chmod(0o755) + env = dict(os.environ, PATH="%s:%s" % (self.bin, os.environ["PATH"])) + return subprocess.run(["bash", "-c", sweep_function(self.tmp) + "clear_stale_tmp"], env=env, + capture_output=True, text=True, timeout=30, check=True).stdout + + def exists(self, name): + return os.path.lexists(self.tmp / name.rstrip("=").split("/")[0]) + + def test_what_killed_sessions_left_goes_and_nothing_else(self): + self.assertEqual("== /tmp: cleared 8 entries earlier sessions left\n", self.sweep()) + for name in STALE: + self.assertFalse(self.exists(name), name) + for name in KEPT: + self.assertTrue((self.tmp / name).exists(), name) + + def kept_while(self, running, owned): + self.sweep(running) + for name in STALE: + self.assertEqual(name in owned, self.exists(name), name) + + def test_a_running_client_keeps_its_own(self): + self.kept_while("1234 /root/.local/share/Steam/steamrtarm64/steam -gamepadui\n", STEAM) + + def test_a_running_chromium_keeps_its_singleton(self): + self.kept_while("88 /opt/Heroic/heroic --type=renderer --enable-features=x\n", CHROMIUM) + + def test_a_running_appimage_keeps_its_stamp(self): + self.kept_while("77 /bin/bash /usr/local/bin/droiddeck-appimage-run /root/Apps/X.AppImage\n", APPIMAGE) + + def test_a_running_session_bus_keeps_its_socket(self): + self.kept_while("66 dbus-daemon --config-file=/usr/share/dbus-1/session.conf --nofork --session\n", BUS) + + def test_the_system_bus_is_not_a_session_bus(self): + self.kept_while("55 dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork\n", []) + + def test_a_running_desktop_keeps_its_decorations(self): + self.kept_while("44 kwin_wayland --xwayland\n", KDE) + + def test_a_clean_tmp_says_nothing(self): + self.sweep() + self.assertEqual("", self.sweep()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_ui_scale.py b/tools/tests/test_steam_ui_scale.py index 8d4689483..5440dcd49 100644 --- a/tools/tests/test_steam_ui_scale.py +++ b/tools/tests/test_steam_ui_scale.py @@ -14,6 +14,8 @@ DISPLAY = MODULE['DISPLAY'] seed_native_resolution = MODULE['seed_native_resolution'] initialize_native_resolution = MODULE['initialize_native_resolution'] NATIVE_RESOLUTION = MODULE['NATIVE_RESOLUTION'] +seed_no_screensaver_in_game = MODULE['seed_no_screensaver_in_game'] +initialize_screensaver = MODULE['initialize_screensaver'] class SteamUiScaleTest(unittest.TestCase): @@ -151,3 +153,60 @@ class SteamNativeResolutionTest(unittest.TestCase): initialize_native_resolution(self.root) self.assertEqual(text, self.config.read_text()) self.assertFalse(self.marker.exists()) + + +class SteamScreensaverTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / 'config/config.vdf' + self.config.parent.mkdir() + self.marker = self.config.with_name('droiddeck-screensaver-initialized') + + def in_game(self, text): + return parse(text).get('InstallConfigStore').get('Customization').get('Screensaver').get('WhileInGame') + + def test_fresh_client_shows_no_screensaver_over_a_game(self): + self.assertTrue(initialize_screensaver(self.root)) + self.assertEqual('0', self.in_game(self.config.read_text())) + self.assertTrue(self.marker.exists()) + + def test_it_goes_beside_the_screensaver_the_user_picked(self): + text = ('"InstallConfigStore"\n{\n\t"Customization"\n\t{\n\t\t"Screensaver"\n\t\t{\n' + '\t\t\t"CurrentScreensaver"\t\t"steam-bouncinglogo"\n\t\t}\n\t}\n}\n') + updated, changed = seed_no_screensaver_in_game(text) + self.assertTrue(changed) + screensaver = parse(updated).get('InstallConfigStore').get('Customization').get('Screensaver') + self.assertEqual('steam-bouncinglogo', screensaver.get('CurrentScreensaver')) + self.assertEqual('0', screensaver.get('WhileInGame')) + self.assertIn('\t\t\t"WhileInGame"\t\t"0"\n\t\t}\n', updated) + + def test_users_choice_is_kept(self): + text = '"InstallConfigStore" { "Customization" { "Screensaver" { "WhileInGame" "1" } } }' + self.assertEqual((text, False), seed_no_screensaver_in_game(text)) + + def test_turning_it_back_on_survives_restart(self): + initialize_screensaver(self.root) + text = '"InstallConfigStore" { "Customization" { } }' + self.config.write_text(text) + self.assertFalse(initialize_screensaver(self.root)) + self.assertEqual(text, self.config.read_text()) + + def test_every_seeded_setting_lands_in_one_valid_config(self): + initialize(self.root, 2340, 1080) + initialize_native_resolution(self.root) + initialize_screensaver(self.root) + store = parse(self.config.read_text()).get('InstallConfigStore') + self.assertIsNotNone(store.get('UI').get('display').get(DISPLAY)) + self.assertEqual('1', store.get('Gamescope').get(NATIVE_RESOLUTION)) + self.assertEqual('0', store.get('Customization').get('Screensaver').get('WhileInGame')) + + def test_invalid_config_is_never_rewritten_or_marked_initialized(self): + for text in ('"InstallConfigStore" {', '"InstallConfigStore" { "Customization" "bad" }', + '"InstallConfigStore" { "Customization" { "Screensaver" "bad" } }'): + self.config.write_text(text) + with self.assertRaises(ValueError): + initialize_screensaver(self.root) + self.assertEqual(text, self.config.read_text()) + self.assertFalse(self.marker.exists())