The pin moved there from droiddeck-steam-x64, so the client settings sheet and its test read it from the new script. The x86-64 client notes now name the updater and Components' FEX watcher.
40 KiB
x86-64 Steam client
Valve's x86-64 Linux Steam client, run unchanged under FEX beside the default arm64 client. It is
opt-in: Steam settings, Steam client: x86-64 (steamClientArch, passed to the
session as BL_STEAM_CLIENT_ARCH=x86_64, applies from the next session), or the desktop menu entry
Steam x86-64 (FEX, experimental). The Library lists the active client's games and tags its
header "x86-64" while that client's list is shown. This page records why each workaround exists and what was seen
on devices. The timeline is in docs/progress-log.md.
Why. The arm64 Linux client never runs VAC: Left 4 Dead 2 and Team Fortress 2 are refused by VAC-secured servers ("Connection Blocked ... VAC secured server") and no client log mentions VAC. VAC modules are x86 code. Under FEX the x86-64 client has a module it can run, as SteamLite does in Wine.
Layout. Everything lives in its own home, $X64_HOME (BL_X64_HOME, default ~/.droiddeck-x64):
its own ~/.steam links, config, libraries, x86 rootfs and log (steam-x64.log). Shared with the arm64
client: the sign-in, and its config/uioverrides (Steam's startup movie overrides), which the x86 client
links. One client runs at a time, since both use the same account; the standalone launcher shuts the
arm64 client down first.
| Command | Does |
|---|---|
droiddeck-steam-x64 [steam:// URL or client arguments] |
standalone run (the desktop menu entry) |
droiddeck-steam-x64 --prepare |
install and set up, then exit |
droiddeck-steam-x64 --exec ARGS... |
run the client binary in its environment (how the session starts it) |
droiddeck-steam-x64 --sync-login |
copy the newer sign-in to the other client |
droiddeck-steam-x64 --map-direct [--live] |
map Linux titles to DroidDeck direct |
droiddeck-steam-x64 --default-tool |
print the default ARM64 Proton wrapper's tool name |
droiddeck-steam-x64 --gpu / --webhelper-gpu |
exit status: client / x86 web helper on the GPU |
Client install and update
- The first run downloads FEX (release
FEX_TAGin Droid-Deck/DroidDeck-Components, sha256-pinned), FEX's Ubuntu 24.04 x86 rootfs (about 525 MB, 1.5 GB unpacked, sha256-pinned; the same repository's copy of FEX-Emu's 2026-08-11 image) and the client (about 600 MB) from Valve's CDN, part by part with sha256 checks. A part-done rootfs download resumes; a finished one answers 416, which is not a failure.unsquashfs -no-xattrs -ignore-errors: ownership and xattrs mean nothing in the guest, and device nodes cannot be made and are not needed (FEX passes/devthrough). - Channel
steamdeck_publicbeta, the-clientbetathe session starts it with.package/betamust match-clientbetabefore every start, or the client loops on rc 42 (its restart request, which the standalone launcher honours up to five times, assteam.shdoes). - Before each start
x64_sync_clientbrings the client to Valve's current build, downloading only the changed parts (9 of 28 on a device). If that fails, the client updates itself. - The client is the
steamrt64platform (steamrt64/steam, the manifest it checks for updates), so the install, the sync and the web-helper build check readsteam_client_<channel>_steamrt64. Theubuntu12manifest lags it (device: 1792108788 against 1792192922): an install from it was always one build behind the arm64 client, so the arm64 web helper was never used, and the client updated itself in place at its first start (506 MB) after the helper had already been chosen. .steam-enable-steamrt64-clientin the client folder opts into the SteamRT3 build (steamrt64/steam). Without it the client re-executes itself in the scout environment throughsteam.sh, whose start-up checks need what proot cannot give; the session stalled before the interface. The client is started directly, as the session startssteamrtarm64/steam:STEAM_RUNTIME=1, its ownsteamrt64libraries first, and the~/.steamlinkssteam.shmakes.- User namespaces. Unlike the arm64 client, this one carries pressure-vessel and refuses to start
("Steam now requires user namespaces") when
steam-runtime-check-requirements' bwrap probe cannot create a user namespace, which no Android app can.STEAM_RUNTIME_STEAMRTpoints atsteamrt-shim: Valve's runtime linked file by file with only the probe replaced (exit 0). proot is the container, as for the arm64 client.droiddeck-steamrt-shimbuilds it, and builds the same shim for the arm64 client if that one is containerized too (arm64-steam-client.md). - Re-exec after an update. The client re-executes itself inside the SteamRT container through
pressure-vessel-wrap(bwrap).STEAM_STEAMRT_RECURSION_GUARD=1says it is already inside one; the FEX rootfs with the client'ssteamrt64libraries is that container. - Starting games. Believing itself inside the container, the client starts games and system
commands "alongside Steam" through
steam-runtime-launch-client. Valve's asks a launcher service on the session bus (there is none: the game ended at once), and with none onPATHthe launch fails (No such file). The shim's version applies--directory,--envand--unset-envand runs the command. - Libraries. The rootfs lacks what the SteamRT container gives the client (GTK 2 for
steamui.so, ...). Valve's steamrt3c platform libraries are linked intosteamrt-libs; they ship without soname links (pressure-vessel makes those when it builds the container), so the rootfs'sldconfigadds them there.LD_LIBRARY_PATHbeats the system folders whatever its order, so only libraries the rootfs lacks are kept: the platform's older glibc must not shadow the rootfs's. - System V IPC. Android kernels have none, and the client's tier0 gave up on start ("Function not
implemented" from
semget; traced with a-DDEBUG_STRACEFEX). The session shim (preload/*.c) is also built for x86-64 and i386 and preloaded through the rootfs's own/etc/ld.so.preload, one copy per architecture under the multiarch folder named through glibc's$LIB. Without a list in the rootfs, FEX falls through to the guest's, which names the session's arm64 preloads. - Tracer. 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.
preload/tracer.cexempts web helpers on purpose (Chromium traps on a status fd that is not procfs); withBL_WEBHELPER_HIDE_TRACER=1only opens called fromlibtier0_s.soget the copy without the tracer. - FEXServer owns the rootfs. A FEX process takes its rootfs from FEXServer, and a server started
earlier without
FEX_ROOTFS(any FEX run in the guest) serves none: every x86 library lookup fell through to the arm64 guest. It is stopped while no x86 client runs. /dev/dri. x86 programs enumerated/dev/dri, found the session's KGSL node and spun on it (x86 Turnip "failed to query kernel driver version", KMS dumb-buffer ioctls in a loop); the web helper never got a browser up. FEX looks in the rootfs first, so an emptyrootfs/dev/drihides the node.- Scout runtime. Valve ships it as
ubuntu12_32/steam-runtime.tar.xzandsteam.shunpacks it. Sincesteam.shis not used,--prepareunpacks it when its checksum changes. - Repair. Repair Steam (
BL_X64_REPAIR=1), or two starts in a row that never showed the client (package/droiddeck-unconfirmed; the app clears the count once the client is on screen), removes the install stamp so the client is reinstalled. Counts from before this was tracked are dropped rather than taken as failed starts.droiddeck-steam-installremoves the repair request when it is done, so the session reads it first and both clients are repaired. - Stale state. A killed client leaves
.crash, which opens the update UI on its spinner, andhtmlcache/Singleton*locks, which can leave the UI blank. Both are removed while no client runs. - Network. The client's connection header and Settings > Internet come from NetworkManager on the
system bus; without it the client shows no connection while downloads run. The standalone launcher
starts the session's system bus configuration and the
droiddeck-netmanagerstand-in when no system bus is up (a desktop has none). - FEX settings. The app's PERFORMANCE_TSO preset (
core/FexPreset.kt, WinNative's default). FEX's own defaults keep half-barrier TSO and full-precision x87, which cost every x86 program time. Values already in the environment win. - Fast path. libblfastpath, preloaded into every guest process and so into FEX, broke FEX's 32-bit
mode (SIGILL at start). It stands down when FEX is about to run a 32-bit program; 64-bit programs
keep it.
BL_X64_FP_OFF=1turns it off for everything. - Diagnostics.
BL_FEXruns another FEX build (aDEBUG_STRACEone, say).BL_FEXSERVER_LOGstarts a FEXServer in the foreground logging there; every FEX process that logs to "server" (FEX_OUTPUTLOGor an AppConfig) then has its lines tagged with its pid (file logs overwrite each other).
Web helper on the GPU, QAM/WSI and flicker
The client's web helper draws its whole UI. There are two:
- The arm64 client's helper (
droiddeck-x64-arm-webhelper), used when both clients are the same Steam build (unlessBL_X64_X86_WEBHELPER=1). FEX hands the arm64 program to the CPU, so it runs natively with the session's preloads (KGSL poll fix, fast path) and the arm64 session's GPU settings. It talks to the client over IPC, and a helper of another build never comes up: the client restarted it every 10 s (device: 1790721607 against 1790904859). The session therefore updates the arm64 client's parts on the same channel first (droiddeck-steam-install --sync steamdeck_publicbeta). With this helper the client itself gets the GPU: it shows every Big Picture frame the helper draws, and in software (llvmpipe under FEX) that held the UI at about 20 fps (BL_X64_CLIENT_SOFTWARE=1keeps it in software). Device: 144 fps menus. - The client's own x86 helper on the GPU (
droiddeck-x64-webhelper), otherwise: ANGLE's GL backend through FEX's GL thunk to zink and Turnip. On ANGLE's Vulkan backend the x86 compositor thread was the bottleneck (16 fps against 60). Never--in-process-gpu, the preload's default: a GPU crash there took the whole helper down (102 restarts).BL_X64_CEF_GPU=0keeps it in software. Its GPU process loads libpci, which exits when/sys/bus/pci/deviceslists a device whose files the app may not read (a RedMagic's PCIe root ports): every GPU process exited with status 1 in about 50 ms, software ones too, until Chromium gave up ("GPU process isn't usable") and the menus stayed blank.LinuxRuntimebinds an empty folder over/sys/bus/pci/deviceson such devices; device: 0 GPU process exits, ANGLE on zink and Turnip.
The session shim (preload/net.c) turns steamrt64/steamwebhelper.sh's exec of ./steamwebhelper
into BL_X64_ARM_WEBHELPER. Nothing in the client's folder is edited: the client checksums its files
as it starts and reinstalled itself over an edited launcher (2-3 minutes) on every start.
Quick Access Menu and flicker, x86 helper:
- Steam's compositor in the helper draws each browser window (main UI, QAM) from that window's X
pixmap. gamescope's WSI layer took the helper's zink swapchains and sent the frames to gamescope
instead: the QAM opened blank until a d-pad press and the screen behind it went black. The helper
runs with
DISABLE_GAMESCOPE_WSI=1; games keep the layer. Not FEX: a texture-from-pixmap test behaved the same under FEX and natively. - gamescope sets
vk_xwayland_wait_ready=falsefor its children because its layer does the waiting. Without the layer, unfinished frames reached the compositor as black flashes (3 in 10 QAM opens, screen-recorded and counted frame by frame). The helper setsvk_xwayland_wait_ready=true. - zink's kopper swapchain still flashed on about 1 in 20 opens with the wait. With
LIBGL_KOPPER_DISABLE=truenone did in 60 opens, at 60 fps instead of about 140 (Xwayland's per-window timing for child windows). No flashes was chosen over frame rate.
Software path (no host thunks, or BL_X64_SOFTWARE=1): x86 Mesa has no driver for this GPU, so
llvmpipe for GL (the client's own window needs GLX) and the rootfs's own Vulkan ICDs (lavapipe). GLX
first failed because the desktop exports zink over the arm64 Turnip ICD; those variables are cleared.
LIBGL_DRI3_DISABLE=1: over DRI3 the X server hands out the GPU node and Mesa's software path then
tries KMS dumb buffers on it in a loop.
Decky's loader talks to the client's CEF debugging port. Its marker
(.cef-enable-remote-debugging) is kept in both clients' roots, because the session reads it from the
client it runs. The app creates it in the x86-64 root only once that client is installed: the folder
alone would then read as an installed client.
FEX build and patches
-
tools/fex/build-in-arch.shruns in an Arch Linux ARM container (menci/archlinuxarm:base-devel) on an arm64 runner: Arch Linux ARM has no FEX package and FEX publishes no Linux binaries. It packs FEX and the runtime's ownunsquashfsasfex.tzst(usr/local/...), withfex-needed.txt(every needed soname must resolve in a stock guest) andfex-files.txt.TUNE_CPU=none: the default (native) would tune for the runner's Neoverse cores, not the device. -
Host thunks (
FEX_THUNKS=1): an x86 program's libvulkan, libGL, libX11, ... calls go to the runtime's arm64 libraries (Turnip, Mesa). The host half needs the graphics development packages. The guest half is cross-compiled with clang against an x86 sysroot of Arch x86_64 and multilib i686 packages, unpacked by pacman into their own root and never run (no scriptlets, no signature checks). FEX's x86 toolchain files assume an x86 build machine, so on arm64 the sysroot also supplies headers, C runtime objects and libgcc. -
Stack protector. Arch's clang builds the guest thunks with stack protection. The canary load in
CallHostFunction's prologue (mov %fs:0x28, %r11) overwrote r11, which carries the host function's address, and FEX called the canary. 9 of 521 Vulkan and 66 of 736 GL host calls (those with 7 or more arguments) were affected,vkCmdBindDescriptorSetsfrom TF2's DXVK among them; 32-bit games pass the host address in mm0. The toolchain files add-fno-stack-protector(fex-2610-r2). -
vkGetDeviceProcAddr. FEX-2609's guest version passed a lookup ofvkGetDeviceProcAddritself to the host and handed back the raw host function, so every pointer fetched through it was unlinked. TF2's native DXVK (2.3) makes that lookup. FEX-2610 answers self-lookups in the guest (FEX-Emu/FEX 3397eb9b). -
Patches in
tools/fex/patches:0001-openat2-enosys-fallback: our proot answersopenat2with ENOSYS (tools/proot/PATCHES.md, 0008). FEX opened every rootfs path withopenat2(RESOLVE_IN_ROOT)and fell through to the arm64 guest on anything but EXDEV; x86 bash could not find libtinfo. Since r4 the fallback resolves the path inside the rootfs itself (absolute symlinks restart at the rootfs, asRESOLVE_IN_ROOTdoes); before, they resolved against the arm64 guest.libblsession.so(preload/robust.c) answersopenat2first in practice and does the same walk forRESOLVE_IN_ROOTcallers, so Valve's FEX-Emu on the arm64 client gets it too. Each path component is then its own system call.0002-thunkgen-newer-clang: the container's newer clang has noFileManager::clearStatCache(), which the thunk generator called after each run.0003-allocator32-map32bit-exhaustion: Counter-Strike 2 reserves the low 2 GB withMAP_32BITin shrinking sizes. FEX'sMemAllocator32Bitmet EEXIST, stepped down one page per retry and restarted from the top forever (found withperf_event_openIP sampling, since proot holds ptrace). Such a range is now skipped whole and a second exhausted scan returns-ENOMEM, as the kernel does (fex-2610-r3).
-
Building and publishing. Droid-Deck/DroidDeck-Components'
build-fex.ymlbuildstools/fexfrom this repository at a chosen ref (defaultmain) with host thunks, in the same Arch Linux ARM container with a read-only token, then a separate job replacesfex.tzston Components' singlefexrelease, checks the digest GitHub stored, and records the build's version, sha256 and size in itsfex.json(in that order: a device that reads the oldfex.jsonmeanwhile gets a mismatching archive and keeps its FEX). This repository'sbuild-fex.ymlonly buildstools/fexwhen it changes (and on dispatch, for diagnostic builds such as-DDEBUG_STRACE); nothing is published from it. -
Releases so far:
fex-2609-r2(no thunks),fex-2609-r3(thunks),fex-2610-r1,fex-2610-r2,fex-2610-r3,fex-2610-r4(in-root resolution when openat2 is missing, exact MAP_32BIT marking),fex-2610-r5(current: bundles libxxhash, which the Linux runtime does not ship; the build fails when a program in the package needs a library that is neither bundled nor listed intools/fex/runtime-libs.txt). They were built and published in The412Banner/DroidDeck; up to r3 their tags point at that repository'smain, from r4 at the commit each was built from. Later builds are versions on the singlefexrelease;fex-2610-r5stays published as the builddroiddeck-fex-updatepins. -
Where the app gets it. The app downloads FEX from Droid-Deck/DroidDeck-Components, where nothing is uploaded by hand. The app follows its
fex.json: it reads it from Components'main(directly, at most every ten minutes) and keeps the last copy, and every session passes that build todroiddeck-fex-update(BL_FEX_TAG,BL_FEX_SHA256,BL_FEX_URLSwith the download source's mirrors). The script installs a build only after its archive matches the sha256 and every program in it loads (ld-linux --list); the files it replaces are kept aside until the installed copy loads too and put back otherwise, and the tag is written last. Otherwise it keeps the installed one while that still loads, then falls back toFEX_TAG/FEX_SHA256built in, which stay the first-start default when no copy offex.jsonhas been read. Components'watch-fex.ymlstarts that run by itself every six hours once FEX-Emu tags a release ortools/fexchanges onmain. Publishing a FEX build therefore takes a Components run only; a replaced archive cannot be pointed back to, so undoing a build means building an earlier ref. The rootfs is FEX-Emu's own image, unmodified:fex-rootfs.jsonthere names its rootfs.fex-emu.gg link, sha256 and size, andmirror-upstream.ymlfetches it into the catalog's release only when both match. A newer image is a new catalog release;ROOTFS_URLandROOTFS_SHA256then move to it, and a device that already unpacked one keeps it. -
What a session log shows.
FEX ready (<tag>, <source>)names the build and where it came from (catalog,built-in, orkeptwhen a newer one did not install, with the reason a line above);x86-64 rootfs:names the image;x86-64 client:the client's steamrt64 build at start (it may update itself after). The rootfs download reports every tenth of its size as a step. When the web helper's GPU process keeps exiting until Chromium gives up on the GPU (the menus then never appear), one line says so with the exit codes, followed by x86glxinfo -Bandvulkaninfo --summarythrough FEX's thunks.
DroidDeck direct for Linux games and runtimes
- Steam runs native Linux games in the Steam Linux Runtime container (pressure-vessel), which needs
bwrap and user namespaces. DroidDeck direct (
compatibilitytools.d/droiddeck-direct, linux to linux) runs them under FEX with no container. Scout titles run through Valve's scout library runtime (ubuntu12_32/steam-runtime/run.sh). Titles Valve gives a newer runtime (sniper, soldier; listed indirect-sniper) are built for newer libraries than scout's and run on the rootfs (Ubuntu 24.04) as it is. - Mapping (
x64_map_direct): every game the client's appinfo cache shows with a Linux build (type game, oslist with linux; runtime fromconfig/app_mappings: SteamLinuxRuntime_sniper or _soldier, none means scout), installed or not, so Steam downloads the Linux depots. A title on a tool chosen in Properties keeps it, exceptBL_X64_DIRECT_APPS(Left 4 Dead 2, 550, by default), which is always taken back.direct-auto.jsonlists what was mapped. One pass over the mapping: a 2000-title fixture went from 16.5 s to 0.2 s with the same output. - A game whose Windows build is installed (it ran on a Proton before) stays a Windows game on the
default Proton. Steam does not swap an installed build when the tool changes: DroidDeck direct was
handed
Launcher.exeand the game ended at once (Borderlands 2), and getting the Linux build back means downloading the game again. - The client rewrites
config.vdffrom memory as it exits, so the mapping is written only while no x86 client runs: by--prepare, and by the session after each exit, when the appinfo cache also knows titles bought during the run.--live(the session's pass while the client runs) writes nothing; installed Linux titles it would map go to the session's pending list. - A new user's client has no
config.vdfbefore its first start, so Steam Play's default and the Linux titles would wait a session.--preparewrites theCompatToolMappingblock alone, which the client keeps. - Sniper check.
tf.shreadsVERSION_CODENAMEfrom/etc/os-releaseand stops unless it issniper. Fordirect-snipertitles the run script runs such a script throughbash -cwith that line replaced byVERSION_CODENAME=sniper. - HarfBuzz. The rootfs's HarfBuzz needs a newer FreeType than a game may bring. Counter-Strike 2
ships an older
libfreetype.so.6and no HarfBuzz: its menu text library failed to load, the game flagged its files corrupt (a verify each start) and waited behind an error box that could not open. Newer-runtime titles get Valve's runtime HarfBuzz (2.7) preloaded fromsniper-compat, as they would in its container. - GPU. Games get FEX's host thunks and the session's GPU settings (Turnip, Zink), saved by the
launcher as
game-gpu.envand rewritten whenever it runs;game-gpu.localis the user's, never rewritten, and is read after it.BL_X64_GAME_SOFTWARE=1keeps a game in software, withmesa_glthread=false(GL threading is no help with software GL under FEX). - Source games (
hl2.sh,hl2_linux,srcds) get-condebugfor the VAC diagnostics. - Controllers.
libfakeinputis built for x86-64 and preloaded in the rootfs; Big Picture works with it. The 32-bit copy crashed Left 4 Dead 2: its fake devices reach the game's own SDL2 through udev with a missing attribute and SDLstrdup()s the NULL (fault backtrace: strlen in libc <-__strdup<- libSDL2 <-udev_device_get_sysattr_value). 32-bit programs get only the session shim. The preload line names the x86_64 copy by full path, since with$LIBFEX falls through to the session's own/usr/local/lib/i386-linux-gnucopy. - Device: TF2 main menu at 358 fps on the GPU and about 84 fps in a match; Half-Life (32-bit) at 101 fps; Left 4 Dead 2 at 51 fps (about 11 in software).
- The arm64 client's route to x86 Linux titles,
droiddeck-fex, is not wrapped for this client. Its wrapper ran when the client probed its tools after logon; the arm64 client took that for an x86 program wanting FEX and installed Valve's FEX. droiddeck-fexitself (arm64 client): Valve's FEX-Emu keeps its server socket in$STEAM_COMPAT_DATA_PATH/fex-emu/ServerwhateverFEX_APP_DATA_LOCATIONorFEX_SERVERSOCKETPATHsay. Under the app's data folder that path passed the 108 bytes a socket path may have (Half-Life: 136), FEXServer never listened and the game ended at once. FEX's data folder in the runtime directory is short enough; the one under HOME is not, so/tmp/fexstands in for it.
Libraries, SD card and sharing between clients
- Shared games. Before either client starts,
droiddeck-steam-sharegives it a view of the games the other client installed:~/.droiddeck-x64/shared/arm64for the x86 client ("ARM64 client games") andshared/x86-64for the arm64 one ("x86-64 client games"). A view holds a copy of each finished game's manifest and links to itscommonfolder andcompatdata, so the game shows as installed in both clients, runs from the same files and keeps one prefix and one set of saves. Runtimes, Proton and FEX are not shared. Device: the x86 client listed the 14 arm64 games as installed (13 ready to launch, Valheim fetching its shader cache) and started Stardew Valley from the arm64 files with no game data downloaded; the arm64 client listed Spacewar installed only in the x86 client. - Same build in both. A title with a Linux build ran as that in the x86 client and as the Windows
build in the arm64 one, so opening it in the other client switched the files and the first client
downloaded its version again (Left 4 Dead 2, 8.6 GB; hard links do not help, switching platform makes
Steam download depot 551 anyway). A shared game gets the tool its own client uses:
droiddeck-fexthere becomesdroiddeck-directhere and the other way round, a Proton build keeps its Proton. Only an automatic choice that would switch the build is replaced; any other choice, including another Proton, stays. - Updates and uninstall. The newer manifest wins: an update made through a view is written back to the client that owns the game. Uninstalling a shared game removes it from that client only; its files stay with the client that installed it (Steam does not delete through the link) and it stays hidden there until that client updates or reinstalls it. A game installed in both clients is each client's own and is not shared. A view with nothing in it is not offered as an install location. Views are written only while no client runs. Shader pre-caching stays each client's own: the x86 client may fetch a shared game's shader cache into its own folder (Valheim, 1.8 GB).
- SD card. The x86 client's card library is a folder inside the arm64 one (
/mnt/droiddeck-sd/x86-64,GameStorage.X64_CARD_DIR, passed asBL_X64_STEAM_LIBRARIES), so the install dialog offers Internal Storage and SD card. compatdata, shadercache and runtime tools stay private, as in the arm64 card library. FEX runs x86 programs from the noexec exFAT card. The folder appears with the client's first session on the card and itssteamappswith the first library write. Desktop sessions prepare it as well: desktop mode offers the x86 client whichever is selected, anddroiddeck-steam-x64drops library folders it is not given. The arm64 card library may hold the arm64 copy of the same title, so the session's installed check looks only in the x86 card folder. - Card downloads.
storage.c(no zero-filled reservation wherefallocateis refused) anddircache.crecognised onlysteamrtarm64/steam, and the x86 client wrote each download in full as zeros first (Audiosurf: 415 MB written, 0 downloaded). After the fix: FlatOut, 1.19 GB, to the card in 51 s. - Sign-in. Before either client starts, the newer sign-in (
config/loginusers.vdf, the login token inlocal.vdf,AutoLoginUserandRememberPasswordinregistry.vdf) is copied to the other, so signing in or switching accounts in one counts for both; never while either client runs, since each rewrites these files. A client that refused its token rewriteslocal.vdfwith an emptyConnectCache, so it is never the source. The copy keeps the time stamp, so both match until one client signs in again. The rest ofregistry.vdf(language, skin) stays each client's. - Account IDs. A client logs on with the token only once it knows the account's SteamID, which it
takes from
Accountsinconfig/config.vdf. The x86 client'sconfig.vdfis made by this script without one, so it logged on as[U:1:0]with the arm64 token and Steam answered "Invalid Password" (and the client then dropped the token andAutoLoginUser): every switch asked for the password. Each start adds the accounts either client knows to the other'sAccounts, and anAutoLoginUserorRememberPasswordone client has and the other lacks. Device: the x86 client signed in with the arm64 token (RecvMsgClientLogOnResponse ... 'OK'). - App side. The library, art picker, grid sync and boot movie follow the running client, else the
selected one; a changed client applies from the next session. A game is listed once per client: an added game
whose folder a Steam install already covers (its
DroidDeck-<appid>import) is shown as that Steam game only, and a client switch drops the old client's imported entries with its Steam games. In Steam, an import stays one when its ownership cannot be confirmed that session instead of gaining a shortcut beside it, and only the client's own library folders count as an install (the arm64 card library made the x86 client skip a game's import and its shortcut). Offline Mode is written to both clients'loginusers.vdf; until the x86 client's first start its sign-in is the arm64 one. The log redactor learns account names from both clients. The startup movie is staged into the x86 client's config only once its first start has created it;--preparelinks the arm64config/uioverridesand copies theStartupMoviechoice. - Storage page. It lists both clients' games, the x86 ones labelled, each where its files are, so a
shared game is counted once; a game can be installed in both and an id left over in several
libraries. The Games page of each client lists the games shared with it.
steam://uninstallreaches only the session's client, so the other client's own copy is uninstalled after switching clients. Tools in the x86 client's libraries are shown as required (it runs its Linux games with Valve's runtimes). Proton tool files live only in the arm64 root; the x86 client has wrappers. An x86 client withoutconfig.vdfhas never mapped a game.
Proton for Windows titles in the x86 client
- Windows games run on the arm64 client's Protons; x86 Proton would need pressure-vessel. For each
arm64 client tool (droiddeck-proton-arm64, DroidDeck-Proton, GE-Proton, CachyOS)
--preparemakes a wrapper in the x86 client'scompatibilitytools.dwith the same folder and display name: Components swaps and per-title mappings are keyed by them. In the tool namearm64andaarch64becomea64, because the x86 client ignores a tool named for arm64 ("for a different target platform linux arm64"). The session runs--prepareagain after it installs a new Proton. - The wrapper is an x86 script under FEX. It starts
droiddeck-x64-arm-protonthrough an arm64 binary (a copy of/usr/bin/env), which FEX hands straight to the CPU, so Wine and the game run as from the arm64 client: same Proton, launcher, Components swaps, per-game environment and Steam client library (~/.steam/sdk64). droiddeck-x64-arm-protondrops FEX's settings and the x86 library paths and preloads, then restores the arm64 session's environment thatdroiddeck-steam-x64saved before it built the x86 one (arm-session.env: HOME, PATH, GPU, audio, input, fast path). Steam's per-game variables are not in it. It keeps the game display Steam chose: the savedDISPLAY=:0hid the game window and made DiRT Showdown exit after about 7 s. Proton takes its Steam client parts from the client's install folder, and the x86 client's are x86, soSTEAM_COMPAT_CLIENT_INSTALL_PATHpoints at the arm64 root andBL_X64_STEAM_ROOTkeeps the x86 one for install-script lookups.- Game overlay. The x86 client preloads its own x86 overlay; the arm64 one takes its place. The
overlay marks the game's windows with the Steam app ID, and without it gamescope never shows them
(FlatOut ran and played sound behind Steam's launch screen, "steam app id: 0"). The agent also tags
untagged
steam_app_<id>windows withSTEAM_GAMEon the game display when the x86 client runs (DiRT 3, an added game, then reached its title screen at 144 fps). - The x86 client's tools are not adopted as arm64 Protons:
adopt_extrason itscompatibilitytools.drewrote DroidDeck direct as a Windows tool, and Steam installed Half-Life's Windows files. The arm64 Proton is adopted on demand at an x86 launch. - Default. The newest DroidDeck-Proton, then the newest GE-Proton (aarch64), else
droiddeck-proton-a64. FlatOut from the card ran on DroidDeck-Proton 11-9.1 and exited on GE-Proton11-7 (no log). With droiddeck-proton-arm64 (Proton Experimental) Xwayland never got its window buffers back and FlatOut took the device's RAM with it (10.5 GB of dma-buf in 35 s); GE-Proton, same game and client, stayed flat. The session asks for GE-Proton when none is installed.default-toolrecords the default the script set; when it changes, the titles still on the old one move along (device: 113 titles off GE-Proton11-7). A default or title the user set to another tool keeps it. Beforedefault-toolexisted, the default the script set was an-a64wrapper at priority 75. steam-compatibility --x64repoints entries the client aimed at a Proton it cannot start (Valve'sproton_*names, anddroiddeck-proton-arm64left by an arm64 compat pass) to the default wrapper. Tools in its owncompatibilitytools.dare kept: the wrappers includeproton-cachyos-*-a64, which starts with "proton" like Valve's.- Added games are written by the client the session runs and pointed at its default wrapper. The
shortcuts pass keeps the x86 client's own default and state (
droiddeck-compatbeside its Steam folder), because the arm64 client'sstate.jsonlists which of that client's titles follow its default. - Install scripts. A game's own installers without a
HasRunKey(Borderlands 2: VC++ 2005, 2008 and 2010, DirectX, .NET 4.0) are marked underSoftware\Valve\Steam\Apps\<appid>(and the Wow6432Node view), so Steam skips them; this applies to both clients. Windows components install into the x86 client's prefix (DiRT Showdown and DiRT 3 needed OpenAL).
VAC findings
- x86-64 client with Linux builds: secure. Left 4 Dead 2 joined a live Quick Match game, the server type that refuses the arm64 client; on the GPU an online server answered the A2S info query as "Valve Left4Dead 2 US East Server", VAC=1. TF2 Casual matchmaking put it on a VAC-secured Valve server. Half-Life's native build joined a VAC-secured server.
- x86-64 client with a Windows build (TF2 on DroidDeck-Proton): insecure. Proton reaches Steam through
the arm64 client's
steamclient.so. - arm64 client: Left 4 Dead 2 was blocked on both Protons; Half-Life (Windows build) joined a vac=1 GoldSrc server. Why the arm64 client fails is not established; it has no VAC module of its own to run.
- Headless VAC testing is not possible: current Steam starts the saved login from its UI.
- VAC diagnostics (Setup page). A Source 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 with
-condebug:<game>/<mod>/console.log, or<game>/game/<mod>/console.logfor Source 2. At session start the app writes~/.droiddeck-vac-diagnostics(start time, client, each console.log's length, since-condebugappends). The launch wrapper (bl_vacdiag) then adds-condebugto Source titles (found bygameinfo.txtorgameinfo.gi), flags-insecurein launch options and notes each launch. At the end the session copies what each console.log gained intovac/, scrubbed, and writesvac.txt: the game's secure/insecure lines, VAC lines in the client's logs, and which client files are present. A stopped session keeps its own copy of the marker and launch list; a new session waits for earlier collections, because it replaces the marker and Steam's logs. - "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.
- SteamLite's "INSECURE - VAC may kick" warning was our own parser reading the wrong log while the
client was secure all along.
vac.txttherefore quotes the lines it found and says "inconclusive" when the game never reached a server.
Session integration, restarts and mapping
- With
BL_STEAM_CLIENT_ARCH=x86_64,droiddeck-sessionupdates the arm64 client's parts (droiddeck-steam-install, then--sync steamdeck_publicbeta), runsdroiddeck-steam-x64 --prepareand starts the client withdroiddeck-steam-x64 --exec. Steps only for the arm64 client (library registration, the ARM64 Proton fetch, the live compat helper) are skipped; added games,steam-compatibility --x64, UI scale and redistributables run for the x86 client. - Deck mode. With
-steamos3the client's SteamOS audio manager passed libpulse the NULL operation of a failed request, and libpulse aborted the client right after start ("Assertion 'o' failed at ../src/pulse/operation.c:136 pa_operation_get_state()", the runtime's PulseAudio 13).preload/pulseguard.c(x86 builds only; the arm64 client does not hit it) answers a missing operation as cancelled and ignores its unref and cancel. The session passes-steamdeck -steamos3unlessBL_X64_DECK=0. Switch to Desktop is expected to need no change (an x86 shell under FEX runssteamos-session-select); the full switch has not been tested on a device. - Process names. Under FEX a process's argv[0] is FEX and the program is its first argument. The session's game detection, CPU pinning and the zRAM watcher read that argument. Steam pins itself and its web helper to a few cores, so the session's CPU set is applied again every 5 s.
- Live mapping and restarts. While the client runs, the session's background loop runs
steam-compatibility --x64 --liveanddroiddeck-steam-x64 --map-direct --live, every 15 s and only in the session's first hour (720 beats of 5 s). A title installed after that is mapped when the client exits and takes effect from the next session. Steam reads a mapping changed while it runs only at its next start, so changed titles go to.bl-compat-pending. Once they have installed (StateFlags4 in the client's root or its card folder) and no game runs, the client is restarted once per title, at most 3 times a session. - After exit. Added games,
steam-compatibility --x64and--map-directrun again: the client wrote its mapping from memory as it exited, over what the live passes set. - The Lossless Scaling ownership probe and the Steam import watcher reach the x86 client through the
arm64 client's
steamclient.so(BL_STEAMCLIENT); the watcher restarts the client withdroiddeck-steam-x64 --exec -shutdown. - Steam logs and the Steam language come from the x86 client's home. On the desktop,
steamandsteamdeck-steamstart the selected client, and the desktop game entries list its libraries. - The standalone launcher (desktop menu) shuts the arm64 client down first, starts the system bus and the NetworkManager stand-in when missing, clears stale locks, pins CPUs and restarts the client on rc 42.
Known limits
- Experimental and opt-in; one client runs at a time.
- Windows titles in the x86 client are not VAC-secure: Proton reaches Steam through the arm64
steamclient.so. - 32-bit games get no controller reader (the i386
libfakeinputis left out). - 32-bit Linux titles through the arm64 client's
droiddeck-fexstill fail (not pursued). - The x86 web helper's menus run at 60 fps without kopper.
- Counter-Strike 2 reaches its menu (and loaded Dust II at 78 fps), then Android's
lmkdkills DroidDeck. The game's GPU memory is pinned: KGSLpage_allocfollowed Shmem through the load, to about 3.2 GB at the kill, with Unevictable just above it. This device'slmkdruns in minfree mode, which does not count swap, while MemAvailable counted that memory as available. Paging the game out with a root tool (process_madvise(MADV_PAGEOUT)) let it draw its menu, butlmkdstill killed the session. Not solved. - Sharing libraries between the clients is parked.