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 5822ef0be..bb977c73f 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) @@ -436,6 +468,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())