mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
Merge branch 'main' into fix/steam-client-download
This commit is contained in:
commit
11bd604dfd
10 files changed
+841
-43
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user