Compare commits

...
40 Commits
Author SHA1 Message Date
The412Banner 8f3d8d0afa FEX build: the x86 sysroot for the guest thunks' toolchains
The guest halves linked without it on the arm64 builder (no crtbeginS.o,
no libgcc).
2026-10-02 18:36:41 -04:00
The412Banner 4ee3463c20 FEX build: create the x86 sysroot's package cache 2026-10-02 18:31:41 -04:00
The412Banner 7b836d52c3 fastpath: stand down in FEX only for 32-bit programs
libblfastpath broke FEX's 32-bit mode (SIGILL at start), so the x86-64
client ran with PROOT_FP_OFF=1 and every one of its syscalls went the
slow ptrace way. In a FEX process it now reads the program FEX is about
to run and stays on for a 64-bit ELF only. bannerlator-steam-x64 drops the
blanket switch (BL_X64_FP_OFF=1 brings it back).
2026-10-02 18:31:31 -04:00
The412Banner 5530d8aa70 FEX build: optional host thunks
FEX_THUNKS=1 (build-fex.yml 'thunks' input) builds FEX's host thunks: the
arm64 half against the runtime's graphics dev packages, the x86 and i686
guest halves with clang against a sysroot of Arch Linux x86_64 and
multilib packages unpacked by pacman (not run). With them an x86 program's
Vulkan, GL, X11, Wayland and DRM calls reach the runtime's own libraries -
Turnip and Mesa on the GPU - instead of llvmpipe.
2026-10-02 18:30:29 -04:00
The412Banner 822ca6405f build: the i386 preloads at the very end of the preload step
The step also builds libtracefs with the aarch64 cross compiler after the
first move; gcc-multilib must come after all of them.
2026-10-02 18:20:48 -04:00
The412Banner cca4ff3746 build: the i386 preloads after every aarch64 one
gcc-multilib conflicts with the aarch64 cross compiler and removed it, so
the next aarch64 build in the step failed (exit 127).
2026-10-02 18:20:10 -04:00
The412Banner 49f1ceaadc x86-64 client: run native Linux games, 32-bit preloads, no Deck flags
- DroidDeck direct: a compatibility tool the x86 client gets in its own
  compatibilitytools.d, running a native Linux game with Valve's scout
  library runtime under FEX instead of the Steam Linux Runtime container
  (pressure-vessel, which needs bwrap). Left 4 Dead 2 (BL_X64_DIRECT_APPS)
  is mapped to it in the x86 config while that client is not running.
- The session shim and the controller reader are also built for i386 and
  preloaded through glibc's $LIB, so 32-bit games get them too.
- The runtime's bin/ on the client's PATH (steam-runtime-launch-client).
- No -steamdeck -steamos3 for the x86 client yet: its SteamOS audio
  manager aborts on the runtime's PulseAudio 13 (pa_operation_get_state).
2026-10-02 18:16:17 -04:00
The412Banner 2d2ffd9d05 Progress log: x86-64 client controllers, online, Play-button choice 2026-10-02 17:28:49 -04:00
The412Banner 05d74c8a1f Steam session: choose the ARM64 or the x86-64 client
A Steam setting (Client > Steam client: ARM64, the default, or x86-64
under FEX, experimental) sent as BL_STEAM_CLIENT_ARCH. With x86_64 the
session starts Valve's x86-64 client through bannerlator-steam-x64 in its
own home instead of steamrtarm64/steam, so the Play button gets every fix
the session has (system bus and NetworkManager stand-in, gamescope, Deck
and Big Picture UI, stale locks, branch sync, restart loop, stop watcher,
logs, core pinning); what is only for the arm64 client (ARM64 Proton and
its mapping, redist seeding, Decky, the steamclient.so probes) is skipped.
bannerlator-steam-x64 gains --prepare and --exec for that; its own library
replaces the shared steamapps link.
2026-10-02 17:28:00 -04:00
The412Banner cb2334da4e bannerlator-steam-x64: the app's FEX preset for the client
The client ran on FEX's defaults (half-barrier TSO, full-precision x87).
Use the app's default game preset, PERFORMANCE_TSO (WinNative's), as the
FEX_* variables both FEX builds read; values already set win.
2026-10-02 17:24:24 -04:00
The412Banner 8a89387ef8 x86-64 client: the session's fixes for the arm64 client
From an audit of bannerlator-session against bannerlator-steam-x64:
- a system bus and the NetworkManager stand-in when none is up, stopped
  with the client: the client showed no connection on the desktop
- stale .crash/steam.pid/htmlcache Singleton* cleared before a start
- package/beta matched to -clientbeta before every start (rc=42 loop)
- BL_CLIENT_CPUS re-pinning of the client and its web helper every 5 s
- pathcache.c and perms.c also recognise steamrt64 (the path cache saved
  most of the arm64 client's start-up; the write override covers its
  self-update)
2026-10-02 16:59:56 -04:00
The412Banner 47e528102d x86-64 client: the controller reader for x86 programs
The session's pads exist for a guest program only through libfakeinput
(the FAKE_EVDEV_* rings), which was built for arm64 alone, so the x86
client saw no controller. Build it natively for x86-64 too, stage it, and
preload it through the FEX rootfs next to the session shim.
2026-10-02 16:52:53 -04:00
The412Banner 1d90448924 Progress log: the x86-64 Steam client under FEX (2026-10-02) 2026-10-02 16:52:01 -04:00
The412Banner 131ba72ee5 x86-64 client: hide the tracer only from tier0's status reads
Hiding it from the whole x86 web helper got it past tier0 (message loop,
BrowserCreated) but then Chromium trapped on the status descriptor it
opened itself, now a memfd copy - which is the other reason the shim
leaves the web helper alone. With BL_WEBHELPER_HIDE_TRACER=1 only opens
called from libtier0_s.so get the copy; Chromium's get procfs.
2026-10-02 16:28:43 -04:00
The412Banner b2e3a3a5ea x86-64 client: hide proot's tracer from the web helper too
Traced per process through FEXServer: every x86 web helper read
/proc/self/status (the string sits in libtier0_s.so) and died of SIGTRAP
right after. proot is a tracer, so tier0 believes a debugger is attached
and breaks into it with int3 on the first failed assertion. The arm64
build only issues a stray svc there, which is why the session shim leaves
the arm64 web helper reading the real TracerPid. BL_WEBHELPER_HIDE_TRACER=1,
set by bannerlator-steam-x64, serves the x86 one TracerPid 0 as well.
2026-10-02 16:17:55 -04:00
The412Banner 254e1db969 bannerlator-steam-x64: BL_FEXSERVER_LOG for pid-tagged FEX logs
A diagnostic: FEX's file log is opened without O_APPEND, so each process
overwrites the last. Through a foreground FEXServer every line carries
the pid and tid it came from.
2026-10-02 16:14:31 -04:00
The412Banner 34e7191e02 bannerlator-steam-x64: BL_FEX picks the FEX build that runs the client
For diagnostics: a DEBUG_STRACE build traces the client and everything it
starts.
2026-10-02 15:49:32 -04:00
The412Banner 5dfa6f43ed bannerlator-steam-x64: webhelper on Wayland
On X11 Chromium sets up GBM on the render node Xwayland hands out over
DRI3 even with the GPU disabled, loops on KMS dumb-buffer ioctls against
the KGSL device, and never creates the SteamUI browser; the client
restarted it every 10 s. Without a GPU, Chromium's Wayland backend draws
to shared-memory buffers.
2026-10-02 15:46:35 -04:00
The412Banner 2ff30ab4bb bannerlator-steam-x64: no DRI3 for x86 Mesa
Hiding /dev/dri was not enough: Xwayland hands the render node out over
DRI3, and llvmpipe then loops on KMS dumb-buffer ioctls against the KGSL
device. LIBGL_DRI3_DISABLE=1 presents through plain X.
2026-10-02 15:44:41 -04:00
The412Banner aa27921dee bannerlator-steam-x64: hide the GPU node from x86 programs
x86 Mesa and the Vulkan loader still enumerated /dev/dri, found the
session's KGSL node and looped on it (KMS dumb-buffer ioctls), and the
webhelper never brought a browser up; the client restarted it every
10 s. FEX resolves paths in the rootfs first, so an empty /dev/dri there
leaves llvmpipe and lavapipe.
2026-10-02 15:43:52 -04:00
The412Banner 581a7d1557 bannerlator-steam-x64: software GL and the rootfs's Vulkan for the x86 client
The desktop exports zink over the arm64 Turnip ICD (GALLIUM_DRIVER,
MESA_LOADER_DRIVER_OVERRIDE, VK_ICD_FILENAMES); x86 Mesa found no driver
and the client died on glXChooseVisual. Clear them and use llvmpipe.
2026-10-02 15:31:02 -04:00
The412Banner e07e436857 bannerlator-steam-x64: SteamRT libraries only where the rootfs has none
LD_LIBRARY_PATH beats the system folders regardless of order, so the
platform's own older libc.so.6 shadowed the rootfs's (GLIBC_2.38 not
found). Keep only the libraries the rootfs lacks.
2026-10-02 15:29:19 -04:00
The412Banner e0348adc02 bannerlator-steam-x64: soname links for the SteamRT platform libraries
Valve's platform runtime files carry each library under its full version
only; pressure-vessel makes the soname links when it builds a container.
Link them into a folder of our own and run the rootfs's ldconfig -n there,
so libgtk-x11-2.0.so.0 and the rest resolve.
2026-10-02 15:28:46 -04:00
The412Banner a7b4d79c15 bannerlator-steam-x64: the SteamRT platform libraries after the rootfs's
steamui.so needs GTK 2 (libgtk-x11-2.0.so.0), which the SteamRT container
provides and FEX's Ubuntu 24.04 rootfs does not. Append Valve's own
steamrt3c platform library directories to the client's library path,
after the rootfs.
2026-10-02 15:27:38 -04:00
The412Banner ae96d7fdaf bannerlator-steam-x64: stay out of pressure-vessel, restart on request
After its self-update the x86 client re-executes itself in the SteamRT
container through pressure-vessel-wrap, which needs bwrap. The FEX rootfs
with the client's steamrt64 libraries is the container here, as proot is
for the arm64 client: set the client's recursion guard. Restart on exit
code 42 the way steam.sh does.
2026-10-02 15:26:34 -04:00
The412Banner e5145bfad5 x86-64 client: System V IPC shim for x86 programs, and staging
The x86 client stops at start on a tier0 assertion: semget answers ENOSYS,
as Android kernels have no System V IPC (traced with a DEBUG_STRACE FEX).
The session shim that gives the arm64 client its semaphores is now also
built as native x86-64 and preloaded through the FEX rootfs's own
ld.so.preload. SessionFiles stages it and bannerlator-steam-x64 into
installed runtimes.
2026-10-02 15:16:24 -04:00
The412Banner b3c54e6cda FEX build: optional diagnostic CXXFLAGS
A workflow_dispatch input passes extra CXXFLAGS (e.g. -DDEBUG_STRACE for a
syscall trace) into the build; such builds stay artifacts and are never
published under a release tag.
2026-10-02 15:08:49 -04:00
The412Banner a6a11cad54 bannerlator-steam-x64: answer the SteamRT3 client's namespace probe
The x86 client carries pressure-vessel and will not start when
steam-runtime-check-requirements cannot create a user namespace; no
Android app can, and proot is the container here as it is for the arm64
client, which has no such probe. STEAM_RUNTIME_STEAMRT points it at
Valve's runtime linked file by file, with only that probe replaced.
2026-10-02 15:05:49 -04:00
The412Banner 615539dcd3 bannerlator-steam-x64: start steamrt64/steam the way the session starts the arm64 client
Through steam.sh, steam-runtime-check-requirements' bwrap probe needs user
namespaces, which proot does not have. Run the SteamRT3 client directly
with STEAM_RUNTIME=1 and its own library folders, after the ~/.steam
links steam.sh would make, as bannerlator-session does for steamrtarm64.
2026-10-02 15:04:48 -04:00
The412Banner b121a89c8b bannerlator-steam-x64: start Valve's 64-bit SteamRT3 client
The legacy ubuntu12_32 path in steam.sh refuses to start without user
namespaces (steam-runtime-check-requirements, exit 71). On the beta branch
STEAM_FORCE_CLIENT=steamrt64 makes steam.sh run steamrt64/steam directly,
the x86 twin of the arm64 client the session already runs.
2026-10-02 15:03:46 -04:00
The412Banner d7b7193cbd bannerlator-steam-x64: run FEX without the proot fast path
With libblfastpath loaded into FEX, every i386 program died with SIGILL
(FEX's fatal trap) before logging was up, and steam.sh reported the
32-bit libc.so.6 missing. Bisected on device: the session's other two
preloads are harmless; PROOT_FP_OFF=1 lets i386 ld.so run.
2026-10-02 15:01:28 -04:00
The412Banner ca166c670e bannerlator-steam-x64: FEX fex-2609-r2 2026-10-02 14:43:48 -04:00
The412Banner 42aa7692dc FEX: fall back to openat inside the rootfs when openat2 is missing
Our proot answers openat2 with ENOSYS. FEX opened every rootfs path with
openat2(RESOLVE_IN_ROOT) and only fell back on EXDEV, so in a session
every x86 file lookup missed the rootfs and hit the arm64 guest: bash
under FEX could not find libtinfo. GetEmulatedFDPath already follows
absolute symlinks inside the rootfs, so a plain openat relative to the
rootfs fd is the same lookup. The rootfs also gets an empty
ld.so.preload, so the guest's arm64 preloads are not tried.
2026-10-02 14:38:37 -04:00
The412Banner 5786ddf81f bannerlator-steam-x64: no early FEX run, stop a stale FEXServer
FEX processes take the rootfs from FEXServer. The version probe started
one before FEX_ROOTFS was set, and later clients inherited its empty
rootfs. Still not enough on its own on the device: under investigation.
2026-10-02 14:04:13 -04:00
The412Banner ec3243a997 bannerlator-steam-x64: a finished rootfs download is not a failure
curl -C - on a complete file gets 416, which set -e turned into an abort.
The Ubuntu 24.04 image is 525 MB, not 850.
2026-10-02 13:58:23 -04:00
The412Banner d5ef1c832a Opt-in x86-64 Steam client under FEX
The arm64 Linux client never runs VAC: TF2 and L4D2 are refused by VAC
servers and no client log mentions VAC, because VAC modules are x86 code.
bannerlator-steam-x64 installs Valve's x86-64 client, FEX and FEX's
Ubuntu 24.04 x86 rootfs, and runs the client unchanged under FEX in a
home of its own, sharing only steamapps with the arm64 client. The arm64
client stays the default; the desktop menu gets an experimental entry.
2026-10-02 13:56:22 -04:00
The412Banner 61ea96a9e8 Build FEX for the runtime
The x86-64 Steam client experiment needs FEX inside the guest, and neither
Arch Linux ARM nor FEX publishes a Linux arm64 binary. Built in the same
Arch Linux ARM container as gamescope, without thunks, tuned for generic
armv8-a, with the runtime's own unsquashfs alongside to unpack FEX's x86
rootfs image.
2026-10-02 13:51:26 -04:00
The412Banner ed2160c137 VAC diagnostics: an attempt to connect is not a connection
Left 4 Dead 2 logs "Connecting to", retries, and refuses a VAC server with
a "Connection Blocked: Please remove plug-ins" dialog that never reaches
the console. Counting the attempt as reaching a server would have reported
an insecure game as connected. Only "Connected to" counts now; an attempt
alone is reported as not connected, naming the dialog to look for.
2026-10-02 12:37:37 -04:00
The412Banner c9fb19b0cd Restart the client once for a title mapped while it runs
The client reads config.vdf once, as it starts. A title installed during a
session got its per-app ARM64 Proton entry from the compat beat, but the
running client kept its own pick: native for a Linux build, which then sat
on "Downloading content (0%)" waiting for Steam Linux Runtime 2.0 and FEX
it can never run here (Left 4 Dead 2), or proton-stable for a Windows one.

The beat now runs the compat pass with --live, which notes titles it maps
while the client runs. Once each has finished installing and no game is
running, the session restarts the client through the existing restart-once
marker. Once per title and three times per session at most. The mapping is
re-applied before the client starts again, since it writes config.vdf back
from memory as it exits.
2026-10-02 12:30:34 -04:00
The412Banner 9a6558d8b3 VAC diagnostics: game console + vac.txt in the session logs
A Source-engine game says whether it started VAC-secure only in its own
console, which reaches a file only with -condebug. With the new Setup
toggle on (off by default), the launch wrapper adds -condebug to Source
and Source 2 titles and notes each launch; at the end of the session each
console.log written during it is copied, scrubbed, into vac/ and vac.txt
quotes the game's secure/insecure lines, any VAC lines in the client's
logs and which client builds are on disk. No connection to a server is
reported as inconclusive rather than guessed.
2026-10-02 11:17:56 -04:00
26 changed files with 1133 additions and 42 deletions

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
name: Build FEX (x86-64 Steam client experiment)
# FEX built for the runtime in an Arch Linux ARM container on an arm64 runner (tools/fex), for the
# opt-in x86-64 Steam client (bannerlator-steam-x64). Published as a release asset that script
# fetches; the runtime image and the default arm64 client are untouched.
on:
push:
paths:
- 'tools/fex/**'
- '.github/workflows/build-fex.yml'
workflow_dispatch:
inputs:
tag:
description: 'Release tag to publish as (e.g. fex-2609-r1); empty = artifact only'
required: false
default: ''
thunks:
description: 'Build the host thunks (Vulkan/GL/X11/... to the runtime arm64 libraries)'
required: false
default: 'false'
cxxflags:
description: 'Extra CXXFLAGS for a diagnostic build (e.g. -DDEBUG_STRACE); never published with a tag'
required: false
default: ''
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
- name: Build in Arch Linux ARM
run: |
set -euo pipefail
docker run --rm -e FEX_EXTRA_CXXFLAGS="${{ inputs.cxxflags }}" -e FEX_THUNKS="${{ inputs.thunks == 'true' && '1' || '0' }}" -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/fex/build-in-arch.sh
sudo chown -R "$(id -u):$(id -g)" .
- uses: actions/upload-artifact@v4
with:
name: fex
path: |
fex.tzst
fex.tzst.sha256
fex-needed.txt
fex-files.txt
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != '' && inputs.cxxflags == ''
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release create "${{ inputs.tag }}" --latest=false --prerelease=false --title "${{ inputs.tag }}" --notes "FEX for the runtime (tools/fex/build-in-arch.sh); fetched by bannerlator-steam-x64. sha256 $(cut -d' ' -f1 fex.tzst.sha256)" fex.tzst fex.tzst.sha256 || gh release upload "${{ inputs.tag }}" fex.tzst fex.tzst.sha256 --clobber
# A component the apk fetches by tag, never something users install: it must not
# become the repo's Latest release or appear as a pre-release, also when re-uploaded.
gh release edit "${{ inputs.tag }}" --latest=false --prerelease=false
+21 -2
View File
@@ -28,7 +28,7 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v2
PRELOAD_CACHE_VERSION: v5
jobs:
build:
@@ -176,6 +176,17 @@ jobs:
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
# The same session shim for x86-64 programs under FEX (bannerlator-steam-x64): the x86
# Steam client needs its System V semaphores just as the arm64 one does. Native gcc on
# this runner; Ubuntu 24.04's glibc is the one in FEX's Ubuntu 24.04 rootfs.
gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblsession-x86_64.so" tools/linuxfs/preload/*.c -ldl
strip --strip-unneeded "$D/libblsession-x86_64.so"
# And the controller reader: the session's pads reach x86-64 programs the same way.
g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput-x86_64.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
strip --strip-unneeded "$D/libfakeinput-x86_64.so"
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
@@ -229,7 +240,15 @@ jobs:
install -Dm644 tools/linuxfs/desktop/panel.conf "$D/etc/xdg/lxqt/panel.conf"
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
ls -lR "$D"
# And for 32-bit x86 programs (Left 4 Dead 2 and the other Source games are i386). Last:
# gcc-multilib conflicts with the aarch64 cross compiler and removes it.
sudo apt-get install -y -qq gcc-multilib g++-multilib >/dev/null
gcc -m32 -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblsession-i386.so" tools/linuxfs/preload/*.c -ldl
g++ -m32 -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
-pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
-o "$D/libfakeinput-i386.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
strip --strip-unneeded "$D/libblsession-i386.so" "$D/libfakeinput-i386.so"
- name: Cache PulseAudio 13.0 source
id: pa-src
uses: actions/cache@v4
@@ -258,6 +258,7 @@ class MainActivity : ComponentActivity() {
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var steamClientArch by mutableStateOf("arm64")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
@@ -279,6 +280,7 @@ class MainActivity : ComponentActivity() {
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var vacDiagnostics by mutableStateOf(false)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
private var homeScreenEnabled by mutableStateOf(false)
@@ -363,7 +365,7 @@ class MainActivity : ComponentActivity() {
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, vacDiagnostics = vacDiagnostics,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -461,6 +463,10 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
},
onVacDiagnostics = {
SessionPrefs.setVacDiagnostics(this, !SessionPrefs.vacDiagnostics(this))
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
},
onShareLogs = {
Thread({
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
@@ -959,6 +965,7 @@ class MainActivity : ComponentActivity() {
storageOptions = storageOptions,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1005,6 +1012,7 @@ class MainActivity : ComponentActivity() {
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
@@ -1108,6 +1116,7 @@ class MainActivity : ComponentActivity() {
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
steamClientArch = SessionPrefs.steamClientArch(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
@@ -1192,6 +1201,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> "Desktop"
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
@@ -45,6 +45,7 @@ object SessionArtifacts {
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
copySteamLogs(context, dir)
VacDiagnostics.collect(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
@@ -145,6 +146,7 @@ object SessionArtifacts {
)
if (newest) {
copySteamLogs(context, dir)
VacDiagnostics.collect(context, dir)
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
@@ -29,6 +29,10 @@ object SessionFiles {
val files = arrayOf(
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libblsession-x86_64.so" to "usr/local/lib/x86_64-linux-gnu/libblsession.so",
"libfakeinput-x86_64.so" to "usr/local/lib/x86_64-linux-gnu/libfakeinput.so",
"libblsession-i386.so" to "usr/local/lib/i386-linux-gnu/libblsession.so",
"libfakeinput-i386.so" to "usr/local/lib/i386-linux-gnu/libfakeinput.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"libblfastpath.so" to "usr/local/lib/libblfastpath.so",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
@@ -39,6 +43,8 @@ object SessionFiles {
"usr/local/bin/bannerlator-proton-extra" to "usr/local/bin/bannerlator-proton-extra",
"usr/local/bin/bannerlator-netmanager" to "usr/local/bin/bannerlator-netmanager",
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
// Experimental: Valve's x86-64 client under FEX (desktop menu entry).
"usr/local/bin/bannerlator-steam-x64" to "usr/local/bin/bannerlator-steam-x64",
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
@@ -424,6 +424,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean("logs", on).apply()
}
/**
* VAC diagnostics (see [VacDiagnostics]): Source-engine games launched with -condebug and their
* console plus the client's VAC lines summarised into vac.txt. Off by default: it changes a
* game's command line.
*/
fun vacDiagnostics(context: Context): Boolean =
prefs(context).getBoolean("vac_diagnostics", false)
fun setVacDiagnostics(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("vac_diagnostics", on).apply()
}
// ── Per-mode display ────────────────────────────────────────────────────────────────────
/**
@@ -473,6 +485,17 @@ object SessionPrefs {
prefs(context).edit().putString("steamChannel", id).apply()
}
/**
* Which Steam client a Steam session runs: "arm64" (Valve's native ARM64 client, the default) or
* "x86_64" (Valve's x86-64 client under FEX, bannerlator-steam-x64 - experimental, for what the
* arm64 client cannot do: it never runs VAC). Each has its own sign-in and library.
*/
fun steamClientArch(context: Context): String = prefs(context).getString("steamClientArch", null) ?: "arm64"
fun setSteamClientArch(context: Context, id: String) {
prefs(context).edit().putString("steamClientArch", id).apply()
}
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
@@ -267,6 +267,7 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
VacDiagnostics.arm(this)
val sessionDir = openSessionFolder()
val sessionLog = File(sessionDir, "session.log")
@@ -559,6 +560,7 @@ class SessionService : Service() {
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
if (steamHere) guest.add("BL_STEAM_CLIENT_ARCH=" + SessionPrefs.steamClientArch(this))
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
@@ -0,0 +1,194 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.LogRedactor
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.io.RandomAccessFile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* VAC diagnostics: what a session can show about whether a game ran VAC-secure, from the files
* Valve's own software writes - nothing is inferred from our side of the launch.
*
* The verdict is the game's to give. A Source-engine game decides secure or insecure once, as it
* starts, and says so only in its own console ("You are in insecure mode. You must restart before
* you can connect to secure servers."). That console reaches a file only when the game runs with
* -condebug, as console.log in the game's mod folder. With this on, the launch wrapper
* (bannerlator-proton, bl_vacdiag) adds -condebug to Source titles and notes each launch; at the end
* of the session this copies each console.log written during it into vac/, scrubbed, and writes
* vac.txt: the game's own secure/insecure lines, any VAC lines in the client's logs, and which
* client files are present.
*
* The SteamLite lesson applies: its "INSECURE - VAC may kick" warning was our own parser reading the
* wrong log while the client was secure all along. So vac.txt quotes the lines it found and says
* "inconclusive" when the game never reached a server, rather than guessing either way.
*/
object VacDiagnostics {
private const val TAG = "VacDiagnostics"
/** In the guest's home; the launch wrapper checks for it. Holds the session's start, epoch seconds. */
private const val MARKER = "root/.droiddeck-vac-diagnostics"
/** One line per game launch the wrapper saw: epoch, app id, -condebug added or not, the exe. */
private const val LAUNCHES = "root/.droiddeck-vac-launches"
/** The console log grows across launches; the end of it is the session that just ran. */
private const val CONSOLE_TAIL_BYTES = 4L * 1024 * 1024
private const val MAX_QUOTED = 40
private val INSECURE = Regex("insecure mode", RegexOption.IGNORE_CASE)
// "Connecting to" is only an attempt: L4D2 logs it, retries, and then refuses a VAC server with a
// dialog ("Connection Blocked: Please remove plug-ins before attempting to connect to a VAC
// secured server") that never reaches the console. Only "Connected to" means the game got in.
private val CONNECTED = Regex("^Connected to", RegexOption.IGNORE_CASE)
private val ATTEMPTED = Regex("^Connecting to", RegexOption.IGNORE_CASE)
private val NOTABLE = Regex("\\bVAC\\b|secure|Connected to|Connecting to|Disconnect|challenge|game coordinator", RegexOption.IGNORE_CASE)
private val STEAM_VAC = Regex("\\bVAC\\b|anti-?cheat", RegexOption.IGNORE_CASE)
fun enabled(context: Context): Boolean = SessionPrefs.vacDiagnostics(context)
/**
* At session start, after the scripts are staged: arms the wrapper for this session (or disarms
* it) and starts a fresh launch list, so the summary names only games this session ran.
*/
fun arm(context: Context) {
val root = LinuxRuntime.rootDir(context)
val marker = File(root, MARKER)
try {
File(root, LAUNCHES).delete()
if (enabled(context)) marker.writeText("${System.currentTimeMillis() / 1000}\n")
else marker.delete()
} catch (e: Exception) {
Log.w(TAG, "could not arm VAC diagnostics", e)
}
}
/** At the end of a session, into its folder. Does nothing unless the session was armed. */
fun collect(context: Context, dir: File) {
val root = LinuxRuntime.rootDir(context)
val marker = File(root, MARKER)
if (!marker.isFile) return
try {
val since = (marker.readText().trim().toLongOrNull() ?: 0L) * 1000 - 5_000
val out = File(dir, "vac").apply { mkdirs() }
val consoles = consoleLogs(context).filter { it.lastModified() >= since }
val report = StringBuilder()
report.appendLine("VAC diagnostics - ${now()}")
report.appendLine("Read from the game's own console (-condebug) and the Steam client's logs.")
report.appendLine()
report.appendLine("== Client")
clientFiles(root).forEach { report.appendLine(" $it") }
report.appendLine()
report.appendLine("== Game launches this session (from the launch wrapper)")
val launches = File(root, LAUNCHES).takeIf { it.isFile }?.readLines().orEmpty().filter { it.isNotBlank() }
if (launches.isEmpty()) report.appendLine(" none - no Windows game was launched through the client")
launches.forEach { report.appendLine(" " + LogRedactor.redact(it)) }
report.appendLine()
report.appendLine("== Game consoles")
if (consoles.isEmpty()) {
report.appendLine(" No console.log was written this session. Only Source-engine games write one,")
report.appendLine(" and only when started with -condebug (this setting adds it).")
}
consoles.forEach { log ->
val name = consoleName(log)
val lines = tail(log)
File(out, "$name-console.log").bufferedWriter().use { w ->
lines.forEach { w.write(LogRedactor.redact(it)); w.newLine() }
}
report.appendLine("-- $name (vac/$name-console.log)")
report.appendLine(" Verdict: " + verdict(lines))
lines.filter { NOTABLE.containsMatchIn(it) }.takeLast(MAX_QUOTED)
.forEach { report.appendLine(" | " + LogRedactor.redact(it.trim())) }
report.appendLine()
}
report.appendLine("== VAC in the Steam client's logs")
val steamHits = File(dir, "steam").listFiles { f -> f.isFile }?.sortedBy { it.name }.orEmpty()
.flatMap { f -> runCatching { f.readLines() }.getOrDefault(emptyList()).filter { STEAM_VAC.containsMatchIn(it) }.map { "${f.name}: ${it.trim()}" } }
if (steamHits.isEmpty()) report.appendLine(" none - the client logged no VAC activity this session")
steamHits.takeLast(MAX_QUOTED).forEach { report.appendLine(" $it") }
File(dir, "vac.txt").writeText(report.toString())
Log.i(TAG, "VAC diagnostics: ${consoles.size} console log(s), ${steamHits.size} client VAC line(s), into $dir")
} catch (e: Exception) {
Log.w(TAG, "collecting VAC diagnostics", e)
}
}
private fun verdict(lines: List<String>): String = when {
lines.any { INSECURE.containsMatchIn(it) } ->
"INSECURE - the game started in insecure mode and cannot join VAC-secured servers until it restarts."
lines.any { CONNECTED.containsMatchIn(it) } ->
"No insecure notice, and the game connected to a server. Confirm with the server's own info that it is VAC-secured."
lines.any { ATTEMPTED.containsMatchIn(it) } ->
"NOT CONNECTED - the game tried a server and never got in. If the screen said \"Connection Blocked: " +
"Please remove plug-ins ... VAC secured server\" (Left 4 Dead 2's wording), the game is insecure; " +
"that dialog never reaches the console."
else ->
"Inconclusive - the game never connected to a server. Join a VAC-secured server, then check again."
}
/** Every console.log in a Steam library: <game>/<mod>/console.log (Source) and <game>/game/<mod>/console.log (Source 2). */
private fun consoleLogs(context: Context): List<File> {
val found = LinkedHashMap<String, File>()
for (library in libraryRoots(context)) {
val games = File(library, "steamapps/common").listFiles { f -> f.isDirectory } ?: continue
for (game in games) {
val mods = (game.listFiles { f -> f.isDirectory }.orEmpty().toList() +
File(game, "game").listFiles { f -> f.isDirectory }.orEmpty().toList())
for (mod in mods) {
val log = File(mod, "console.log")
if (log.isFile) found[runCatching { log.canonicalPath }.getOrDefault(log.path)] = log
}
}
}
return found.values.toList()
}
private fun libraryRoots(context: Context): List<File> {
val root = LinuxRuntime.rootDir(context)
val steam = File(root, "root/.local/share/Steam")
val roots = mutableListOf(steam)
GameStorage.effective(context)?.let { roots.add(File(it.path)) }
runCatching {
Regex("\"path\"\\s+\"([^\"]+)\"").findAll(File(steam, "steamapps/libraryfolders.vdf").readText()).forEach {
roots.add(File(root, it.groupValues[1].removePrefix("/")))
}
}
return roots.filter { it.isDirectory }.distinctBy { runCatching { it.canonicalPath }.getOrDefault(it.path) }
}
/** "Team Fortress 2-tf": the game folder and the mod, safe as a file name. */
private fun consoleName(log: File): String {
val mod = log.parentFile
val game = mod?.parentFile?.let { if (it.name == "game") it.parentFile else it }
return "${game?.name ?: "game"}-${mod?.name ?: "mod"}".replace(Regex("[^A-Za-z0-9._ +-]+"), "_")
}
private fun tail(file: File): List<String> = try {
RandomAccessFile(file, "r").use { raf ->
val start = (raf.length() - CONSOLE_TAIL_BYTES).coerceAtLeast(0)
raf.seek(start)
val bytes = ByteArray((raf.length() - start).toInt())
raf.readFully(bytes)
String(bytes, Charsets.UTF_8).lines().let { if (start > 0) it.drop(1) else it }
}
} catch (e: Exception) {
emptyList()
}
/** Which Steam client builds are on disk - the arm64 one has no VAC module of its own to run. */
private fun clientFiles(root: File): List<String> {
val steam = File(root, "root/.local/share/Steam")
return listOf(
"linuxarm64/steamclient.so" to "arm64 Linux client",
"linux64/steamclient.so" to "x86-64 Linux client",
"ubuntu12_64/steamclient.so" to "x86-64 Linux client (ubuntu12_64)",
).map { (path, label) -> "$label: ${if (File(steam, path).isFile) "present" else "absent"} ($path)" }
}
private fun now(): String = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
}
@@ -91,6 +91,7 @@ class FrontEndState(
val frameGenLabel: String,
val romsDir: String?,
val logsEnabled: Boolean,
val vacDiagnostics: Boolean = false,
val steamGames: List<Library.SteamGame>,
val emulators: List<Library.Emulator>,
val running: String?,
@@ -152,6 +153,7 @@ class FrontEndActions(
val onFiles: () -> Unit,
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onVacDiagnostics: () -> Unit = {},
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
val onOffline: () -> Unit,
@@ -289,6 +289,7 @@ internal fun SetupPanel(
}
}
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
ToggleRow(host, "vacdiag", stringResource(R.string.setup_vac_diag), stringResource(R.string.setup_vac_diag_hint), s.vacDiagnostics) { a.onVacDiagnostics() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs)
ActionRow(stringResource(R.string.setup_saved_logs), stringResource(R.string.setup_saved_logs_hint, com.droiddeck.launcher.session.SessionPaths.KEEP_SESSIONS), stringResource(R.string.setup_clear_logs), a.onClearLogs)
ToggleRow(
@@ -72,6 +72,7 @@ class ModeSettings(
val stretch16x9: Boolean? = null,
/** Steam only: the client branch forced on the command line. */
val steamChannel: String? = null,
val steamClientArch: String? = null,
/** Steam only: enable the SteamOS client interface and its performance controls. */
val steamDeckMode: Boolean = false,
/** Steam, Deck mode: the QAM's performance overlay (mangoapp) is started. */
@@ -121,6 +122,7 @@ class ModeSettingsActions(
val onForceFullscreen: (Boolean) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamClientArch: (String) -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
@@ -325,6 +327,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
note = stringResource(R.string.mode_branch_note),
onPick = a.onSteamChannel,
)
if (s.steamClientArch != null) ChoiceRow(
host, "clientArch", stringResource(R.string.mode_client_arch), stringResource(R.string.mode_client_arch_hint),
listOf("arm64" to stringResource(R.string.mode_client_arch_arm64), "x86_64" to stringResource(R.string.mode_client_arch_x64)), s.steamClientArch,
onPick = a.onSteamClientArch,
)
}
if (steam && s.addedGamesDirs != null) SettingsGroup(stringResource(R.string.mode_added_games)) {
for (dir in s.addedGamesDirs) {
+6
View File
@@ -256,6 +256,10 @@
<string name="mode_branch_public">Beta pública</string>
<string name="mode_branch_deck_beta">Beta pública de Steam Deck</string>
<string name="mode_branch_note">El modo Deck siempre usa la beta pública de Steam Deck.</string>
<string name="mode_client_arch">Cliente de Steam</string>
<string name="mode_client_arch_hint">Se aplica en la próxima sesión. El cliente x86-64 se ejecuta con FEX, con su propio inicio de sesión y biblioteca, y dibuja su interfaz sin la GPU.</string>
<string name="mode_client_arch_arm64">ARM64 (recomendado)</string>
<string name="mode_client_arch_x64">x86-64 (FEX, experimental)</string>
<string name="mode_added_games">Juegos añadidos</string>
<string name="mode_added_none">no hay carpetas de juegos con un .exe</string>
<string name="mode_games_folder">Carpeta de juegos</string>
@@ -483,6 +487,8 @@
<string name="setup_session">Sesión</string>
<string name="setup_logs">Registros de sesión</string>
<string name="setup_logs_hint">Se guardan tras cada sesión</string>
<string name="setup_vac_diag">Diagnóstico de VAC</string>
<string name="setup_vac_diag_hint">Los juegos Source registran su consola (-condebug); vac.txt en los registros dice si es seguro o no</string>
<string name="setup_latest_logs">Últimos registros de sesión</string>
<string name="setup_saved_logs">Registros de sesión guardados</string>
<string name="setup_saved_logs_hint">Conserva los %1$d más recientes</string>
+6
View File
@@ -269,6 +269,10 @@
<string name="mode_branch_public">Public beta</string>
<string name="mode_branch_deck_beta">Steam Deck public beta</string>
<string name="mode_branch_note">Deck mode always uses Steam Deck public beta.</string>
<string name="mode_client_arch">Steam client</string>
<string name="mode_client_arch_hint">Applies next session. The x86-64 client runs under FEX with its own sign-in and library, and draws its interface without the GPU.</string>
<string name="mode_client_arch_arm64">ARM64 (recommended)</string>
<string name="mode_client_arch_x64">x86-64 (FEX, experimental)</string>
<string name="mode_added_games">Added games</string>
<string name="mode_added_none">no game folders with a .exe found</string>
<string name="mode_games_folder">Games folder</string>
@@ -508,6 +512,8 @@
<string name="setup_session">Session</string>
<string name="setup_logs">Session logs</string>
<string name="setup_logs_hint">Saved after each session</string>
<string name="setup_vac_diag">VAC diagnostics</string>
<string name="setup_vac_diag_hint">Source games log their console (-condebug); vac.txt in the session logs says secure or insecure</string>
<string name="setup_latest_logs">Latest session logs</string>
<string name="setup_saved_logs">Saved session logs</string>
<string name="setup_saved_logs_hint">Keeps the newest %1$d</string>
+69
View File
@@ -7,6 +7,75 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-02 - `feat/x86-64-steam-client`: Valve's x86-64 Linux client under FEX (experiment)
Why: the arm64 Linux client never runs VAC. L4D2 (and TF2 in Bannerlator) are refused by VAC
servers ("Connection Blocked ... VAC secured server") and no client log mentions VAC; VAC modules
are x86 code. The question is whether Valve's x86-64 client under FEX gets a VAC module it can run.
Branched from `feat/vac-diagnostics`. Opt-in: `bannerlator-steam-x64` (desktop menu "Steam x86-64
(FEX, experimental)") runs the client in its own home (`~/.droiddeck-x64`) with its own rootfs,
login and library. The arm64 client stays the default and is not touched.
Device-proven on the FIT (Genshin repack): **the x86-64 client runs, self-updates, renders its full
UI (software GL), logs in, shows friends, and downloads games.** VAC itself is not tested yet.
What it took, in order (each step was the next failure on device):
- **FEX for the guest** - `tools/fex/build-in-arch.sh` + `build-fex.yml` (FEX-2609, Arch Linux ARM
container, no thunks), released as `fex-2609-r2` (not Latest). Rootfs: FEX's Ubuntu 24.04 image.
- **openat2** - our proot answers it with ENOSYS; FEX opened every rootfs path with
`openat2(RESOLVE_IN_ROOT)`, fell through to the arm64 guest on anything but EXDEV, and x86 bash
could not find libtinfo. `tools/fex/patches/0001-openat2-enosys-fallback.patch`.
- **FEXServer owns the rootfs** - a server started before `FEX_ROOTFS` (any earlier FEX run) answers
every client with no rootfs. The launcher stops it first.
- **32-bit x86 died with SIGILL** - libblfastpath (the proot fast path, preloaded into every guest
process, FEX itself included) breaks FEX's 32-bit mode. `PROOT_FP_OFF=1` for FEX.
- **"Steam now requires user namespaces"** - steam-runtime-check-requirements' bwrap probe; the
arm64 client has no such probe. The SteamRT3 client (`steamrt64/steam`) is started directly, the
way the session starts `steamrtarm64/steam`, with `STEAM_RUNTIME_STEAMRT` pointing at Valve's
runtime linked file by file and only the probe replaced.
- **tier0 assertion "Function not implemented"** - semget: Android has no System V IPC. Traced with
a `-DDEBUG_STRACE` FEX (build-fex.yml `cxxflags` input, artifact only). The session shim
(`preload/*.c`) is now also built natively as `libblsession-x86_64.so`, staged, and preloaded
through the rootfs's own `/etc/ld.so.preload`.
- **re-exec into pressure-vessel after self-update** - `STEAM_STEAMRT_RECURSION_GUARD=1`.
- **libgtk-x11-2.0 missing / soname links / old glibc shadowing** - Valve's steamrt3c platform
libraries linked into `~/.droiddeck-x64/steamrt-libs`, soname links from the rootfs `ldconfig`,
and only libraries the rootfs lacks (LD_LIBRARY_PATH beats system folders whatever its order).
- **GLX failed** - the desktop exports zink over the arm64 Turnip ICD; cleared, llvmpipe used.
- **web helper died of SIGTRAP a second after start** - found with pid-tagged traces (FEX AppConfig
`OutputLog=server` + a foreground FEXServer, `BL_FEXSERVER_LOG`; file logs overwrite each other):
every web helper read `/proc/self/status` from libtier0_s.so, saw proot as a tracer, and tier0 broke
into the "debugger" with int3. `preload/tracer.c` exempts the web helper on purpose (Chromium traps
on a status fd that is not procfs); with `BL_WEBHELPER_HIDE_TRACER=1` only opens called from
libtier0_s.so get the copy.
- `SessionFiles` stages `bannerlator-steam-x64` and the x86-64 shim (they were missing from the list).
Dead ends worth remembering: headless VAC testing is impossible (current Steam starts the saved
login from its UI); `-cef-*` switches are not forwarded to the x86 web helper; the KMS
`CREATE_DUMB` errors are a red herring (40 fast ENOTTY per process); not an address-space problem
(0 ENOMEM in 618k traced syscalls, although the device is 39-bit VA).
Later the same day (device-proven unless noted):
- **Controllers**: libfakeinput is now also built for x86-64 and preloaded in the FEX rootfs; the
client registers the pad, and **Big Picture works with it** (Xbox prompts, D-pad moves focus).
- **Online**: the launcher starts the system bus and the NetworkManager stand-in like the session.
- From an audit of `bannerlator-session`: stale lock cleanup, branch sync before every start, core
re-pinning, path cache and `perms.c` for `steamrt64`, the app's FEX preset (PERFORMANCE_TSO).
- **Play button (built, not yet device-tested)**: Steam settings > Client > "Steam client: ARM64 /
x86-64 (FEX, experimental)" (`BL_STEAM_CLIENT_ARCH`). The session then starts the x86 client via
`bannerlator-steam-x64 --prepare/--exec` and gives it everything it gives the arm64 one; steps
only for the arm64 client (ARM64 Proton, mapping, seeding, Decky, steamclient.so probes) are
skipped.
- L4D2 hard-link reuse does not work: switching platform makes Steam redownload depot 551 anyway.
Open:
- VAC test: L4D2's native Linux build in the x86 client's own library (hard-linked from the arm64
copy, same filesystem, so only the Linux depots download). Native Linux games start inside the
Steam Linux Runtime container (pressure-vessel) - expected to need the same treatment.
- No x86 GPU driver: games render with llvmpipe. Playable needs FEX host thunks.
- Windows games in the x86 client need x86 Proton = pressure-vessel.
- One client per account: integration would be a per-title hand-off, not side by side.
## 2026-09-29 - `feat/controller-input`: the pad the way SteamOS has it
Device-tested on the AYN Thor (Katamari under Proton Experimental ARM64).
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/bash
# Runs INSIDE an Arch Linux ARM container (menci/archlinuxarm:base-devel) on an arm64 runner.
# Builds FEX for the runtime, with tools/fex/patches, (Arch Linux ARM ships no package and FEX publishes no Linux
# binaries), plus the runtime's own unsquashfs to unpack FEX's x86 rootfs image, and packs both
# as fex.tzst (usr/local/...) for bannerlator-steam-x64 to stage. FEX_THUNKS=1 adds the host
# thunks (see below).
set -euxo pipefail
VERSION=${FEX_VERSION:-FEX-2609}
WORK=/work
cd "$WORK"
# Same pacman workarounds as tools/gamescope/build-in-arch.sh.
grep -q '^DisableSandbox' /etc/pacman.conf || sed -i 's/^\[options\]/[options]\nDisableSandbox/' /etc/pacman.conf
{ for m in https://ca.us.mirror.archlinuxarm.org https://fl.us.mirror.archlinuxarm.org https://de3.mirror.archlinuxarm.org https://nl.mirror.archlinuxarm.org; do echo "Server = $m/\$arch/\$repo"; done; cat /etc/pacman.d/mirrorlist; } > /etc/pacman.d/mirrorlist.new
mv /etc/pacman.d/mirrorlist.new /etc/pacman.d/mirrorlist
pacman -Syu --noconfirm --needed git zstd binutils cmake ninja clang lld llvm python squashfs-tools file
# FEX_THUNKS=1: host thunks too - an x86 program's libvulkan/libGL/libX11/... calls go to the
# runtime's own arm64 libraries (Turnip, Mesa), so the x86-64 client draws on the GPU. The host
# half is arm64 and needs the graphics dev packages; the guest half is cross-compiled with clang
# against an x86 sysroot: Arch Linux x86_64 (and multilib i686) packages unpacked into a root of
# their own by pacman - never run, so no scriptlets and no signature checks.
THUNKS=${FEX_THUNKS:-0}
X86ROOT=$WORK/x86root
if [ "$THUNKS" = 1 ]; then
pacman -S --noconfirm --needed pkgconf libx11 libxcb libxrandr libxrender libxext xorgproto \
libglvnd mesa wayland libdrm alsa-lib libxshmfence vulkan-headers vulkan-icd-loader
rm -rf "$X86ROOT" && mkdir -p "$X86ROOT/var/lib/pacman" "$WORK/x86-cache"
cat > "$WORK/x86-pacman.conf" <<'CONF'
[options]
Architecture = x86_64
SigLevel = Never
DisableSandbox
[core]
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
[extra]
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
[multilib]
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch
CONF
pacman --config "$WORK/x86-pacman.conf" --root "$X86ROOT" --dbpath "$X86ROOT/var/lib/pacman" \
--cachedir "$WORK/x86-cache" --noscriptlet --noconfirm --overwrite '*' -Sy \
glibc linux-api-headers gcc gcc-libs libx11 libxcb xorgproto libxrandr libxrender libxext libxau \
libxdmcp libglvnd mesa wayland libdrm alsa-lib libxshmfence \
lib32-glibc lib32-gcc-libs lib32-libx11 lib32-libxcb lib32-libxrandr lib32-libxrender \
lib32-libxext lib32-libglvnd lib32-mesa lib32-wayland lib32-libdrm lib32-alsa-lib lib32-libxshmfence
ls "$X86ROOT/usr/include" | head -5
fi
rm -rf fex-src out && git clone -q --depth 1 --branch "$VERSION" --recurse-submodules --shallow-submodules https://github.com/FEX-Emu/FEX.git fex-src
# Our proot answers openat2 with ENOSYS (tools/proot/PATCHES.md, 0008); FEX opened every rootfs
# path with openat2 and fell through to the arm64 guest's files on anything but EXDEV.
for p in tools/fex/patches/*.patch; do patch -d fex-src -p1 --no-backup-if-mismatch < "$p"; done
# FEX's x86 toolchain files assume an x86 build machine: on this arm64 one the guest thunks need the
# x86 sysroot for their headers, C runtime objects and libgcc too, not just the generator.
if [ "$THUNKS" = 1 ]; then
for t in fex-src/Data/CMake/toolchain_x86_64.cmake fex-src/Data/CMake/toolchain_x86_32.cmake; do
printf '\nif (X86_DEV_ROOTFS AND NOT X86_DEV_ROOTFS STREQUAL "/")\n set(CMAKE_SYSROOT "${X86_DEV_ROOTFS}")\nendif()\n' >> "$t"
done
fi
# TUNE_CPU none: the default (native) would tune for the runner's Neoverse cores, not the device.
cmake -S fex-src -B fex-build -G Ninja \
-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local \
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DUSE_LINKER=lld \
-DTUNE_CPU=none -DTUNE_ARCH=armv8-a \
-DBUILD_TESTING=OFF -DBUILD_FEXCONFIG=OFF -DENABLE_ASSERTIONS=OFF \
$( [ "$THUNKS" = 1 ] && echo "-DBUILD_THUNKS=ON -DX86_DEV_ROOTFS=$X86ROOT" || echo "-DBUILD_THUNKS=OFF" ) \
-DENABLE_CCACHE=OFF -DENABLE_OFFLINE_TELEMETRY=OFF \
${FEX_EXTRA_CXXFLAGS:+-DCMAKE_CXX_FLAGS="$FEX_EXTRA_CXXFLAGS"}
ninja -C fex-build
DESTDIR="$WORK/out" ninja -C fex-build install
install -Dm755 /usr/bin/unsquashfs out/usr/local/bin/unsquashfs
# Nothing outside the runtime's own libraries: every needed soname must resolve in a stock guest.
for f in out/usr/local/bin/*; do
file "$f" | grep -q ELF || continue
readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }'
done | sort -u | tee fex-needed.txt
(cd out && find . -type f -o -type l | sort) | tee fex-files.txt
tar -C out -c . | zstd -19 -T0 -o fex.tzst
sha256sum fex.tzst | tee fex.tzst.sha256
@@ -0,0 +1,42 @@
diff --git a/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp b/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp
index 7172460..d566e3b 100644
--- a/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp
+++ b/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp
@@ -674,9 +674,10 @@ uint64_t FileManager::Open(const char* pathname, int flags, uint32_t mode) {
};
fd = ::syscall(SYSCALL_DEF(openat2), Path.FD, Path.Path, &how, sizeof(how));
- if (fd == -1 && errno == EXDEV) {
- // This means a magic symlink (/proc/foo) was involved. In this case we
- // just punt and do the access without RESOLVE_IN_ROOT.
+ if (fd == -1 && (errno == EXDEV || errno == ENOSYS)) {
+ // EXDEV: a magic symlink (/proc/foo) was involved. ENOSYS: no openat2 here (proot).
+ // Either way, do the access without RESOLVE_IN_ROOT; GetEmulatedFDPath has already
+ // followed absolute symlinks inside the rootfs.
fd = ::syscall(SYSCALL_DEF(openat), Path.FD, Path.Path, flags, mode);
}
}
@@ -927,9 +928,10 @@ uint64_t FileManager::Openat([[maybe_unused]] int dirfs, const char* pathname, i
.resolve = (Path.FD == AT_FDCWD) ? 0u : RESOLVE_IN_ROOT, // AT_FDCWD means it's a thunk and not via RootFS
};
fd = ::syscall(SYSCALL_DEF(openat2), Path.FD, Path.Path, &how, sizeof(how));
- if (fd == -1 && errno == EXDEV) {
- // This means a magic symlink (/proc/foo) was involved. In this case we
- // just punt and do the access without RESOLVE_IN_ROOT.
+ if (fd == -1 && (errno == EXDEV || errno == ENOSYS)) {
+ // EXDEV: a magic symlink (/proc/foo) was involved. ENOSYS: no openat2 here (proot).
+ // Either way, do the access without RESOLVE_IN_ROOT; GetEmulatedFDPath has already
+ // followed absolute symlinks inside the rootfs.
fd = ::syscall(SYSCALL_DEF(openat), Path.FD, Path.Path, flags, mode);
}
}
@@ -967,6 +969,9 @@ uint64_t FileManager::Openat2(int dirfs, const char* pathname, FEX::HLE::open_ho
// This means a magic symlink (/proc/foo) was involved. In this case we
// just punt and do the access without RESOLVE_IN_ROOT.
fd = ::syscall(SYSCALL_DEF(openat2), Path.FD, Path.Path, how, usize);
+ } else if (fd == -1 && errno == ENOSYS && how->resolve == 0) {
+ // No openat2 here (proot): the plain openat inside the rootfs, as above.
+ fd = ::syscall(SYSCALL_DEF(openat), Path.FD, Path.Path, (int)how->flags, (int)how->mode);
}
}
+11
View File
@@ -119,6 +119,17 @@ export BL_DESKTOP=1
ln -sf /usr/local/bin/bannerlator-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null
ln -sf /usr/local/bin/bannerlator-steam-shim /usr/local/bin/steam 2>/dev/null
python3 /usr/local/bin/bannerlator-desktop-games 2>&1 | sed 's/^/[games] /'
# EXPERIMENT: Valve's x86-64 client under FEX (bannerlator-steam-x64), in a terminal so its
# first-run downloads show. Not a Game entry, so droiddeck-desktop-gpu leaves its Exec alone.
cat > /usr/share/applications/droiddeck-steam-x64.desktop <<'ENTRY'
[Desktop Entry]
Type=Application
Name=Steam x86-64 (FEX, experimental)
Comment=Valve's x86-64 Linux client under FEX, in its own home with its own login
Exec=qterminal -e /usr/local/bin/bannerlator-steam-x64
Icon=steam
Categories=System;
ENTRY
# Player 1 on the controller in every emulator that has no profile of its own yet - now, and again
# every half minute: an emulator started for the first time writes its own config, which is then
# given the controller for its next start without waiting for the next desktop.
@@ -246,10 +246,29 @@ case $mode in
esac
;;
steam)
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
bannerlator-steam-install || exit $?
step "Steam client ready"
steam_root=$XDG_DATA_HOME/Steam
# BL_STEAM_CLIENT_ARCH=x86_64 (the Steam page's Client setting): Valve's x86-64 client under FEX
# instead of the arm64 one, in a home of its own (bannerlator-steam-x64), for what the arm64
# client cannot do - VAC. Everything this session does around the client applies to it; what is
# only for the arm64 client (its ARM64 Proton, the library mapping, the probes that load its
# steamclient.so) is skipped.
steam_x64=0
[ "${BL_STEAM_CLIENT_ARCH:-}" = x86_64 ] && steam_x64=1
if [ "$steam_x64" = 1 ]; then
step "checking the x86-64 Steam client (FEX, experimental; the first run downloads ~2 GB)"
bannerlator-steam-x64 --prepare || exit $?
step "x86-64 Steam client ready"
steam_root=${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam
steam_client=(bannerlator-steam-x64 --exec)
# No x86 GPU driver: the interface renders in software.
steam_cef=(-cef-disable-gpu)
else
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
bannerlator-steam-install || exit $?
step "Steam client ready"
steam_root=$XDG_DATA_HOME/Steam
steam_client=("$steam_root/steamrtarm64/steam")
steam_cef=(-cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan)
fi
# Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is
# its main library already - and the ARM64 Proton that lets it launch the Windows ones.
# A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts,
@@ -257,7 +276,7 @@ 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 [ -s "$HOME/.bl-proton-extra" ]; then
if [ "$steam_x64" = 0 ] && [ -s "$HOME/.bl-proton-extra" ]; then
_bl_left=$HOME/.bl-proton-extra.left
: > "$_bl_left"
while read -r _bl_extra || [ -n "$_bl_extra" ]; do
@@ -301,7 +320,7 @@ case $mode in
for ((_i = 0; _i < 720; _i++)); do
for _d in /proc/[0-9]*; do
IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue
case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac
case $_c in *steamwebhelper*|*steamrtarm64/steam|*steamrt64/steam|*gamescope*) ;; *) continue ;; esac
_t=("$_d"/task/*)
_p=${_d#/proc/}
[ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && continue
@@ -313,6 +332,7 @@ case $mode in
) &
fi
if [ "$steam_x64" = 0 ]; then
step "registering the app's game library with Steam"
bannerlator-steam-library "$steam_root" || true
step "setting up the ARM64 Proton compatibility tool"
@@ -326,9 +346,10 @@ 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
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.
if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
step "asking the client to fetch the ARM64 Proton"
set -- "$@" steam://install/4427310
# Ported from Bannerlator (bannerlator-session, device-proven 2026-09-22). The client decides
@@ -348,7 +369,7 @@ case $mode in
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
step "compatibility layer installed: restarting the Steam client once"
: > "$HOME/.bl-restart-once"
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
"${steam_client[@]}" -shutdown >/dev/null 2>&1 || true
exit 0
fi
done
@@ -383,7 +404,7 @@ case $mode in
sleep 10
[ -f "$_bl_acf" ] && exit 0
step "signed in: asking the client again for the ARM64 Proton (try $_bl_try of 3)"
"$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true
"${steam_client[@]}" steam://install/4427310 >/dev/null 2>&1 || true
sleep 50
done
exit 0
@@ -485,7 +506,7 @@ case $mode in
# 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.
(
[ "$steam_x64" = 0 ] && (
beat=0
compat_stamp=$HOME/.bl-compat-beat
rm -f "$compat_stamp"
@@ -498,6 +519,31 @@ case $mode in
done
return 1
}
# A title the compat pass maps while the client runs only takes that mapping at the client's
# next start (see bannerlator-steam-compat): until then the client launches it its own way -
# a Linux build stuck on "Downloading content (0%)" for a Steam Linux Runtime and FEX that
# cannot run here (Left 4 Dead 2, 2026-10-02). So restart the client once for it, through the
# same marker and -shutdown as the ARM64 Proton's restart: only when every new title has
# finished installing (StateFlags 4) and no game is running; another download just resumes.
# Once per title and three times per session at most, so a client that rewrites config.vdf
# can never loop it.
compat_pending=$HOME/.bl-compat-pending
compat_restarted=$HOME/.bl-compat-restarted
rm -f "$compat_pending"
: > "$compat_restarted"
compat_restarts=0
compat_settled() {
local app m flags
while read -r app; do
[ -n "$app" ] || continue
m="$steam_root/steamapps/appmanifest_$app.acf"
[ -f "$m" ] || m="/mnt/droiddeck-sd/steamapps/appmanifest_$app.acf"
[ -f "$m" ] || return 1
flags=$(sed -n 's/^[[:space:]]*"StateFlags"[[:space:]]*"\([0-9]*\)".*/\1/p' "$m" | head -n 1)
[ "$flags" = 4 ] || return 1
done < "$compat_pending"
! pgrep -f 'reaper SteamLaunch' >/dev/null 2>&1
}
for ((_i = 0; _i < 720; _i++)); do
nap 5
beat=$((beat + 1))
@@ -505,7 +551,17 @@ case $mode in
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
bannerlator-steam-compat "$steam_root" --live >/dev/null 2>&1 || true
fi
if [ -s "$compat_pending" ] && compat_settled; then
_bl_new=$(sort -u "$compat_pending" | grep -vxF -f "$compat_restarted" || true)
rm -f "$compat_pending"
[ -n "$_bl_new" ] && [ "$compat_restarts" -lt 3 ] || continue
compat_restarts=$((compat_restarts + 1))
printf '%s\n' $_bl_new >> "$compat_restarted"
step "newly installed title(s) $(echo $_bl_new) set to the ARM64 Proton: restarting the Steam client once so it takes effect"
: > "$HOME/.bl-restart-once"
"${steam_client[@]}" -shutdown >/dev/null 2>&1 || true
fi
done
) &
@@ -578,7 +634,7 @@ 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 [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
if [ "$steam_x64" = 0 ] && [ -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
(
@@ -623,7 +679,10 @@ BUSCONF
# is an update that never installs: exit 42, a restart, for ever (Bannerlator 38996ea1). The
# loop below puts package/beta back before every start. The SteamOS helpers the client calls
# in this mode are staged as no-op stubs under /usr/bin.
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ]; then
# 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_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
@@ -633,7 +692,7 @@ BUSCONF
# low-memory killer), and the client then finds .crash - opening its update UI on the spinner -
# and the web helper's Singleton* locks in htmlcache, owned by a process that is gone, which can
# leave the Gamepad UI blank. No client or helper is running at this point in a session.
if ! pgrep -f 'steamwebhelper|steamrtarm64/steam' >/dev/null 2>&1; then
if ! pgrep -f 'steamwebhelper|steamrtarm64/steam|steamrt64/steam' >/dev/null 2>&1; then
_stale=""
for _f in "$steam_root/.crash" "$steam_root/steam.pid" \
"$steam_root/config/htmlcache/SingletonLock" "$steam_root/config/htmlcache/SingletonCookie" \
@@ -652,14 +711,14 @@ BUSCONF
# again on the branch it just wrote.
steam_channel=${BL_STEAM_CHANNEL:-publicbeta}
steam_beta=$steam_root/package/beta
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
if [ "$steam_x64" = 0 ] && [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
games_watcher=$!
fi
# Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in
# client once a session until the answer is yes. From then on the app offers importing
# Lossless.dll and passes BL_LOSSLESS_OWNED=1, so the question is never asked again.
if [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then
if [ "$steam_x64" = 0 ] && [ "${BL_LOSSLESS_OWNED:-0}" != 1 ]; then
(
owned=$HOME/.config/droiddeck/lossless-owned
for ((_i = 0; _i < 60; _i++)); do
@@ -674,7 +733,7 @@ BUSCONF
done
) &
fi
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox ${steam_cef[*]} $*$([ "$steam_x64" = 1 ] && echo ' (x86-64 client under FEX)')"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
@@ -687,7 +746,7 @@ BUSCONF
rm -f "$BL_LAUNCH_DIR/steam-stop"
: > "$HOME/.bl-stop-requested"
echo "== steam: the app asked for a clean exit"
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
"${steam_client[@]}" -shutdown >/dev/null 2>&1 || true
exit 0
fi
nap 0.5
@@ -702,13 +761,12 @@ BUSCONF
echo "$steam_channel" > "$steam_beta"
fi
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
"${steam_client[@]}" "${_bl_ui[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox "${steam_cef[@]}" "$@"
rc=$?
echo "== steam exited rc=$rc"
collect_steam_logs
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
if [ "$steam_x64" = 0 ] && [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
if [ -f "$HOME/.bl-stop-requested" ]; then
@@ -719,6 +777,9 @@ BUSCONF
# arguments, which were acted on. Not counted against the update cap.
if [ -f "$HOME/.bl-restart-once" ]; then
echo "== steam: restarting once so the new compatibility layer is in the client's lists"
# The client wrote config.vdf back from its own memory as it exited, which can drop the
# per-title entries written while it ran - the very ones this restart is for.
bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true
set --
continue
fi
@@ -189,6 +189,37 @@ bl_idtech3() {
}
"""
# VAC diagnostics (the app's Setup page): a Source-engine game says whether it started VAC-secure
# only in its own console, and writes that console to <mod>/console.log only with -condebug. Armed
# per session by the app (~/.droiddeck-vac-diagnostics); each launch is noted for its summary.
# Nothing else about the launch changes - this only asks the game to keep its own log.
BL_VACDIAG_SETUP = """
bl_vacdiag() {
[ -f "${HOME:-/root}/.droiddeck-vac-diagnostics" ] || return 0
[ "${verb:-}" = waitforexitandrun ] || return 0
local exe= a dir added=no app=${SteamAppId:-${STEAM_COMPAT_APP_ID:-0}}
for a in "$@"; do case $a in *.exe|*.EXE) exe=$a ;; esac; done
[ -n "$exe" ] || return 0
dir=$(dirname "$exe")
# Source: <game>/<mod>/gameinfo.txt beside the exe. Source 2: <game>/game/bin/win64/<exe> with
# <game>/game/<mod>/gameinfo.gi.
if ls "$dir"/*/gameinfo.txt >/dev/null 2>&1 || ls "$dir"/../../*/gameinfo.gi >/dev/null 2>&1; then
case " $* " in
*" -condebug "*) echo "bannerlator-proton: vac-diag: app $app already has -condebug" >&2 ;;
*) BL_EXTRA_ARGS+=(-condebug); added=yes
echo "bannerlator-proton: vac-diag: app $app is a Source title; launched with -condebug" >&2 ;;
esac
else
added=not-source
echo "bannerlator-proton: vac-diag: app $app is not a Source title; it keeps no console log to read" >&2
fi
case " $* " in
*" -insecure "*) echo "bannerlator-proton: vac-diag: app $app has -insecure in its launch options - it can never be VAC-secure this way" >&2 ;;
esac
printf '%s\tapp %s\tcondebug %s\t%s\n' "$(date +%s)" "$app" "$added" "${exe##*/}" >> "${HOME:-/root}/.droiddeck-vac-launches"
}
"""
BL_DIRECTAUDIO_SETUP = """
bl_directaudio() {
base=$1
@@ -271,6 +302,7 @@ if [ -n "$depot" ]; then
bl_directaudio "$depot"
bl_game_cpus
bl_idtech3 "$@"
bl_vacdiag "$@"
bl_libpath
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
fi
@@ -282,7 +314,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_VACDIAG_SETUP + BL_COMPONENTS_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
@@ -318,6 +350,7 @@ bl_components "$here"
bl_directaudio "$here"
bl_game_cpus
bl_idtech3 "$@"
bl_vacdiag "$@"
bl_libpath
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
# started from its own tree.
@@ -503,7 +536,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_VACDIAG_SETUP + BL_COMPONENTS_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))
@@ -546,7 +579,8 @@ def installed_apps(steam_root):
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."""
"""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}
@@ -555,7 +589,8 @@ def register_default(config_path, apps=(), name=None, protect=()):
tokens = tokenize(original)
steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"])
if steam is None:
return
return []
added = []
mapping = find_block(tokens[steam + 1:], ["CompatToolMapping"])
entry = ['"0"', "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"75"', "}"]
if mapping is None:
@@ -579,10 +614,12 @@ def register_default(config_path, apps=(), name=None, protect=()):
continue
tokens[mapping + 1:mapping + 1] = [
'"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"250"', "}"]
added.append(app)
# Nothing to change is the usual case on the session's beat: the client's own file is left alone.
text = serialize(tokens)
if text != serialize(tokenize(original)):
write_changed(config_path, text)
return added
def depot_build(depot):
@@ -633,8 +670,18 @@ def main():
config = os.path.join(steam_root, "config", "config.vdf")
if os.path.isfile(config):
apps = installed_apps(steam_root)
register_default(config, apps, protect=extras)
added = register_default(config, apps, protect=extras)
print("bannerlator-steam-compat: default and %d installed title(s) set to %s" % (len(apps), TOOL))
# The client reads config.vdf once, as it starts, and keeps its own decisions in memory for
# the rest of its run: a title installed while it runs gets the client's own pick (native
# for a Linux build, which then waits for Steam Linux Runtime + FEX it can never run here;
# proton-stable for a Windows one), and the entry written here only counts from the next
# start. Run from the session's beat (--live), the new entries are noted for the session,
# which restarts the client once the install has settled (bannerlator-session).
if added and "--live" in sys.argv[2:]:
with open(os.path.join(os.path.expanduser("~"), ".bl-compat-pending"), "a") as f:
f.write("".join("%s\n" % a for a in added))
print("bannerlator-steam-compat: mapped while the client runs: %s" % " ".join(added))
if __name__ == "__main__":
+406
View File
@@ -0,0 +1,406 @@
#!/bin/bash
# EXPERIMENT, opt-in: Valve's x86-64 Linux Steam client under FEX, beside the default arm64 one.
# bannerlator-steam-x64 [steam:// URL or client arguments] run it (the desktop menu entry)
# bannerlator-steam-x64 --prepare install and set up, then exit
# bannerlator-steam-x64 --exec ARGS... run the client binary with ARGS in
# its environment (bannerlator-session
# with BL_STEAM_CLIENT_ARCH=x86_64)
#
# Why: the arm64 Linux client never runs VAC (TF2, L4D2: "Connection Blocked ... VAC secured
# server", no VAC line in any client log) - VAC modules are x86 code. The x86-64 client under FEX
# has a module it can run, the way SteamLite does in Wine. This runs Valve's client unchanged.
#
# Everything lives in its own home ($X64_HOME): the x86 client gets its own ~/.steam links, its
# own login, config and library, and never touches the arm64 client's. Only one client runs at a
# time (same account), so the arm64 one is shut down first.
#
# First run downloads FEX (this repo's release), FEX's Ubuntu 24.04 x86 rootfs (~525 MB, unpacked
# ~1.5 GB) and the x86 client (~600 MB). Log: $X64_HOME/steam-x64.log.
set -eu
FEX_TAG=fex-2609-r2
FEX_SHA256=5c5fb5bfc2a78879fc4488d2b4cdea0d40d9268d9551bdc37c123e36da21b9c7
FEX_URL=https://github.com/Droid-Deck/DroidDeck/releases/download/$FEX_TAG/fex.tzst
ROOTFS_URL=https://rootfs.fex-emu.gg/Ubuntu_24_04/2026-08-11/Ubuntu_24_04.sqsh
CDN=https://client-update.fastly.steamstatic.com
MANIFEST=steam_client_publicbeta_ubuntu12
REAL_HOME=${HOME:-/root}
X64_HOME=${BL_X64_HOME:-$REAL_HOME/.droiddeck-x64}
ROOTFS=$X64_HOME/rootfs
STEAM_X64=$X64_HOME/.local/share/Steam
ARM_STEAM=${XDG_DATA_HOME:-$REAL_HOME/.local/share}/Steam
LOG=$X64_HOME/steam-x64.log
mkdir -p "$X64_HOME"
step() { echo "== $(date -u +%H:%M:%S) $*" | tee -a "$LOG"; }
x64_prepare() {
# --- FEX -------------------------------------------------------------------------------------
if [ ! -x /usr/local/bin/FEX ] || [ "$(cat /usr/local/share/fex-emu/.droiddeck-tag 2>/dev/null)" != "$FEX_TAG" ]; then
step "downloading FEX ($FEX_TAG)"
tmp=$(mktemp -d)
curl -fL --progress-bar "$FEX_URL" -o "$tmp/fex.tzst"
if [ "$FEX_SHA256" != PENDING ]; then echo "$FEX_SHA256 $tmp/fex.tzst" | sha256sum -c --quiet; fi
zstd -dc "$tmp/fex.tzst" | tar -C / -x
rm -rf "$tmp"
mkdir -p /usr/local/share/fex-emu && echo "$FEX_TAG" > /usr/local/share/fex-emu/.droiddeck-tag
fi
step "FEX ready ($FEX_TAG)"
# --- x86 rootfs --------------------------------------------------------------------------------
if [ ! -f "$ROOTFS/.droiddeck-complete" ]; then
step "downloading FEX's Ubuntu 24.04 x86 rootfs"
# Resumes a part-done download; a finished one answers 416, which is not a failure here.
curl -fL --progress-bar -C - "$ROOTFS_URL" -o "$X64_HOME/rootfs.sqsh" || {
rc=$?
[ "$rc" = 22 ] && [ -s "$X64_HOME/rootfs.sqsh" ] || { step "rootfs download failed (curl $rc)"; exit 1; }
}
step "unpacking the rootfs"
rm -rf "$ROOTFS"
# Ownership and xattrs mean nothing in this guest; device nodes cannot be made here and are not
# needed (FEX passes /dev through from the host).
/usr/local/bin/unsquashfs -no-xattrs -no-progress -ignore-errors -d "$ROOTFS" "$X64_HOME/rootfs.sqsh" >>"$LOG" 2>&1 || true
[ -x "$ROOTFS/usr/bin/bash" ] || { step "rootfs unpack failed (no usr/bin/bash); see $LOG"; exit 1; }
rm -f "$X64_HOME/rootfs.sqsh"
touch "$ROOTFS/.droiddeck-complete"
fi
# The rootfs's own ld.so.preload: a path missing from the rootfs falls through to the guest, whose
# list names the session's arm64 preloads. In it, the session shim built for x86-64 (System V IPC,
# which Android kernels lack: the client's tier0 gives up on start without its semaphore).
# Each architecture's copy under the rootfs's own multiarch folder, named once through glibc's $LIB
# (lib/x86_64-linux-gnu or lib/i386-linux-gnu): every process loads the one built for it, so the
# 32-bit games get them too. A missing copy is skipped by the loader.
: > "$ROOTFS/etc/ld.so.preload"
for lib in libblsession libfakeinput; do
have=0
for arch in x86_64 i386; do
[ -f "/usr/local/lib/$arch-linux-gnu/$lib.so" ] || continue
mkdir -p "$ROOTFS/usr/local/lib/$arch-linux-gnu"
cp -f "/usr/local/lib/$arch-linux-gnu/$lib.so" "$ROOTFS/usr/local/lib/$arch-linux-gnu/$lib.so"
have=1
done
if [ "$have" = 1 ]; then echo "/usr/local/\$LIB/$lib.so" >> "$ROOTFS/etc/ld.so.preload"
else step "warning: no x86 build of $lib (/usr/local/lib/x86_64-linux-gnu/$lib.so)"; fi
done
rm -f "$ROOTFS/usr/local/lib/libblsession-x86_64.so" "$ROOTFS/usr/local/lib/libfakeinput-x86_64.so"
# --- the x86-64 client -------------------------------------------------------------------------
if [ ! -x "$STEAM_X64/steamrt64/steam" ] || [ ! -f "$STEAM_X64/package/droiddeck-installed" ]; then
step "downloading the x86-64 Steam client ($MANIFEST)"
work=$(mktemp -d)
curl -fsSL "$CDN/$MANIFEST" -o "$work/manifest"
version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
# Same parsing as bannerlator-steam-install: every component block's plain zip and sha256.
awk -F'"' '
/^\t"/ && NF >= 3 && $4 == "" { block = $2 }
$2 == "file" { file[block] = $4 }
$2 == "sha2" { sha[block] = $4 }
END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] }
' "$work/manifest" | sort > "$work/parts"
[ -s "$work/parts" ] || { step "no client components in $MANIFEST"; exit 1; }
mkdir -p "$STEAM_X64/package"
total=$(grep -c . "$work/parts")
i=0
while read -r block file sum; do
i=$((i + 1))
step "client: $block ($i/$total)"
curl -fsSL "$CDN/$file" -o "$work/$file"
echo "$sum $work/$file" | sha256sum -c --quiet
unzip -qo "$work/$file" -d "$STEAM_X64"
rm -f "$work/$file"
done < "$work/parts"
rm -rf "$work"
echo publicbeta > "$STEAM_X64/package/beta"
echo "$version" > "$STEAM_X64/package/droiddeck-installed"
chmod +x "$STEAM_X64/steam.sh" "$STEAM_X64/steamrt64/steam" "$STEAM_X64/ubuntu12_32/steam" 2>/dev/null || true
fi
# Its own library: the arm64 client's Windows installs are not what this client runs (it would
# switch a shared install to the Linux build and the arm64 client would download it again).
[ -L "$STEAM_X64/steamapps" ] && rm -f "$STEAM_X64/steamapps"
mkdir -p "$STEAM_X64/steamapps"
# They still enumerate /dev/dri, find the session's KGSL node there, and spin on it (x86 Turnip
# "failed to query kernel driver version", KMS dumb-buffer ioctls in a loop) - the webhelper never
# got a browser up. FEX looks in the rootfs first: an empty /dev/dri there hides the node.
mkdir -p "$ROOTFS/dev/dri"
# What steam.sh sets up before it runs the client, for the x86 tree.
mkdir -p "$X64_HOME/.steam"
ln -sfn "$STEAM_X64" "$X64_HOME/.steam/root"
ln -sfn "$STEAM_X64" "$X64_HOME/.steam/steam"
ln -sfn "$STEAM_X64/ubuntu12_32" "$X64_HOME/.steam/bin32"
ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64"
ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32"
ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64"
# Unlike the arm64 client, this one carries pressure-vessel (steamrt64/pv-runtime) and refuses to
# start when steam-runtime-check-requirements' bwrap probe cannot make a user namespace, which no
# Android app can. proot is the container here, as it is for the arm64 client, which has no such
# probe. STEAM_RUNTIME_STEAMRT names the runtime the client takes the probe from: Valve's own,
# linked file by file, with only the probe answering that the requirements are met.
srt_real=$STEAM_X64/steamrt64/pv-runtime/steam-runtime-steamrt
srt_shim=$X64_HOME/steamrt-shim
if [ -d "$srt_real" ]; then
rm -rf "$srt_shim"
mkdir -p "$srt_shim/bin"
for e in "$srt_real"/* "$srt_real"/.[!.]*; do
[ -e "$e" ] || continue
[ "$(basename "$e")" = bin ] || ln -s "$e" "$srt_shim/$(basename "$e")"
done
for e in "$srt_real"/bin/*; do ln -s "$e" "$srt_shim/bin/$(basename "$e")"; done
rm -f "$srt_shim/bin/steam-runtime-check-requirements"
printf '#!/bin/sh
# bannerlator-steam-x64: proot stands in for user namespaces here.
exit 0
' > "$srt_shim/bin/steam-runtime-check-requirements"
chmod 755 "$srt_shim/bin/steam-runtime-check-requirements"
fi
# The libraries the SteamRT container would give the client and the rootfs lacks (GTK 2 for
# steamui.so, ...): Valve's own platform runtime files, for what the rootfs does not have.
# They ship without their soname links (pressure-vessel makes those when it builds the container),
# so they are linked into a folder of our own and the rootfs's ldconfig adds the soname links there.
srt_libs=$X64_HOME/steamrt-libs
rm -rf "$srt_libs"
mkdir -p "$srt_libs"
for d in "$STEAM_X64"/steamrt64/pv-runtime/steam-runtime-steamrt/steamrt3c_platform_*/files/lib/x86_64-linux-gnu \
"$STEAM_X64"/steamrt64/pv-runtime/steam-runtime-steamrt/steamrt3c_platform_*/files/usr/lib/x86_64-linux-gnu; do
[ -d "$d" ] || continue
for f in "$d"/*.so*; do [ -e "$srt_libs/$(basename "$f")" ] || ln -s "$f" "$srt_libs/"; done
done
FEX_ROOTFS=$ROOTFS /usr/local/bin/FEX "$ROOTFS/usr/sbin/ldconfig.real" -n "$srt_libs" >>"$LOG" 2>&1 || step "ldconfig on $srt_libs failed"
# LD_LIBRARY_PATH beats the system folders whatever its order, so only what the rootfs lacks stays:
# the platform's own (older) glibc and the rest must not shadow the rootfs's.
for f in "$srt_libs"/*; do
n=$(basename "$f")
if [ -e "$ROOTFS/usr/lib/x86_64-linux-gnu/$n" ] || [ -e "$ROOTFS/lib/x86_64-linux-gnu/$n" ]; then rm -f "$f"; fi
done
# Native Linux games: Steam runs them in the Steam Linux Runtime container (pressure-vessel), which
# needs bwrap - no user namespaces here, as for the client. "DroidDeck direct" runs them with
# Valve's scout library runtime instead, directly under FEX; the titles below are mapped to it.
tool=$STEAM_X64/compatibilitytools.d/droiddeck-direct
mkdir -p "$tool"
cat > "$tool/compatibilitytool.vdf" <<'VDF'
"compatibilitytools"
{
"compat_tools"
{
"droiddeck-direct"
{
"install_path" "."
"display_name" "DroidDeck direct (no container)"
"from_oslist" "linux"
"to_oslist" "linux"
}
}
}
VDF
cat > "$tool/toolmanifest.vdf" <<'VDF'
"manifest"
{
"version" "2"
"commandline" "/run %verb%"
}
VDF
cat > "$tool/run" <<'RUN'
#!/bin/sh
# DroidDeck direct: a native Linux game with Valve's scout library runtime, no container.
verb=$1; shift
rt=$HOME/.local/share/Steam/ubuntu12_32/steam-runtime.old
[ -x "$rt/run.sh" ] || rt=$HOME/.local/share/Steam/ubuntu12_32/steam-runtime
case $verb in
run|waitforexitandrun) exec "$rt/run.sh" "$@" ;;
*) exec "$@" ;;
esac
RUN
chmod 755 "$tool/run"
# The mapping lives in config.vdf, which the client rewrites from memory as it exits: written
# here, while no x86 client runs.
if [ -f "$STEAM_X64/config/config.vdf" ] && ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
python3 - "$STEAM_X64/config/config.vdf" ${BL_X64_DIRECT_APPS:-550} <<'PY' || step "could not map titles to DroidDeck direct"
import re, sys
path, apps = sys.argv[1], sys.argv[2:]
s = open(path, encoding="utf-8", errors="surrogateescape").read()
def block(text, key):
"""(start of the key, index after its opening brace, index of its closing brace) or None."""
m = re.search(r'"%s"\s*\{' % re.escape(key), text)
if not m:
return None
depth, k = 1, m.end()
while depth:
c = text[k]
depth += c == "{"
depth -= c == "}"
k += 1
return m.start(), m.end(), k - 1
if not block(s, "CompatToolMapping"):
st = block(s, "Steam")
if not st:
sys.exit("no Steam section")
s = s[:st[1]] + '\n\t\t\t\t"CompatToolMapping"\n\t\t\t\t{\n\t\t\t\t}' + s[st[1]:]
for app in apps:
_, open_, close = block(s, "CompatToolMapping")
inner = s[open_:close]
old = block(inner, app)
if old:
inner = inner[:old[0]] + inner[old[2] + 1:]
entry = ('\n\t\t\t\t\t"%s"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"droiddeck-direct"\n'
'\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"250"\n\t\t\t\t\t}' % app)
s = s[:open_] + entry + inner + s[close:]
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
# started earlier without FEX_ROOTFS (any FEX run in this guest) answers with no rootfs at all:
# every x86 library lookup then falls through to the arm64 guest. Stop it while no x86 client
# runs, so the next FEX starts one with this rootfs.
if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
FEX_ROOTFS=$ROOTFS /usr/local/bin/FEXServer -k >/dev/null 2>&1 || true
fi
}
# The environment the client and everything it starts run in.
x64_env() {
# The arm64 session's library paths and preloads are aarch64 and mean nothing to x86 processes.
unset LD_LIBRARY_PATH LD_PRELOAD STEAM_RUNTIME STEAM_RUNTIME_PREFER_HOST_LIBRARIES
# So are the desktop's GPU settings: zink over the arm64 Turnip ICD. x86 Mesa has no driver for
# this GPU, so software: llvmpipe for GL (the client's own window needs GLX), and the rootfs's
# own Vulkan ICDs (lavapipe) found by the x86 loader instead of the arm64 one.
unset GALLIUM_DRIVER MESA_LOADER_DRIVER_OVERRIDE VK_ICD_FILENAMES VK_DRIVER_FILES LIBGL_KOPPER_DRI2 \
ZINK_DEBUG ZINK_DESCRIPTORS TU_DEBUG MESA_NO_ERROR
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
# The X server hands out that node too (DRI3 open), and Mesa's software path then tries KMS dumb
# buffers on it in a loop: present through plain X instead.
export LIBGL_DRI3_DISABLE=1
# The proot fast path (libblfastpath, preloaded into every guest process, FEX included) stands down
# by itself when FEX is about to run a 32-bit program (it broke FEX's 32-bit mode: SIGILL at start);
# 64-bit ones keep it. BL_X64_FP_OFF=1 turns it off for everything, as before.
[ "${BL_X64_FP_OFF:-0}" = 1 ] && export PROOT_FP_OFF=1
# FEX tuning: the app's default preset for games (core/FexPreset.kt PERFORMANCE_TSO, WinNative's
# default) - FEX's own defaults keep half-barrier TSO and full-precision x87, which cost every x86
# program time. A value already in the environment wins.
export FEX_TSOENABLED=${FEX_TSOENABLED:-1} FEX_VECTORTSOENABLED=${FEX_VECTORTSOENABLED:-0} \
FEX_MEMCPYSETTSOENABLED=${FEX_MEMCPYSETTSOENABLED:-0} FEX_HALFBARRIERTSOENABLED=${FEX_HALFBARRIERTSOENABLED:-0} \
FEX_X87REDUCEDPRECISION=${FEX_X87REDUCEDPRECISION:-1} FEX_MULTIBLOCK=${FEX_MULTIBLOCK:-1}
export HOME=$X64_HOME
# The session points these at the arm64 client's home; this client has its own.
export XDG_DATA_HOME=$X64_HOME/.local/share XDG_CONFIG_HOME=$X64_HOME/.config XDG_CACHE_HOME=$X64_HOME/.cache
export FEX_ROOTFS=$ROOTFS
export STEAM_EXTRA_COMPAT_TOOLS_PATHS=
# Valve's 64-bit SteamRT3 client (steamrt64/steam), the x86 twin of the arm64 one, started the way
# the session starts that one (bannerlator-session): directly, STEAM_RUNTIME=1, its own library
# folders first. Through steam.sh it would first run steam-runtime-check-requirements, whose
# bwrap probe needs user namespaces - proot has none. Software rendering: no x86 GPU driver.
export STEAM_RUNTIME=1
[ -d "$X64_HOME/steamrt-shim" ] && export STEAM_RUNTIME_STEAMRT=$X64_HOME/steamrt-shim
# The client wraps the processes it starts with steam-runtime-launch-client, which the SteamRT
# container has on its PATH ("Failed to wrap process item with launch client" without it).
[ -d "$X64_HOME/steamrt-shim/bin" ] && export PATH=$X64_HOME/steamrt-shim/bin:$PATH
export LD_LIBRARY_PATH=$STEAM_X64/steamrt64:$STEAM_X64/steamrt64/panorama:$X64_HOME/steamrt-libs
# After an update the client re-executes itself inside the SteamRT container through
# pressure-vessel-wrap, which needs bwrap. The FEX rootfs with the client's own steamrt64 libraries
# is that container here; the client's recursion guard says it is already inside one.
export STEAM_STEAMRT_RECURSION_GUARD=1
# proot is a tracer, so tier0 believes a debugger is attached and breaks into it with int3 on the
# first failed assertion: the web helper died of SIGTRAP a second after every start. The session
# shim hides the tracer from the x86 web helper too (preload/tracer.c).
export BL_WEBHELPER_HIDE_TRACER=1
}
# The client binary under FEX, for the session: the starting arguments, or -shutdown / a steam://
# URL handed to the running instance.
x64_exec() {
x64_env
cd "$STEAM_X64"
exec "${BL_FEX:-/usr/local/bin/FEX}" "$STEAM_X64/steamrt64/steam" "$@"
}
case "${1:-}" in
--prepare) x64_prepare; exit 0 ;;
--exec) shift; x64_exec "$@" ;;
esac
# --- standalone (the desktop menu entry) ------------------------------------------------------
# --- one client at a time ----------------------------------------------------------------------
if pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1; then
step "shutting down the arm64 client first"
"$ARM_STEAM/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
for _ in $(seq 1 30); do pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 || break; sleep 1; done
fi
x64_prepare
step "FEX, rootfs and client ready"
# BL_FEXSERVER_LOG: a FEXServer of our own in the foreground, logging there - every FEX process that
# logs to "server" (FEX_OUTPUTLOG, or an AppConfig) then has its lines tagged with its pid.
if [ -n "${BL_FEXSERVER_LOG:-}" ]; then
FEX_ROOTFS=$ROOTFS "$(dirname "${BL_FEX:-/usr/local/bin/FEX}")/FEXServer" -f -p 600 >"$BL_FEXSERVER_LOG" 2>&1 &
sleep 1
fi
# --- what bannerlator-session gives the arm64 client ---------------------------------------
# The client's connection header and Settings > Internet come from NetworkManager on the SYSTEM
# bus; without them it shows no connection while downloads run. Same bus config and stand-in as the
# session, started here when no system bus is up (a desktop has none), stopped with the client.
x64_bus= x64_nm= x64_pin=
x64_cleanup() {
[ -n "$x64_pin" ] && kill "$x64_pin" 2>/dev/null
[ -n "$x64_nm" ] && kill "$x64_nm" 2>/dev/null
[ -n "$x64_bus" ] && kill "$x64_bus" 2>/dev/null
return 0
}
trap x64_cleanup EXIT
if ! pgrep -f 'dbus-daemon --config-file=/usr/local/share/bannerlator/system-bus.conf' >/dev/null 2>&1 \
&& command -v dbus-daemon >/dev/null 2>&1 && command -v bannerlator-netmanager >/dev/null 2>&1; then
mkdir -p /run/dbus /usr/local/share/bannerlator
cat > /usr/local/share/bannerlator/system-bus.conf <<'BUSCONF'
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
<type>system</type>
<listen>unix:path=/run/dbus/system_bus_socket</listen>
<auth>EXTERNAL</auth>
<policy context="default">
<allow user="*"/>
<allow own="*"/>
<allow send_destination="*"/>
<allow receive_sender="*"/>
</policy>
</busconfig>
BUSCONF
rm -f /run/dbus/system_bus_socket
dbus-daemon --config-file=/usr/local/share/bannerlator/system-bus.conf --nofork --nopidfile &
x64_bus=$!
for _ in $(seq 1 50); do [ -S /run/dbus/system_bus_socket ] && break; sleep 0.1; done
if [ -S /run/dbus/system_bus_socket ]; then
bannerlator-netmanager &
x64_nm=$!
step "network: system bus up, NetworkManager stand-in running"
else
step "network: the system bus did not come up; the client will show no connection"
fi
fi
# What a killed client leaves behind: .crash opens the update UI on its spinner, stale htmlcache
# Singleton* locks can leave the UI blank (as in bannerlator-session).
if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
for f in "$STEAM_X64/.crash" "$STEAM_X64/steam.pid" "$STEAM_X64/config/htmlcache/SingletonLock" \
"$STEAM_X64/config/htmlcache/SingletonCookie" "$STEAM_X64/config/htmlcache/SingletonSocket"; do
{ [ -e "$f" ] || [ -L "$f" ]; } && rm -f "$f"
done
fi
# The session's CPU set for the client (BL_CLIENT_CPUS): Steam pins itself and its web helper to a
# few cores; re-pin every 5 s as the session does for the arm64 client.
if [ -n "${BL_CLIENT_CPUS:-}" ] && command -v taskset >/dev/null 2>&1; then
(
while sleep 5; do
for pid in $(ps -A -o PID,ARGS | awk '$2=="FEX" && ($3 ~ /steamrt64\/steam$/ || $3 ~ /steamwebhelper$/) {print $1}'); do
taskset -a -p -c "$BL_CLIENT_CPUS" "$pid" >/dev/null 2>&1
done
done
) &
x64_pin=$!
fi
# BL_FEX: another FEX build for the client and everything it starts (a DEBUG_STRACE one, say).
# Restart on request (42, as steam.sh does), at most a few times.
for attempt in 1 2 3 4 5; do
# The branch on disk must match -clientbeta before every start, or the client loops on rc=42.
echo publicbeta > "$STEAM_X64/package/beta"
step "starting the x86-64 SteamRT3 client under FEX (attempt $attempt): $*"
rc=0
( x64_exec -clientbeta publicbeta -no-cef-sandbox -cef-disable-gpu "$@" ) >>"$LOG" 2>&1 || rc=$?
step "client exited rc=$rc"
[ "$rc" = 42 ] || break
done
+2 -2
View File
@@ -483,12 +483,12 @@ static FILE *synthesize(const char *table) {
}
/* tracer.c */
int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden")));
int bl_status_without_tracer(const char *path, int flags, const void *caller) __attribute__((visibility("hidden")));
static FILE *open_stream(const char *symbol, const char *path, const char *mode) {
typedef FILE *(*fopen_fn)(const char *, const char *);
int status = mode && mode[0] == 'r' && !strchr(mode, '+')
? bl_status_without_tracer(path, O_RDONLY | O_CLOEXEC) : -1;
? bl_status_without_tracer(path, O_RDONLY | O_CLOEXEC, NULL) : -1;
if (status >= 0) {
FILE *copy = fdopen(status, "r");
if (copy == NULL) close(status);
+3 -3
View File
@@ -33,7 +33,7 @@
#include <string.h>
#include <sys/types.h>
int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden")));
int bl_status_without_tracer(const char *path, int flags, const void *caller) __attribute__((visibility("hidden")));
int bl_writable_retry(int dirfd, const char *path, int flags) __attribute__((visibility("hidden")));
static int retry_without_noatime(int fd, int flags) {
@@ -60,7 +60,7 @@ int open(const char *path, int flags, ...) {
errno = EACCES;
return -1;
}
if ((fd = bl_status_without_tracer(path, flags)) >= 0) return fd;
if ((fd = bl_status_without_tracer(path, flags, __builtin_return_address(0))) >= 0) return fd;
if (real_open == NULL) real_open = dlsym(RTLD_NEXT, "open");
if (real_open == NULL) {
errno = ENOSYS;
@@ -86,7 +86,7 @@ int openat(int dirfd, const char *path, int flags, ...) {
errno = EACCES;
return -1;
}
if ((fd = bl_status_without_tracer(path, flags)) >= 0) return fd;
if ((fd = bl_status_without_tracer(path, flags, __builtin_return_address(0))) >= 0) return fd;
if (real_openat == NULL) real_openat = dlsym(RTLD_NEXT, "openat");
if (real_openat == NULL) {
errno = ENOSYS;
+2 -1
View File
@@ -57,7 +57,8 @@ static int enabled(void) {
if (cached >= 0) return cached;
off = getenv("BL_NO_PATH_CACHE");
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
strstr(program_invocation_name, "steamrtarm64") != NULL &&
(strstr(program_invocation_name, "steamrtarm64") != NULL ||
strstr(program_invocation_name, "steamrt64") != NULL) &&
!(off != NULL && off[0] == '1');
return cached;
}
+1 -1
View File
@@ -24,7 +24,7 @@
#include <unistd.h>
static int in_steam_dirs(const char *path) {
return path != NULL && (strstr(path, "/Steam/steamrtarm64/") != NULL
return path != NULL && (strstr(path, "/Steam/steamrtarm64/") != NULL || strstr(path, "/Steam/steamrt64/") != NULL
|| strstr(path, "/Steam/ubuntu12_64/") != NULL || strstr(path, "/Steam/linuxarm64/") != NULL);
}
+18 -5
View File
@@ -18,8 +18,10 @@
*/
#define _GNU_SOURCE
#include <ctype.h>
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/syscall.h>
@@ -47,9 +49,20 @@ static int is_status_path(const char *path) {
}
/* Chromium stands its hang watchdogs down when it sees a tracer, and the client's web helper does
* not come up within their patience here: it is left reading the truth. */
static int is_web_helper(void) {
return strcmp(program_invocation_short_name, "steamwebhelper") == 0;
* not come up within their patience here: it is left reading the truth. Chromium also traps when a
* status descriptor it opened turns out not to be procfs. The x86-64 client under FEX
* (BL_WEBHELPER_HIDE_TRACER=1, set by bannerlator-steam-x64) still needs its tier0 told: tier0
* reads the status, sees the tracer, and breaks into the supposed debugger with int3 on the first
* failed assertion - the web helper died of SIGTRAP a second after it started. There tier0's own
* opens (by caller) get the copy and Chromium's the real file. */
static int is_web_helper(const void *caller) {
if (strcmp(program_invocation_short_name, "steamwebhelper") != 0) return 0;
const char *hide = getenv("BL_WEBHELPER_HIDE_TRACER");
Dl_info info;
if (hide && hide[0] == '1' && caller && dladdr(caller, &info) && info.dli_fname
&& strstr(info.dli_fname, "libtier0_s.so"))
return 0;
return 1;
}
static int write_all(int fd, const char *data, size_t len) {
@@ -65,13 +78,13 @@ static int write_all(int fd, const char *data, size_t len) {
/* The descriptor to hand back for a read-only open of a status file, or -1 with errno untouched
* when the path is something else or the copy cannot be made, and the real open should run. */
__attribute__((visibility("hidden"))) int bl_status_without_tracer(const char *path, int flags) {
__attribute__((visibility("hidden"))) int bl_status_without_tracer(const char *path, int flags, const void *caller) {
char text[STATUS_MAX];
size_t len = 0;
int saved = errno;
int out = -1;
if ((flags & O_ACCMODE) != O_RDONLY || !is_status_path(path) || is_web_helper()) return -1;
if ((flags & O_ACCMODE) != O_RDONLY || !is_status_path(path) || is_web_helper(caller)) return -1;
int in = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC);
if (in < 0) goto done;
for (;;) {
+27
View File
@@ -137,10 +137,37 @@ static int traced_by_match(const char *key) {
return found;
}
/*
* FEX (the x86-64 Steam client's translator) loads this library into its own process. Its 32-bit
* mode does not survive that: every i386 program died with SIGILL, FEX's fatal trap, at start. So
* in FEX, only stay on when the program it is about to run (argv[1]) is a 64-bit ELF; a 32-bit
* one, a script, or anything that cannot be read gets the real calls, as before.
*/
static int fex_runs_32bit(void) {
if (strcmp(program_invocation_short_name, "FEX") != 0 && strcmp(program_invocation_short_name, "FEXInterpreter") != 0)
return 0;
char args[PATH_MAX + 64];
long fd = syscall(SYS_openat, AT_FDCWD, "/proc/self/cmdline", O_RDONLY | O_CLOEXEC);
if (fd < 0) return 1;
long n = syscall(SYS_read, fd, args, sizeof args - 1);
syscall(SYS_close, fd);
if (n <= 0) return 1;
args[n] = 0;
const char *prog = args + strlen(args) + 1;
if (prog >= args + n || !*prog) return 1;
unsigned char ident[5];
fd = syscall(SYS_openat, AT_FDCWD, prog, O_RDONLY | O_CLOEXEC);
if (fd < 0) return 1;
n = syscall(SYS_read, fd, ident, sizeof ident);
syscall(SYS_close, fd);
return !(n == 5 && memcmp(ident, "\x7f" "ELF", 4) == 0 && ident[4] == 2 /* ELFCLASS64 */);
}
static void fp_init(void) {
const char *r = getenv("PROOT_FP_ROOT"), *b = getenv("PROOT_FP_BINDS"), *t = getenv("PROOT_FP_TTL_MS");
const char *key = getenv("PROOT_FP_KEY");
if (!r || !*r || !key || !*key || getenv("PROOT_FP_OFF")) return;
if (fex_runs_32bit()) return;
void *p = mmap((void *)FP_STUB_ADDR, FP_STUB_SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
if (p != (void *)FP_STUB_ADDR) {