x86-64 client: the arm64 client's session steps, startup movie, Decky

What the session did only for the arm64 client, now for the x86-64 one:
- Per-app entries the x86 client aims at Valve's x86 Protons are pointed
  at the arm64 wrapper it defaults to (bannerlator-steam-compat --x64,
  BL_COMPAT_TOOL); tools in the client's own compatibilitytools.d are
  never repointed (cachyos wrappers start with "proton" too).
- The app's own games become the x86 client's shortcuts, on that wrapper.
- Redistributables are marked in both clients' prefixes.
- Proton downloads (~/.bl-proton-extra) go to the arm64 client, where
  the x86 wrappers come from, and GE-Proton is fetched when there is
  none (the x86 default); wrappers are made again after a download.
- The startup movie: config/uioverrides linked to the arm64 client's and
  its StartupMovie choice copied into the x86 client's config.vdf.
- Decky: the browser is the arm64 one, so the loader runs for the x86
  client too (CEF debugging marker copied).
- Deck mode for x86 behind BL_X64_DECK=1: preload/pulseguard.c (x86
  builds only) answers libpulse's missing operation that aborted the
  client with -steamos3.

bannerlator-steam-x64 --default-tool prints the wrapper Windows titles
map to.
This commit is contained in:
The412Banner committed 2026-10-03 00:10:25 -04:00
1 parent 60403f6a52
commit 6811f5d7a6
4 files changed
+174 -11

No files matched your search

@@ -290,13 +290,26 @@ case $mode in
# arguments to bannerlator-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL.
# Hundreds of megabytes each, so it is only ever done on request, and the line is dropped once
# it has succeeded - a failed download stays and is retried next session.
if [ "$steam_x64" = 0 ] && [ -s "$HOME/.bl-proton-extra" ]; then
# The x86-64 client runs the arm64 client's Protons, so they go there; its default is GE-Proton
# (Proton Experimental leaked Xwayland's window buffers until the device ran out of memory), so
# one is fetched when the arm64 client has none, as the arm64 side fetches its own Proton.
_bl_proton_root=$steam_root
if [ "$steam_x64" = 1 ]; then
_bl_proton_root=$XDG_DATA_HOME/Steam
if ! ls -d "$_bl_proton_root"/compatibilitytools.d/GE-Proton*-aarch64 >/dev/null 2>&1 &&
! grep -q '^ge' "$HOME/.bl-proton-extra" 2>/dev/null; then
echo ge >> "$HOME/.bl-proton-extra"
fi
fi
if [ -s "$HOME/.bl-proton-extra" ]; then
_bl_left=$HOME/.bl-proton-extra.left
: > "$_bl_left"
_bl_got_proton=0
while read -r _bl_extra || [ -n "$_bl_extra" ]; do
case ${_bl_extra# } in ""|"#"*) continue ;; esac
step "installing Proton '$_bl_extra' (large download, several minutes)"
if bannerlator-proton-extra "$steam_root" $_bl_extra; then
if bannerlator-proton-extra "$_bl_proton_root" $_bl_extra; then
_bl_got_proton=1
step "installed Proton '$_bl_extra'"
else
step "Proton '$_bl_extra' failed; will retry next session"
@@ -304,6 +317,10 @@ case $mode in
fi
done < "$HOME/.bl-proton-extra"
mv -f "$_bl_left" "$HOME/.bl-proton-extra"
# The x86-64 client's wrappers are made at --prepare: once more for the new one.
if [ "$steam_x64" = 1 ] && [ "$_bl_got_proton" = 1 ]; then
bannerlator-steam-x64 --prepare || true
fi
fi
# steam:// URLs left in ~/.bl-steam-urls are handed to the client on its command line. The app
# writes one when the user asks for a Proton that is one of Valve's own depots rather than a
@@ -360,6 +377,22 @@ case $mode in
# 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.
bannerlator-seed-redists "$steam_root" || true
else
# The x86-64 client: the arm64 client's library folders are its own too (bannerlator-steam-x64),
# and its Windows titles run the arm64 Protons through wrappers. A per-app entry the client aims
# at one of Valve's x86 Protons is pointed at the wrapper it defaults to, the app's own games are
# its shortcuts too, and redistributables are marked in both clients' prefixes.
_bl_x64_tool=$(bannerlator-steam-x64 --default-tool)
if [ -n "$_bl_x64_tool" ]; then
step "pointing the x86-64 client's Windows titles at $_bl_x64_tool"
BL_COMPAT_TOOL=$_bl_x64_tool bannerlator-steam-compat "$steam_root" --x64 || true
if [ -n "${BL_ADDED_GAMES:-}" ]; then
step "adding the app's own games to the x86-64 client's library"
BL_COMPAT_TOOL=$_bl_x64_tool bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
fi
bannerlator-seed-redists "$XDG_DATA_HOME/Steam" || true
bannerlator-seed-redists "$steam_root" || true
fi
# 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.
@@ -666,7 +699,9 @@ BUSCONF
# reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used
# only when its recorded architecture and this guest's existing FEX installation agree.
decky_loader=$HOME/homebrew/services/PluginLoader
if [ "$steam_x64" = 0 ] && [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
# The x86-64 client's browser is the arm64 client's (bannerlator-x64-arm-webhelper), so the loader
# finds it on the same debugging port.
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown)
if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && [ -x /usr/bin/FEX ] && [ -n "${FEX_ROOTFS:-}" ]; }; then
(
@@ -714,7 +749,10 @@ BUSCONF
# Not for the x86-64 client yet: with -steamos3 its SteamOS audio manager aborts on this runtime's
# PulseAudio 13 server (pa_operation_get_state assertion) right after start. Big Picture without
# the Deck flags runs.
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ] && [ "$steam_x64" = 0 ]; then
# BL_X64_DECK=1 tries it with the x86-64 client: the session preload answers libpulse's missing
# operation there (preload/pulseguard.c).
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ] &&
{ [ "$steam_x64" = 0 ] || [ "${BL_X64_DECK:-0}" = 1 ]; }; then
_bl_ui+=(-steamdeck -steamos3)
# The QAM's Scaling Filter lists NVIDIA Image Scaling only when told gamescope has it (it does).
export STEAM_GAMESCOPE_NIS_SUPPORTED=1
@@ -21,6 +21,9 @@ import shutil
import sys
TOOL = "bannerlator-proton-arm64"
# The x86-64 client (bannerlator-steam-x64) runs the arm64 Protons through wrappers of its own, named
# *-a64, and passes the one to map titles to here; its own config and shortcuts take that name.
TOOL = os.environ.get("BL_COMPAT_TOOL") or TOOL
# Valve's own ARM64 Proton, by the name the client registers it under. Used only to bootstrap a
# fresh install: naming it is what makes the client offer it and fetch the depot.
SEED_TOOL = "proton-experimental-arm64"
@@ -578,12 +581,33 @@ def installed_apps(steam_root):
return sorted(apps, key=int)
def local_tools(steam_root):
"""The names of the compatibility tools installed in steam_root/compatibilitytools.d."""
names = set()
tools = os.path.join(steam_root, "compatibilitytools.d")
try:
entries = os.listdir(tools)
except OSError:
return names
for entry in entries:
try:
with open(os.path.join(tools, entry, "compatibilitytool.vdf"), encoding="utf-8", errors="replace") as f:
m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', f.read())
except OSError:
continue
if m:
names.add(m.group(1).lower())
return names
def register_default(config_path, apps=(), name=None, protect=()):
"""Writes the mapping. `name` defaults to our own tool; the first-run seed passes Valve's.
Returns the app ids given a per-app entry by this call."""
tool = name or TOOL
# Tool names the user may deliberately have chosen (see adopt_extras); never repointed.
keep = {n.lower() for n in protect}
# Tool names the user may deliberately have chosen (see adopt_extras); never repointed. Every
# tool in this client's own compatibilitytools.d is one: the x86-64 client's arm64 wrappers
# include proton-cachyos-*-a64, which starts with "proton" like Valve's own.
keep = {n.lower() for n in protect} | local_tools(os.path.dirname(os.path.dirname(config_path)))
with open(config_path, encoding="utf-8", errors="replace") as f:
original = f.read()
tokens = tokenize(original)
@@ -657,6 +681,15 @@ def build_tool(tool_dir):
def main():
steam_root = sys.argv[1]
if "--x64" in sys.argv[2:]:
# The x86-64 client: its tools are wrappers bannerlator-steam-x64 makes and its Linux titles
# run as Linux builds (FEX), so only the per-app entries the client aimed at a Proton it
# cannot start are repointed, to BL_COMPAT_TOOL.
config = os.path.join(steam_root, "config", "config.vdf")
if os.path.isfile(config):
register_default(config)
print("bannerlator-steam-compat: x86-64 client's Proton entries point at %s" % TOOL)
return
tools = os.path.join(steam_root, "compatibilitytools.d")
os.makedirs(tools, exist_ok=True)
build_tool(os.path.join(tools, TOOL))
@@ -34,6 +34,21 @@ LOG=$X64_HOME/steam-x64.log
mkdir -p "$X64_HOME"
step() { echo "== $(date -u +%H:%M:%S) $*" | tee -a "$LOG"; }
# The arm64 Proton wrapper this client maps Windows titles to: the newest GE-Proton, else the arm64
# client's own (bannerlator-proton-arm64, Proton Experimental). Prints its folder; --default-tool
# prints its tool name, which the session hands to bannerlator-steam-compat and the shortcuts.
x64_default_dir() {
local d
for d in $(ls -d "$STEAM_X64"/compatibilitytools.d/GE-Proton*-aarch64 2>/dev/null | sort -V -r); do
[ -f "$d/.droiddeck-arm64-proton" ] && { echo "$d"; return; }
done
echo "$STEAM_X64/compatibilitytools.d/bannerlator-proton-arm64"
}
x64_default_tool() {
sed -n 's/^[[:space:]]*"\([^"]*\)"[[:space:]]*$/\1/p' "$(x64_default_dir)/compatibilitytool.vdf" 2>/dev/null |
grep -v -x -e compatibilitytools -e compat_tools | head -n 1
}
x64_prepare() {
# --- FEX -------------------------------------------------------------------------------------
if [ ! -x /usr/local/bin/FEX ] || [ "$(cat /usr/local/share/fex-emu/.droiddeck-tag 2>/dev/null)" != "$FEX_TAG" ]; then
@@ -377,11 +392,7 @@ PY
# with bannerlator-proton-arm64 (Proton Experimental) Xwayland never got its window buffers back
# and FlatOut took the device's RAM with it (10.5 GB of dma-buf in 35 s); GE-Proton, same game,
# same client, stayed flat. bannerlator-proton-a64 when no GE-Proton is installed.
default=
for d in $(ls -d "$STEAM_X64"/compatibilitytools.d/GE-Proton*-aarch64 2>/dev/null | sort -V -r); do
[ -f "$d/.droiddeck-arm64-proton" ] && { default=$d; break; }
done
[ -n "$default" ] || default=$STEAM_X64/compatibilitytools.d/bannerlator-proton-arm64
default=$(x64_default_dir)
if [ -f "$default/compatibilitytool.vdf" ] && [ -f "$STEAM_X64/config/config.vdf" ] &&
! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
python3 - "$STEAM_X64/config/config.vdf" "$default/compatibilitytool.vdf" <<'PY' || step "could not make ARM64 Proton the default"
@@ -413,6 +424,39 @@ entry = ('\n\t\t\t\t\t"0"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"%s"\n' % tool +
'\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"75"\n\t\t\t\t\t}')
s = s[:m.end()] + entry + s[m.end():]
open(path, "w", encoding="utf-8", errors="surrogateescape").write(s)
PY
fi
# The startup movie the app stages for the arm64 client (its config/uioverrides/movies, chosen in
# its config.vdf as StartupMovie): the same one here, through a link to that folder and the same
# choice in this client's config. And Decky's marker for the client's CEF debugging port, which the
# loader talks to whichever client runs: the browser is the arm64 one either way.
if [ -d "$ARM_STEAM/config/uioverrides" ] && [ ! -e "$STEAM_X64/config/uioverrides" ]; then
ln -s "$ARM_STEAM/config/uioverrides" "$STEAM_X64/config/uioverrides"
fi
if [ -f "$ARM_STEAM/.cef-enable-remote-debugging" ]; then
: > "$STEAM_X64/.cef-enable-remote-debugging"
else
rm -f "$STEAM_X64/.cef-enable-remote-debugging"
fi
if [ -f "$ARM_STEAM/config/config.vdf" ] && [ -f "$STEAM_X64/config/config.vdf" ] &&
! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
python3 - "$ARM_STEAM/config/config.vdf" "$STEAM_X64/config/config.vdf" <<'PY' || step "could not copy the startup movie choice"
import re, sys
movie = re.search(r'\n([ \t]*)"StartupMovie"\s*\{[^{}]*\}', open(sys.argv[1], encoding="utf-8", errors="replace").read())
if not movie:
sys.exit(0)
path = sys.argv[2]
s = open(path, encoding="utf-8", errors="surrogateescape").read()
old = re.search(r'\n[ \t]*"StartupMovie"\s*\{[^{}]*\}', s)
if old:
if old.group(0).split(None, 1)[1] == movie.group(0).split(None, 1)[1]:
sys.exit(0)
s = s[:old.start()] + s[old.end():]
steam = re.search(r'"Valve"\s*\{\s*"Steam"\s*\{', s)
if not steam:
sys.exit(0)
s = s[:steam.end()] + movie.group(0) + s[steam.end():]
open(path, "w", encoding="utf-8", errors="surrogateescape").write(s)
PY
fi
# A FEX process takes its rootfs from FEXServer, not from its own environment, and a server
@@ -546,6 +590,7 @@ case "${1:-}" in
--prepare) x64_prepare; exit 0 ;;
--exec) shift; x64_exec "$@" ;;
--gpu) x64_gpu; exit $? ;;
--default-tool) x64_default_tool; exit 0 ;;
esac
# --- standalone (the desktop menu entry) ------------------------------------------------------
+47
View File
@@ -0,0 +1,47 @@
/* PulseAudio operations that never started, for the x86-64 Steam client.
*
* In Deck mode (-steamos3) the x86-64 client's SteamOS audio manager passes libpulse the operation a
* failed request returned - none - and libpulse aborts the client on it: "Assertion 'o' failed at
* ../src/pulse/operation.c:136 pa_operation_get_state()" right after start (device, 2026-10-02).
* A missing operation is answered as cancelled and its unref/cancel ignored; every real one goes
* to libpulse. Built into the x86 libraries only: the arm64 client does not hit it. */
#if defined(__x86_64__) || defined(__i386__)
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stddef.h>
typedef struct pa_operation pa_operation;
#define PA_OPERATION_CANCELLED 2
int pa_operation_get_state(pa_operation *o) {
static int (*next)(pa_operation *);
if (!o)
return PA_OPERATION_CANCELLED;
if (!next)
next = (int (*)(pa_operation *))dlsym(RTLD_NEXT, "pa_operation_get_state");
return next ? next(o) : PA_OPERATION_CANCELLED;
}
void pa_operation_unref(pa_operation *o) {
static void (*next)(pa_operation *);
if (!o)
return;
if (!next)
next = (void (*)(pa_operation *))dlsym(RTLD_NEXT, "pa_operation_unref");
if (next)
next(o);
}
void pa_operation_cancel(pa_operation *o) {
static void (*next)(pa_operation *);
if (!o)
return;
if (!next)
next = (void (*)(pa_operation *))dlsym(RTLD_NEXT, "pa_operation_cancel");
if (next)
next(o);
}
#else
typedef int pulseguard_arm_build_has_nothing_to_do;
#endif