Merge branch 'main' into fix/steam-client-download

This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-11 15:53:38 -04:00
commit 11bd604dfd
10 files changed
+841 -43

No files matched your search

@@ -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",
@@ -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")
@@ -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 <agent focus.json>", 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:]))
@@ -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
@@ -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
@@ -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/<path> = 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)
+220
View File
@@ -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()
+63
View File
@@ -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()
+95
View File
@@ -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()
+59
View File
@@ -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())