Compare commits

...
Author SHA1 Message Date
The412Banner 81699d4e1f Steam redistributables: seed at launch, by presence, title scripts too
The seeder marked Proton's prefix template and every prefix at session
start and on the 15 s library beat, and trusted a stamp beside each file.
That left three ways for a vcredist installer to start under FEX anyway:

- Steam replaces the template's system.reg when it updates Proton and
  leaves the stamp beside it, so an updated template was never marked
  again.
- A Proton installed or updated mid-session had an unmarked template
  until the next beat, and a title launched in that window got a prefix
  with nothing in it.
- A title's own install script (in its install folder) was never read.

The compatibility tool (both the ARM64 Proton launcher and the wrapper
around adopted Protons) now runs bannerlator-seed-redists --launch before
handing a launch to Proton: that Proton's template and the title's prefix
are marked before the client can create or use them. Whether a file is
covered is read from the file, value by value, so nothing is appended
twice and a value lost to a running wineserver is put back next launch.
A title's own scripts contribute only the steps that run a common
redistributable (vcredist, DirectX, .NET, XNA, PhysX, OpenAL) from the
"Run Process" section; its own setup steps still run. Both libraries are
read for Steamworks Shared and templates. The template keeps its date.

The session-start pass stays for prefixes that already exist; the beat
no longer runs the seeder.
2026-10-02 20:29:09 -04:00
3 changed files with 188 additions and 73 deletions

No files matched your search

@@ -10,32 +10,95 @@
# The client skips a "Run Process" block whose HasRunKey is already set, which is the same escape
# WineUtils.seedVcRedistRegistryIfDllsPresent() takes for the app's own containers. The names and
# versions come out of the install scripts rather than a list kept here, because Valve revises
# them; a prefix already carrying the same set is left alone.
# them. Only the values a registry file does not already hold are added, so a file that is
# already covered is not written at all.
#
# bannerlator-seed-redists STEAM_ROOT
# Every template and prefix in both libraries. Run as the session starts.
# bannerlator-seed-redists --launch TOOL_DIR
# From the compatibility tool, before it hands a launch to Proton: the template of the
# Proton in TOOL_DIR and the prefix of the title being launched, plus whichever of that
# title's own install-script steps are redistributables.
#
# Seeding at launch is what closes the race. The client creates a new prefix from the template and
# runs the title's install scripts in one go, so a template seeded on a timer was too late whenever
# the client had just installed or updated that Proton.
#
# Whether a file is covered is read from the file itself rather than from a stamp beside it. A
# stamp outlived the file it vouched for: the client replaces the template's system.reg when it
# updates Proton and leaves a file it did not ship where it is, and a running wineserver writes its
# own copy of a prefix's registry over values appended under it.
set -u
steam_root=${1:?steam root}
redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist"
launch=
if [ "${1:-}" = --launch ]; then
launch=1
tool=${2:?tool dir}
steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam}
else
steam_root=${1:?steam root}
fi
libraries=("$steam_root" /mnt/droiddeck-sd)
# 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.
pairs=$(find "$redist" -name installscript.vdf -print0 2>/dev/null | sort -z | xargs -0 -r awk '
FNR == 1 { depth = 0; name = ""; key = "" }
/^[[:space:]]*\{/ { depth++; next }
/^[[:space:]]*\}/ {
if (depth == 3 && name != "" && key != "")
list=$(mktemp) || exit 0
trap 'rm -f "$list"' EXIT
# "key<TAB>name" for every process an install script would run: the HasRunKey the client sets once
# it has run it, and the name of its block, one level inside "Run Process". With only_redists set,
# a block counts only when it is one of the common redistributables: a title's own script can also
# hold its setup steps (a key, a launcher, a first-run tool), which have to run for real.
entries() {
local only_redists=$1
shift
[ "$#" -gt 0 ] || return 0
awk -v only="$only_redists" '
function flush() {
if (name == "" || key == "")
return
if (only && index(tolower(key), "\\\\commonredist\\\\") == 0 && !redist)
return
printf "%s\t%s\n", key, name
depth--
next
}
{
n = split($0, part, "\"")
if (n >= 5 && tolower(part[2]) == "hasrunkey") { key = part[4]; next }
if (n >= 3 && depth == 2) { name = part[2]; key = "" }
}
')
}
FNR == 1 { depth = 0; section = ""; name = ""; key = ""; redist = 0 }
/^[[:space:]]*\{/ { depth++; next }
/^[[:space:]]*\}/ {
if (depth == 3) {
if (tolower(section) == "run process")
flush()
name = ""; key = ""; redist = 0
}
depth--
next
}
{
n = split($0, part, "\"")
if (n < 3)
next
if (depth == 1) { section = part[2]; next }
if (depth == 2) { name = part[2]; key = ""; redist = 0; next }
if (depth == 3 && n >= 5) {
field = tolower(part[2])
if (field == "hasrunkey")
key = part[4]
else if (field ~ /^process/ && tolower(part[4]) ~ /vcredist|vc_redist|vcrun|directx|dxsetup|dotnet|ndp[0-9]|netfx|xna|physx|openal|oalinst/)
redist = 1
}
}
' "$@"
}
# The install scripts every library shares.
shared=()
for library in "${libraries[@]}"; do
redist="$library/steamapps/common/Steamworks Shared/_CommonRedist"
[ -d "$redist" ] || continue
while IFS= read -r -d '' f; do shared+=("$f"); done \
< <(find "$redist" -iname 'installscript*.vdf' -print0 2>/dev/null | sort -z)
done
entries 0 "${shared[@]}" >"$list"
# The client downloads the scripts with the first game that wants them, which is too late for that
# game; until then the set they held when this was written stands in. (WinNative's list.)
[ -n "$pairs" ] || pairs=$(sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' <<'KNOWN'
[ -s "$list" ] || sed 's/^\([^ ]*\) /HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\CommonRedist\\\\\1\t/' >"$list" <<'KNOWN'
DirectX\\Jun2010 dxsetup
.NET\\4.8 4.8
vcredist\\2008 x86 SP1
@@ -53,42 +116,89 @@ vcredist\\2019 x64 14.28.29334.0
vcredist\\2022 x86 14.51.36247.0
vcredist\\2022 x64 14.51.36247.0
KNOWN
)
# The stamp is the set of names, taken before the block below dates its keys: stamped from the
# dated block it never matched, and every prefix had the whole block appended again on every beat.
stamp=$(printf '%s' "$pairs" | cksum)
block=$(printf '%s\n' "$pairs" | awk -F'\t' -v now="$(date +%s)" '
{
path = $1
sub(/^HKEY_LOCAL_MACHINE\\\\/, "", path)
if (path == $1)
# A title's own scripts, at launch, where the client says which title it is. They go into the
# template as well: the title's prefix may not exist yet, and a key under another title's
# HasRunKey is never read by anything else.
if [ -n "$launch" ] && [ -d "${STEAM_COMPAT_INSTALL_PATH:-}" ]; then
own=()
while IFS= read -r -d '' f; do own+=("$f"); done \
< <(find "$STEAM_COMPAT_INSTALL_PATH" -maxdepth 3 -iname 'installscript*.vdf' -print0 2>/dev/null | sort -z)
entries 1 "${own[@]}" >>"$list"
fi
# Appends to a registry file whichever of the listed values it lacks. The client writes these
# from 32-bit steam.exe, so its own copy lands under Wow6432Node; both spellings are seeded
# because the reader picks by its own word size. Only HKEY_LOCAL_MACHINE keys live in system.reg.
seed() {
local reg=$1 block dated
[ -f "$reg" ] || return 0
block=$(awk -F'\t' -v now="$(date +%s)" '
function want(path, name, id) {
id = tolower(path) SUBSEP name
if (id in queued)
return
queued[id] = 1
order[++count] = path
value[count] = name
}
FNR == NR {
path = $1
if (tolower(substr(path, 1, 20)) != "hkey_local_machine\\\\")
next
path = substr(path, 21)
want(path, $2)
if (tolower(substr(path, 1, 10)) == "software\\\\" && tolower(substr(path, 11, 13)) != "wow6432node\\\\")
want(substr(path, 1, 10) "Wow6432Node\\\\" substr(path, 11), $2)
next
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
# The client writes these from 32-bit steam.exe, so its own copy lands under Wow6432Node;
# both spellings are seeded because the reader picks by its own word size.
sub(/^Software\\\\/, "Software\\\\\\\\Wow6432Node\\\\\\\\", path)
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", path, now, $2
}
')
[ -n "$block" ] || exit 0
# 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.
for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg \
"$steam_root"/steamapps/compatdata/*/pfx/system.reg \
/mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do
[ -f "$reg" ] || continue
seeded=$(dirname "$reg")/.bannerlator-redists
[ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue
dated=$(mktemp) || continue
}
/^\[/ {
current = substr($0, 2)
sub(/\][^]]*$/, "", current)
current = tolower(current)
next
}
/^"/ {
v = $0
sub(/^"/, "", v)
sub(/"=.*$/, "", v)
have[current SUBSEP v] = 1
}
END {
for (i = 1; i <= count; i++)
if (!((tolower(order[i]) SUBSEP value[i]) in have))
printf "\n[%s] %d\n\"%s\"=dword:00000001\n", order[i], now, value[i]
}
' "$list" "$reg")
# The stamp an earlier seeder kept beside the file; nothing reads it now.
rm -f "$(dirname "$reg")/.bannerlator-redists"
[ -n "$block" ] || return 0
# Its date is kept: Proton reads a changed one on a template as a new template and upgrades
# every prefix made from the old. The client leaves some depot files read-only.
dated=$(mktemp) || return 0
touch -r "$reg" "$dated"
[ -w "$reg" ] || chmod u+w "$reg" 2>/dev/null
if printf '%s\n' "$block" >>"$reg"; then
printf '%s\n' "$stamp" >"$seeded"
echo "== seeded redist markers into $reg"
fi
touch -r "$dated" "$reg"
rm -f "$dated"
}
if [ -n "$launch" ]; then
for reg in "$tool"/files/share/default_pfx*/system.reg; do
seed "$reg"
done
[ -n "${STEAM_COMPAT_DATA_PATH:-}" ] && seed "$STEAM_COMPAT_DATA_PATH/pfx/system.reg"
exit 0
fi
for library in "${libraries[@]}"; do
for reg in "$library"/steamapps/common/Proton*/files/share/default_pfx*/system.reg \
"$library"/steamapps/compatdata/*/pfx/system.reg; do
seed "$reg"
done
done
for reg in "$steam_root"/compatibilitytools.d/*/files/share/default_pfx*/system.reg; do
seed "$reg"
done
@@ -330,7 +330,8 @@ case $mode in
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
# Steam runs a game's shared redistributables inside its prefix on first launch; marking
# them as already run keeps those x86 installers from starting under emulation at all.
# them as already run keeps those x86 installers from starting under emulation at all. This
# pass covers the prefixes that already exist; new ones are covered at launch (see below).
bannerlator-seed-redists "$steam_root" || true
# The compatibility tool runs Valve's arm64 Proton, which the client fetches like any depot.
# Asked for on the command line the first time, so a Windows title has it before it is needed.
@@ -403,18 +404,12 @@ case $mode in
# titles are installed: a title installed this session gets the client's own per-app Proton
# choice, which it cannot start. Refresh the per-app entries every so often; the registrar is
# idempotent, so a repeat costs nothing.
# The seeder has the same problem and a worse consequence. It marks the shared redistributables
# as already run in each prefix that exists, and a game's prefix is not created until the
# client launches it - so a title played for the first time this session was never covered, and
# its install scripts run for real. That is exactly the hang the seeder exists to prevent: Need
# for Speed Payback sat on "Running install script" with vcredist_x86.exe blocked in pipe_read,
# holding both ends of a pipe nobody would write to, while the client waited for it to exit.
# Seeding again on the same beat covers a prefix the moment it appears.
# The seeder runs on a shorter beat than the registrar because it is racing something. The
# client creates a prefix when it launches a title and runs that title's install scripts
# moments later, so the seeder has only that gap to mark the redistributables as already run.
# It stamps each prefix it has covered and skips it thereafter, so the extra passes cost
# almost nothing.
# The seeder no longer rides this beat. A title launched for the first time has its prefix
# created and its install scripts run moments apart, and a seeder on a timer lost that race
# often enough: Need for Speed Payback sat on "Running install script" with vcredist_x86.exe
# blocked in pipe_read, holding both ends of a pipe nobody would write to, while the client
# waited for it to exit. The compatibility tool now seeds Proton's prefix template and the
# title's prefix before every launch it hands to Proton, so a new prefix is born marked.
# The drawer's "Stretch games to fill the screen" works live: the app writes its choice to
# ~/.droiddeck-fill, and each change goes to gamescope as GAMESCOPE_FORCE_WINDOWS_FULLSCREEN on
# the root window, which it watches. What gamescope was started with is where this begins.
@@ -484,13 +479,11 @@ case $mode in
esac
done
) &
# The library beat. Both helpers are whole programs under proot (the compat one a python
# start and a parse of the client's config), and nearly every beat has nothing new for them.
# The seeder marks Proton's prefix template as well as the prefixes, so a new prefix is born
# marked and every 15 s is soon enough for the rest. The compat pass runs when a directory it
# reads from has changed since its last run - a new manifest, a new depot or tool, the client
# rewriting config.vdf - which [ -nt ] answers without starting anything, and once a minute
# regardless.
# The library beat. The compat registrar is a whole program under proot (a python start and a
# parse of the client's config), and nearly every beat has nothing new for it. It runs when a
# directory it reads from has changed since its last run - a new manifest, a new depot or
# tool, the client rewriting config.vdf - which [ -nt ] answers without starting anything, and
# once a minute regardless.
(
beat=0
compat_stamp=$HOME/.bl-compat-beat
@@ -508,7 +501,6 @@ case $mode in
nap 5
beat=$((beat + 1))
[ "$((beat % 3))" -eq 0 ] || continue
bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true
if compat_due; then
: > "$compat_stamp"
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
@@ -150,6 +150,17 @@ bl_game_cpus() {
# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against
# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime
# container that path never reaches Proton either. (WinNative's winnative-proton-launch.)
BL_REDISTS_SETUP = """
bl_redists() {
# Steam's shared redistributables marked as already run in this Proton's prefix template and in
# the title's prefix, before Proton is reached: the client creates a new prefix and runs the
# title's install scripts in one go, and an x86 vcredist under FEX can hang that for good.
# (bannerlator-seed-redists has the story.)
[ -x /usr/local/bin/bannerlator-seed-redists ] || return 0
/usr/local/bin/bannerlator-seed-redists --launch "$1" >&2 || true
}
"""
BL_LIBPATH_SETUP = """
bl_libpath() {
# A Proton built to prefer PipeWire (CachyOS's among the adopted ones) hung its audio thread
@@ -268,6 +279,7 @@ for name in %s; do
done
if [ -n "$depot" ]; then
bl_components "$depot"
bl_redists "$depot"
bl_directaudio "$depot"
bl_game_cpus
bl_idtech3 "$@"
@@ -282,7 +294,7 @@ exit 1
# something else.
LAUNCHER_SH = LAUNCHER_SH.replace(
"verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
+ BL_COMPONENTS_SETUP + BL_REDISTS_SETUP + BL_LIBPATH_SETUP, 1)
# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of
# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot
@@ -315,6 +327,7 @@ if [ -f "$fake" ]; then
esac
fi
bl_components "$here"
bl_redists "$here"
bl_directaudio "$here"
bl_game_cpus
bl_idtech3 "$@"
@@ -503,7 +516,7 @@ def adopt_extras(tools):
body = (EXTRA_WRAPPER_SH % entry_point).replace(
'here=$(dirname "$0")',
'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1)
+ BL_COMPONENTS_SETUP + BL_REDISTS_SETUP + BL_LIBPATH_SETUP, 1)
write_changed(wrapper, body, 0o755)
write_changed(manifest, EXTRA_MANIFEST_VDF)
write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry))