Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx afa479ee6d Merge main into feat/x86-64-steam-client 2026-10-10 13:17:20 -04:00
The412Banner d9b86fe9b6 x86-64 client: a Windows build on disk stays on Proton
Steam does not swap an installed build when a title's tool changes. A Linux
game whose Windows build was installed (it ran on a Proton before) was put
on DroidDeck direct, which was then handed Launcher.exe and ended in three
seconds (device: Borderlands 2). Getting the Linux build back would mean
downloading the game again, so such a game now stays a Windows game, on the
client's default Proton; one with its Linux build installed, or none yet,
goes to DroidDeck direct as before. The installed build is read from the
appmanifest's InstalledDepots against appinfo's depot oslists.
2026-10-07 15:20:01 -04:00
The412Banner d06468b802 App: read the Steam client the session runs
Everything in the app that read "the" Steam client's files read the arm64
client's, also with the x86-64 one picked: its session's Steam logs (the
session folder and Share logs got the other client's logs, hours old), the
game list, VAC diagnostics. One place now says which client's folder is in
use, SessionPrefs.activeSteamRoot (the x86-64 one keeps one library of its
own, so the selected library is left out for it). The download monitor and
the log redactor look at both clients, as Lossless already did.

Unchanged on purpose: the Components manager, the ARM64 Proton check and
the Proton extras use the arm64 client's folder, whose Protons the x86-64
client runs through its wrappers.
2026-10-07 14:32:10 -04:00
The412Banner 2cacc00047 x86-64 client: game requests and language to the running client
Two more places the session reached the arm64 client while the x86-64 one
ran: a game the app asked to start went to steamrtarm64/steam, which is not
the running client, and the language was written to the arm64 client's
registry. Both now follow the client the session started.
2026-10-07 14:30:25 -04:00
The412Banner c29362b53f x86-64 client: keep the arm64 compat pass off it
The session started droiddeck-steam-compat in x86-64 mode too. It attached
to the x86-64 client and mapped titles to droiddeck-proton-arm64 by Valve's
picks, Linux builds included (device: 112 titles; 35 of 39 Linux games), so
the client ran them as Windows games: Borderlands 2's installed Linux build
was swapped for the Windows one, another 1.1 GB. That name is also one the
x86-64 client ignores (its wrapper is -a64), so Windows titles left on it
had no tool.

- droiddeck-session: no droiddeck-steam-compat when the client is x86-64.
- droiddeck-steam-x64: droiddeck-proton-arm64 and Valve's proton_* entries
  count as automatic, so Linux games on them go to DroidDeck direct; picks
  made in the client's Properties still stay.
- steam-compatibility --x64: droiddeck-proton-arm64 entries are repointed to
  the client's default Proton like Valve's (test added).

On the device's settings: all 39 Linux games on DroidDeck direct, Windows
titles back on GE-Proton.
2026-10-07 14:19:28 -04:00
The412Banner f8c693b2dc x86-64 client: every Linux game on DroidDeck direct
Left alone, Steam runs a game's Linux build in its Linux runtime container,
which needs user namespaces no Android app has: the game does not start and
nothing says why. Only Left 4 Dead 2 was mapped to DroidDeck direct. The
launcher now reads the client's appinfo cache before each start and maps
every game with a Linux build to it; a title already on another tool keeps
it (BL_X64_DIRECT_APPS, L4D2 by default, is still always taken back).

Valve gives some of them a newer runtime (sniper/soldier in app_mappings:
Counter-Strike 2, Team Fortress 2, Dota 2, the Source titles). Scout's
libraries are too old for those, so DroidDeck direct runs them on the
rootfs (Ubuntu 24.04) as it is, and the rest with scout's run.sh as before.

Device data: 40 Linux titles found, 8 on the newer runtime.
2026-10-07 12:35:01 -04:00
The412Banner 87e1b7cf90 x86-64 client: no Linux x86 (FEX) tool of its own
droiddeck-fex is how the arm64 client runs x86 Linux titles; the x86-64
client runs them itself. Its mirrored wrapper ran the arm64 client's
droiddeck-fex when the client probed its tools after logon, which took that
for an x86 program wanting FEX, marked it wanted, and the arm64 client then
offered to install Valve's FEX on its next start. It is no longer mirrored,
and a wrapper from an earlier start is removed.
2026-10-07 10:40:05 -04:00
The412Banner c0b8ae3937 x86-64 client: never share an empty sign-in
A client that refuses its copied token ("Invalid Password") rewrites
local.vdf with an empty ConnectCache. Being the newest, it was copied over
the other client on the next start and signed that one out too. A sign-in
without a token is now never shared.
2026-10-07 10:06:05 -04:00
The412Banner 23b88aa667 x86-64 client: software interface with its own web helper
The session asks for the GPU on the arm64 web helper's behalf. On a fresh
install's first start the clients' builds are not known yet, so the client
keeps its x86 helper, which has no driver for this GPU: its GPU process died
at once (exit_code=11) and the client restarted the helper without end
(-startcount=15 in three minutes, no interface). With the x86 helper the GPU
arguments are now swapped for -cef-disable-gpu; BL_X64_CEF_GPU=1 keeps them.
2026-10-07 09:50:52 -04:00
The412Banner 81d7c202de x86-64 client: start on a fresh install
Before the clients' first run neither has its package manifest, and reading
the missing one ended droiddeck-steam-x64 --exec under set -e with rc=2 two
seconds after the session started it - no FEX, no client, no Steam logs,
back to the menu. A missing manifest now means no build to compare: the
client keeps its own web helper until both have run once.
2026-10-07 09:26:14 -04:00
The412Banner 1603e76c88 x86-64 client: its own game library, one shared sign-in
The two clients no longer share games. The x86-64 client stopped adding the
arm64 client's library folders (internal storage and the card) to its own:
shared, each could update, verify or switch the other's games, and a title
with a Linux build (run as that by the x86-64 client, as the Windows build by
the arm64 one) was switched and downloaded again whenever the other client
opened it (Left 4 Dead 2, 8.6 GB). Folders an earlier version shared are
removed from its lists at start; the games stay in the arm64 client's library.
The session's beat and redistributable seeding in x86-64 mode cover only its
own library.

The sign-in is the one thing shared: before either client starts, the newer
account list, login token (local.vdf) and auto-login name are copied to the
other (droiddeck-steam-x64 --sync-login, called by the session for the arm64
client too). Steam renews its token every few days and an old copy is
refused, so the newest is copied every start rather than once. Never while
either client runs; language, skin and other settings stay each client's.
The x86-64 client keeps its games on internal storage.
2026-10-07 07:29:09 -04:00
The412Banner 65f5d0e45f Optional x86-64 Steam client under FEX, brought up to date with main
The x86-64 Steam client branch (VAC-secured servers) and its VAC diagnostics,
replayed onto current main as one commit. Originals, in order (kept at
refs/backup/20261007/x86-64-steam-client-before-rebase):
  c8f4441 Optional x86-64 Steam client under FEX (VAC-secured servers)
  43fcfe6 x86-64 client: pin the x86 rootfs image's sha256
  25ca971 x86-64 client: the arm64 client's Proton tools and one shared library
  020a0b7 x86-64 client: arm64 Proton wrappers named *a64*
  0e11894 x86-64 client: arm64 game overlay for arm Protons, GE-Proton by default
  a0441ce x86-64 client: the arm64 web helper, and the client on the GPU
  6811f5d x86-64 client: the arm64 client's session steps, startup movie, Decky
  faa5803 x86-64 client: the library beat and the shortcuts after exit
  95470a0 x86-64 client: Deck mode by default
  2fa4bf2 x86-64 client: install the arm64 client's parts first
  0e1cdcd x86-64 client: redirect the web helper's exec instead of editing Steam
  c3401cc x86-64 client: SteamRT3 opt-in marker, and the arm64 parts on its build
  99e92da bannerlator-steam-install: mark the arm64 client's programs executable
  209a0ff bannerlator-steam-install --sync: mark the programs executable when already current too
  d1074a5 x86-64 client: start signed in with the arm64 client's account

Adjusted for what main changed since the branch last synced:
- Scripts follow main's droiddeck-* names: droiddeck-steam-x64,
  droiddeck-x64-arm-proton and droiddeck-x64-arm-webhelper, and every call
  into the renamed session scripts.
- bannerlator-steam-compat is gone; its additions move to
  steam-compatibility: the VAC diagnostics hook (-condebug for Source titles)
  in both launcher templates, and an --x64 mode (x64_repoint) that points the
  x86-64 client's Valve Proton entries at its arm64 wrapper. The arm64
  client keeps main's mapping, including its handling of titles installed
  while the client runs.
- The x86 libblsession and libfakeinput come from main's
  build-x86-preloads.sh (staged under /usr/local/lib/droiddeck-fex); the
  branch's own host builds are dropped. PRELOAD_CACHE_VERSION v6.
- droiddeck-steam-install keeps main's repair and channel handling next to
  --sync, and the system bus config moves to /usr/local/share/droiddeck.
- The tracer-hiding caller check sits beside main's NTSync open in opens.c.
- tools/tests/test_steam_compat_x64.py covers the --x64 mapping.

Still opt-in (Steam settings > Client) and not device-tested on this base.
2026-10-07 06:37:58 -04:00
40 changed files with 2231 additions and 68 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
name: Build diagnostics
on:
workflow_dispatch:
push:
paths:
- 'tools/diag/**'
- '.github/workflows/build-diag.yml'
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq gcc-multilib >/dev/null
mkdir -p out
gcc -m32 -O1 -fPIC -shared -fno-omit-frame-pointer -o out/libsegvlog-i386.so tools/diag/segvlog.c -ldl
gcc -O1 -fPIC -shared -fno-omit-frame-pointer -o out/libsegvlog-x86_64.so tools/diag/segvlog.c -ldl
file out/*
- uses: actions/upload-artifact@v4
with:
name: diag
path: out/
+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 (droiddeck-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 droiddeck-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
+1 -1
View File
@@ -28,7 +28,7 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v3
PRELOAD_CACHE_VERSION: v6
jobs:
build:
@@ -342,6 +342,7 @@ class MainActivity : ComponentActivity() {
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var steamClientArch by mutableStateOf("arm64")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
@@ -366,6 +367,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 agentCommands by mutableStateOf(false)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
@@ -563,7 +565,8 @@ class MainActivity : ComponentActivity() {
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
@@ -698,6 +701,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)
},
onAgentCommands = {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
@@ -1266,6 +1273,7 @@ class MainActivity : ComponentActivity() {
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) 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,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
@@ -1345,6 +1353,7 @@ class MainActivity : ComponentActivity() {
},
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
@@ -1479,6 +1488,7 @@ class MainActivity : ComponentActivity() {
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamChannel = SessionPrefs.steamChannel(this)
steamClientArch = SessionPrefs.steamClientArch(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
@@ -1568,6 +1578,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
@@ -121,6 +121,8 @@ object LogRedactor {
fun learnFromRuntime(root: java.io.File) {
learnOwnAddresses(java.io.File(root, "etc/droiddeck-net"))
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
// The x86-64 client's own copy (droiddeck-steam-x64), which can name an account the other has not.
learnAccounts(java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf"))
}
/** This device's public addresses as patterns (see [learnOwnAddresses]); empty until learned. */
@@ -88,10 +88,12 @@ object Library {
/** Steam's library roots visible to this launcher: its private default plus the selected library. */
private fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val root = SessionPrefs.activeSteamRoot(context)
// The x86-64 client keeps one library of its own; the selected library is the arm64 client's.
val x64 = SessionPrefs.steamClientArch(context) == "x86_64"
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
GameStorage.effective(context)?.takeUnless { x64 }?.let { File(it.path) to it.label },
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
}
@@ -113,7 +115,7 @@ object Library {
}
fun steamGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val root = SessionPrefs.activeSteamRoot(context)
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val roots = libraries.map { it.first }
@@ -167,6 +167,8 @@ object Lossless {
private fun steamDll(context: Context): File? {
val libraries = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
// The x86-64 client keeps its own library (droiddeck-steam-x64).
File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
GameStorage.effective(context)?.path?.let(::File),
) + SessionPrefs.addedGamesDirs(context).map(::File)
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
@@ -60,6 +60,7 @@ object SessionArtifacts {
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
val record = Record(dir)
copySteamLogs(context, dir, record)
VacDiagnostics.collect(context, dir)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
markScrubbed(scrubTree(record))
@@ -230,7 +231,7 @@ object SessionArtifacts {
/** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */
private fun copySteamLogs(context: Context, dir: File, record: Record) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
val logs = File(SessionPrefs.activeSteamRoot(context), "logs")
if (!logs.isDirectory) return
val out = File(dir, "steam").apply { mkdirs() }
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
@@ -277,7 +278,10 @@ object SessionArtifacts {
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
val record = Record(dir)
if (newest) copySteamLogs(context, dir, record)
if (newest) {
copySteamLogs(context, dir, record)
VacDiagnostics.collect(context, dir)
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
markScrubbed(scrubTree(record))
@@ -86,6 +86,10 @@ object SessionFiles {
"usr/local/bin/droiddeck-open-url" to "usr/local/bin/droiddeck-open-url",
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
// Experimental: Valve's x86-64 client under FEX (desktop menu entry and the Steam client choice).
"usr/local/bin/droiddeck-steam-x64" to "usr/local/bin/droiddeck-steam-x64",
"usr/local/bin/droiddeck-x64-arm-proton" to "usr/local/bin/droiddeck-x64-arm-proton",
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
// Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher.
"usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap",
"usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak",
@@ -116,7 +116,7 @@ object SessionLogShare {
*/
private fun liveSteamLogs(context: Context, folder: File): List<File> {
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
val logs = File(SessionPrefs.activeSteamRoot(context), "logs")
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
}
@@ -514,6 +514,18 @@ object SessionPrefs {
prefs(context).edit().putBoolean("storageDiagnostics", 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 ────────────────────────────────────────────────────────────────────
/**
@@ -604,6 +616,27 @@ 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, droiddeck-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()
}
/**
* The Steam folder of the client [steamClientArch] picks: its logs, library and settings. What
* reads one client's files for "the" Steam client goes through this, so the x86-64 client's
* session shows and reports its own (droiddeck-steam-x64 keeps it under ~/.droiddeck-x64).
*/
fun activeSteamRoot(context: Context): java.io.File = java.io.File(
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context),
if (steamClientArch(context) == "x86_64") "root/.droiddeck-x64/.local/share/Steam" else "root/.local/share/Steam",
)
/** Whether opening DroidDeck starts a Steam session instead of showing the front end. */
fun runSteamAtStartup(context: Context): Boolean = prefs(context).getBoolean("runSteamAtStartup", false)
@@ -329,6 +329,7 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
VacDiagnostics.arm(this)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
// The compat tool asks for an Epic game's sign-in code at every launch, the client's Play button included.
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
@@ -637,6 +638,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_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
}
@@ -55,7 +55,8 @@ internal class SteamDownloadMonitor {
}
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
// Either client's (the x86-64 one keeps its own, droiddeck-steam-x64): the newer log is the one in use.
val log = STEAM_HOMES.map { File(runtimeRoot, "$it/logs/content_log.txt") }.maxByOrNull { it.lastModified() }!!
if (!log.isFile) return false
if (log.length() < contentLogOffset) {
contentLogOffset = 0L
@@ -100,12 +101,15 @@ internal class SteamDownloadMonitor {
}
private fun manifests(runtimeRoot: File): List<File> {
val steamRoots = linkedSetOf(File(runtimeRoot, "root/.local/share/Steam"))
val primary = steamRoots.first()
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
runCatching {
PATH.findAll(libraryFolders.readText()).forEach { match ->
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
val steamRoots = linkedSetOf<File>()
for (home in STEAM_HOMES) {
val primary = File(runtimeRoot, home)
steamRoots += primary
val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
runCatching {
PATH.findAll(libraryFolders.readText()).forEach { match ->
steamRoots += File(runtimeRoot, match.groupValues[1].removePrefix("/"))
}
}
}
return steamRoots.flatMap { root ->
@@ -132,6 +136,8 @@ internal class SteamDownloadMonitor {
const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
/** The arm64 client's Steam folder and the x86-64 client's (droiddeck-steam-x64). */
val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
}
@@ -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
* (droiddeck-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 = SessionPrefs.activeSteamRoot(context)
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())
}
@@ -92,6 +92,7 @@ class FrontEndState(
val frameGenLabel: String,
val romsDir: String?,
val logsEnabled: Boolean,
val vacDiagnostics: Boolean = false,
/** Setup's Debugging tools toggle (AgentAccess): off unless the user turns it on. */
val agentCommands: Boolean = false,
val steamGames: List<Library.SteamGame>,
@@ -193,6 +194,7 @@ class FrontEndActions(
val onFiles: () -> Unit,
val onBrowseFiles: (File) -> Unit = {},
val onLogs: () -> Unit,
val onVacDiagnostics: () -> Unit = {},
val onAgentCommands: () -> Unit = {},
val onShareLogs: () -> Unit = {},
val onClearLogs: () -> Unit = {},
@@ -310,6 +310,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() }
ToggleRow(host, "agent-commands", stringResource(R.string.setup_agent_commands), null, s.agentCommands) { a.onAgentCommands() }
ActionRow(stringResource(R.string.setup_latest_logs), stringResource(R.string.drawer_logs_hint), stringResource(R.string.drawer_share_logs), a.onShareLogs,
progress = com.droiddeck.launcher.session.SessionLogShare.progress)
@@ -88,6 +88,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,
val steamRepairQueued: Boolean = false,
@@ -141,6 +142,7 @@ class ModeSettingsActions(
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamChannel: (String) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onSteamClientArch: (String) -> Unit = {},
val onSteamDeckMode: (Boolean) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
@@ -398,6 +400,11 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
) {
SecondaryButton(stringResource(R.string.mode_steam_repair_button), enabled = !s.steamRepairQueued, onClick = a.onSteamRepair)
}
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 && tab == ModeSettingsTab.STEAM && s.wifiDiscovery != null) SettingsGroup(stringResource(R.string.mode_network)) {
val hint = when {
+10 -1
View File
@@ -297,7 +297,14 @@
<string name="mode_steam_repair_hint">Vuelve a descargar el cliente de Steam la próxima vez que se inicie Steam. Úsalo si Steam se queda en la pantalla de carga. Se conservan los juegos, la sesión iniciada y los ajustes.</string>
<string name="mode_steam_repair_queued">El cliente de Steam se volverá a descargar la próxima vez que se inicie Steam.</string>
<string name="mode_steam_repair_button">Reparar</string>
<string name="mode_added_art">Ilustraciones</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>
<string name="mode_games_folder_another">Otra carpeta de juegos</string>
<string name="mode_sgdb_key">Clave API de SteamGridDB</string>
<string name="mode_sgdb_key_yours">Tu clave</string>
<string name="mode_sgdb_edit">Editar…</string>
@@ -602,6 +609,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_agent_commands">Herramientas de depuración</string>
<string name="setup_latest_logs">Últimos registros de sesión</string>
<string name="setup_saved_logs">Registros de sesión guardados</string>
+10 -3
View File
@@ -307,14 +307,19 @@
<string name="mode_steam_repair_hint">Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.</string>
<string name="mode_steam_repair_queued">The Steam client will be downloaded again the next time Steam starts.</string>
<string name="mode_steam_repair_button">Repair</string>
<string name="mode_added_art">Artwork</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>
<string name="mode_games_folder_another">Another games folder</string>
<string name="mode_sgdb_key">SteamGridDB API key</string>
<string name="mode_sgdb_key_yours">Your key</string>
<string name="mode_sgdb_edit">Edit…</string>
<string name="mode_added_art_hint">Fills in missing art from Steam, then SteamGridDB. Your own cover.jpg, hero.jpg, logo.png or icon.png in the game folder takes priority.</string>
<string name="summary_remove_game">Remove %1$s</string>
<string name="mode_added_remove_confirm">Remove?</string>
<string name="games_add">Add game</string>
<string name="games_add_title">Add a game:</string>
<string name="games_pick_exe_title">Pick the exe:</string>
<string name="games_add_unreachable">This location is not available in the session</string>
@@ -615,6 +620,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_agent_commands">Debugging tools</string>
<string name="setup_latest_logs">Latest session logs</string>
<string name="setup_saved_logs">Saved session logs</string>
@@ -24,6 +24,21 @@ class SteamDownloadMonitorTest {
}
}
@Test fun followsTheX8664ClientsOwnLibrary() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
val steam = File(root, "root/.droiddeck-x64/.local/share/Steam")
val manifest = manifest(steam, "49520", downloaded = 10, total = 100, flags = 6)
val monitor = SteamDownloadMonitor()
assertFalse(monitor.poll(root, now = 1_000L))
manifest.writeText(appManifest(downloaded = 20, total = 100, flags = 6))
assertTrue(monitor.poll(root, now = 3_000L))
} finally {
root.deleteRecursively()
}
}
@Test fun findsDownloadsInAdditionalSteamLibraries() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
+98
View File
@@ -7,6 +7,104 @@ 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.
Evening (device-proven unless noted):
- **Play button with the x86-64 client works** (Steam settings > Client > Steam client: x86-64).
Deck mode is skipped for it: with -steamos3 its SteamOS audio manager aborts on the runtime's
PulseAudio 13 (pa_operation_get_state assertion).
- **Left 4 Dead 2 runs in the x86-64 client and joined a live online game** (Quick Match, Swamp
Fever, other players, 138 ms) - the server type that refused the arm64 client with "Connection
Blocked ... VAC secured server". Not yet hard proof of VAC (no client log line; -condebug added
for the server's secure flag). Software rendering: ~11 fps.
- What the launch needed: "DroidDeck direct", a compatibility tool running native Linux games with
Valve's scout library runtime instead of the Steam Linux Runtime container; a stand-in
steam-runtime-launch-client (the client, believing itself inside the SteamRT container, starts
every game "alongside Steam" through it, and Valve's needs a launcher service on a session bus);
i386 builds of the session shim (the i386 controller reader crashes the game and is left out for
now). A steam:// URL written to the launch dir now reaches the running client.
- **FEX with host thunks** (fex-2609-r3): FEX's Vulkan/GL/EGL/DRM/Wayland/ALSA thunks, 64- and
32-bit guests, guest halves cross-compiled against an Arch x86_64/multilib sysroot. The x86 client
uses them when installed (GPU mode, the arm64 client's ANGLE-on-Vulkan flags) - built, not yet
device-tested.
- The proot fast path now stands down in FEX only for 32-bit programs (it broke FEX's 32-bit mode).
- **On the GPU, VAC proven (19:40):** games get FEX's host thunks and the session's Turnip/Zink
settings (the client and its browser stay in software: with the thunks the browser's GPU process
dies and the client never opens a window). Left 4 Dead 2 in The Passing on an online server:
51 fps (was ~11 in software). The server answered the A2S info query as "Valve Left4Dead 2 US
East Server", VAC=1 - an official VAC-secured server, which the arm64 client is refused.
- The crash that held this up was not the GPU: the 32-bit controller reader was still preloaded
(FEX falls through to the session's own i386 copy), and the game's SDL2 strdup()s a NULL udev
attribute of its fake devices. Found with a SIGSEGV logger (tools/diag/segvlog.c).
- The Linux FEX as a component file: /sdcard/Download/FEX-2609-r2-x86-64-guest.wcp (type
FEX-Guest); the Components section does not know that type yet.
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-10-09 - `feat/games-add-edit`: add games from the Games tab, edit them like Steam's Properties
- **Steam ⚙ settings:** the Games section is gone (it was a feature, not a setting). Games
+83
View File
@@ -0,0 +1,83 @@
// Diagnostic: on SIGSEGV/SIGBUS/SIGILL, write the faulting address, the instruction pointer and a
// frame-pointer backtrace (module+offset, symbol) to $SEGVLOG_FILE, then die as before. Later
// SIGSEGV handlers (the game's breakpad) are refused so this one sees the fault first.
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ucontext.h>
#include <unistd.h>
static int (*real_sigaction)(int, const struct sigaction *, struct sigaction *);
static int out = -1;
static void line(const char *tag, void *pc) {
char buf[512];
Dl_info di;
if (pc && dladdr(pc, &di) && di.dli_fname)
snprintf(buf, sizeof buf, "%s %p %s+0x%lx %s\n", tag, pc, di.dli_fname,
(unsigned long)((char *)pc - (char *)di.dli_fbase), di.dli_sname ? di.dli_sname : "?");
else
snprintf(buf, sizeof buf, "%s %p ?\n", tag, pc);
if (out >= 0) write(out, buf, strlen(buf));
}
static void handler(int sig, siginfo_t *si, void *ctx) {
ucontext_t *uc = ctx;
char buf[160];
#if defined(__i386__)
void *pc = (void *)uc->uc_mcontext.gregs[REG_EIP];
void **fp = (void **)uc->uc_mcontext.gregs[REG_EBP];
#else
void *pc = (void *)uc->uc_mcontext.gregs[REG_RIP];
void **fp = (void **)uc->uc_mcontext.gregs[REG_RBP];
#endif
snprintf(buf, sizeof buf, "=== pid %d signal %d code %d fault addr %p\n", getpid(), sig, si->si_code, si->si_addr);
if (out >= 0) write(out, buf, strlen(buf));
line("pc", pc);
// Leaf functions (strlen, memcpy) keep no frame: the caller's return address is on the stack.
#if defined(__i386__)
void **sp = (void **)uc->uc_mcontext.gregs[REG_ESP];
#else
void **sp = (void **)uc->uc_mcontext.gregs[REG_RSP];
#endif
for (int i = 0; i < 48 && sp; i++) {
Dl_info di;
if (sp[i] && dladdr(sp[i], &di) && di.dli_fname) line(" sp", sp[i]);
}
for (int i = 0; i < 24 && fp && ((unsigned long)fp & 3) == 0; i++) {
void *ret = fp[1];
if (!ret) break;
line(" at", ret);
void **next = (void **)fp[0];
if (next <= fp) break;
fp = next;
}
signal(sig, SIG_DFL);
}
int sigaction(int sig, const struct sigaction *act, struct sigaction *old) {
if (!real_sigaction) real_sigaction = dlsym(RTLD_NEXT, "sigaction");
if (act && (sig == SIGSEGV || sig == SIGBUS || sig == SIGILL) && act->sa_sigaction != handler) {
if (old) real_sigaction(sig, NULL, old);
return 0;
}
return real_sigaction(sig, act, old);
}
__attribute__((constructor)) static void init(void) {
const char *f = getenv("SEGVLOG_FILE");
if (!f) return;
out = open(f, O_WRONLY | O_CREAT | O_APPEND, 0644);
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
real_sigaction = dlsym(RTLD_NEXT, "sigaction");
real_sigaction(SIGSEGV, &sa, NULL);
real_sigaction(SIGBUS, &sa, NULL);
real_sigaction(SIGILL, &sa, NULL);
}
+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 droiddeck-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
@@ -65,6 +65,17 @@ export BL_DESKTOP=1
ln -sf /usr/local/bin/droiddeck-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null
ln -sf /usr/local/bin/droiddeck-steam-shim /usr/local/bin/steam 2>/dev/null
python3 /usr/local/bin/droiddeck-desktop-games 2>&1 | sed 's/^/[games] /'
# EXPERIMENT: Valve's x86-64 client under FEX (droiddeck-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/droiddeck-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.
@@ -368,10 +368,48 @@ case $mode in
esac
;;
steam)
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
droiddeck-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 (droiddeck-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
# The arm64 client's own files are part of this one: its web helper draws the interface, and
# its game overlay and steamclient.so serve the arm64 Protons the x86-64 client starts.
step "checking the Steam client's arm64 parts (first run downloads them, ~1-2 min)"
droiddeck-steam-install || exit $?
# Its web helper only talks to a client of its own build; the x86-64 client updates itself on
# every start, the arm64 one only when it runs.
droiddeck-steam-install --sync "${BL_STEAM_CHANNEL:-steamdeck_publicbeta}" || step "could not update the arm64 client's parts; the x86-64 client keeps its own web helper"
step "checking the x86-64 Steam client (FEX, experimental; the first run downloads ~2 GB)"
droiddeck-steam-x64 --prepare || exit $?
step "x86-64 Steam client ready"
steam_root=${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam
steam_client=(droiddeck-steam-x64 --exec)
# The client's browser is the arm64 client's web helper (droiddeck-x64-arm-webhelper), on the
# GPU with the same flags. The x86 one (BL_X64_X86_WEBHELPER=1) stays in software: its GPU
# process needs libgbm, which FEX does not thunk (device: GPU process restart loop, no first
# frame); BL_X64_CEF_GPU=1 tries it anyway.
if [ "${BL_X64_X86_WEBHELPER:-0}" != 1 ] ||
{ [ "${BL_X64_CEF_GPU:-0}" = 1 ] && droiddeck-steam-x64 --gpu; }; then
steam_cef=(-cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan)
else
steam_cef=(-cef-disable-gpu)
fi
else
step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)"
droiddeck-steam-install || exit $?
step "Steam client ready"
steam_root=$XDG_DATA_HOME/Steam
# One sign-in for both clients: a newer one from the x86-64 client, when it has been used.
if [ -f "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam/local.vdf" ]; then
droiddeck-steam-x64 --sync-login || true
fi
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,
@@ -379,13 +417,26 @@ case $mode in
# arguments to droiddeck-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.
# The x86-64 client runs the arm64 client's Protons, so they go there; its default is GE-Proton
# (Proton Experimental leaked Xwayland's window buffers until the device ran out of memory), so
# one is fetched when the arm64 client has none, as the arm64 side fetches its own Proton.
_bl_proton_root=$steam_root
if [ "$steam_x64" = 1 ]; then
_bl_proton_root=$XDG_DATA_HOME/Steam
if ! ls -d "$_bl_proton_root"/compatibilitytools.d/GE-Proton*-aarch64 >/dev/null 2>&1 &&
! grep -q '^ge' "$HOME/.bl-proton-extra" 2>/dev/null; then
echo ge >> "$HOME/.bl-proton-extra"
fi
fi
if [ -s "$HOME/.bl-proton-extra" ]; then
_bl_left=$HOME/.bl-proton-extra.left
: > "$_bl_left"
_bl_got_proton=0
while read -r _bl_extra || [ -n "$_bl_extra" ]; do
case ${_bl_extra# } in ""|"#"*) continue ;; esac
step "installing Proton '$_bl_extra' (large download, several minutes)"
if droiddeck-proton-extra "$steam_root" $_bl_extra; then
if droiddeck-proton-extra "$_bl_proton_root" $_bl_extra; then
_bl_got_proton=1
step "installed Proton '$_bl_extra'"
else
step "Proton '$_bl_extra' failed; will retry next session"
@@ -393,6 +444,10 @@ case $mode in
fi
done < "$HOME/.bl-proton-extra"
mv -f "$_bl_left" "$HOME/.bl-proton-extra"
# The x86-64 client's wrappers are made at --prepare: once more for the new one.
if [ "$steam_x64" = 1 ] && [ "$_bl_got_proton" = 1 ]; then
droiddeck-steam-x64 --prepare || true
fi
fi
# steam:// URLs left in ~/.bl-steam-urls are handed to the client on its command line. The app
# writes one when the user asks for a Proton that is one of Valve's own depots rather than a
@@ -423,7 +478,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
@@ -435,6 +490,7 @@ case $mode in
) &
fi
if [ "$steam_x64" = 0 ]; then
step "registering the app's game library with Steam"
droiddeck-steam-library "$steam_root" || true
step "setting up the ARM64 Proton compatibility tool"
@@ -451,9 +507,25 @@ 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.
droiddeck-seed-redists "$steam_root" || true
else
# The x86-64 client: its own library (the clients share the sign-in, not games; see
# droiddeck-steam-x64), and its Windows titles run the arm64 Protons through wrappers. A per-app
# entry the client aims at one of Valve's x86 Protons is pointed at the wrapper it defaults to,
# the app's own games are its shortcuts too, and redistributables are marked in its prefixes.
_bl_x64_tool=$(droiddeck-steam-x64 --default-tool)
if [ -n "$_bl_x64_tool" ]; then
step "pointing the x86-64 client's Windows titles at $_bl_x64_tool"
BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 || true
if [ -n "${BL_ADDED_GAMES:-}" ]; then
step "adding the app's own games to the x86-64 client's library"
BL_COMPAT_TOOL=$_bl_x64_tool droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
fi
droiddeck-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's session script (device-proven 2026-09-22). The client decides
@@ -473,7 +545,7 @@ case $mode in
steam-compatibility "$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
@@ -508,7 +580,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
@@ -598,13 +670,10 @@ case $mode in
esac
done
) &
# The library beat. Both helpers are whole programs under proot (the seeder some forty of them,
# the compat one a python start and a parse of the client's config), and nearly every beat has
# nothing new for them: a burst of thousands of proot stops every quarter minute, game loads
# included. Each runs only when what it reads has changed, which bash answers without starting
# anything ([ -nt ], globs), and every five minutes regardless. The seeder acts on a prefix (or
# Proton's template for them) it has not marked, and on new install scripts, which arrive with a
# manifest; the compat pass on a new manifest, depot or tool, or the client rewriting config.vdf.
# The library beat avoids repeated helper starts and proot work during game loads. It checks
# changed libraries and runs every five minutes as a backstop. The active x86-64 client has its
# own library and uses the x64 compatibility mapping; the ARM64 client uses Valve's mapping.
_bl_beat_roots=("$steam_root")
(
beat=0
compat_stamp=$HOME/.bl-compat-beat
@@ -637,6 +706,33 @@ 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 droiddeck-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=
for _r in "${_bl_beat_roots[@]}" /mnt/droiddeck-sd; do
[ -f "$_r/steamapps/appmanifest_$app.acf" ] && { m=$_r/steamapps/appmanifest_$app.acf; break; }
done
[ -n "$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))
@@ -648,7 +744,22 @@ case $mode in
fi
if compat_due; then
: > "$compat_stamp"
steam-compatibility "$steam_root" >/dev/null 2>&1 || true
if [ "$steam_x64" = 1 ]; then
[ -n "${_bl_x64_tool:-}" ] &&
BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 --live >/dev/null 2>&1 || true
else
steam-compatibility "$steam_root" >/dev/null 2>&1 || true
fi
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
) &
@@ -761,6 +872,8 @@ BUSCONF
# reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used
# only when its recorded architecture says so and droiddeck-fex has FEX and its runtime ready.
decky_loader=$HOME/homebrew/services/PluginLoader
# The x86-64 client's browser is the arm64 client's (droiddeck-x64-arm-webhelper), so the loader
# finds it on the same debugging port.
if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then
decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown)
if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && /usr/local/bin/droiddeck-fex prepare >/dev/null; }; then
@@ -806,9 +919,10 @@ BUSCONF
export BL_CDP_GUARD=$BL_CDP_PORT
echo "== steam: devtools port $BL_CDP_PORT, guest processes only"
fi
# The agent bridge drives the client through it (droiddeck-agent's cdp requests).
# The agent bridge drives the client through its CDP port.
[ -n "${BL_LAUNCH_DIR:-}" ] && mkdir -p "$BL_LAUNCH_DIR/agent" && echo "$BL_CDP_PORT" > "$BL_LAUNCH_DIR/agent/cdp-port"
droiddeck-steam-compat "$steam_root" 2>&1 &
# The ARM64 helper maps Linux titles to ARM64 Proton; the x86-64 client has its own mapping.
[ "$steam_x64" = 1 ] || droiddeck-steam-compat "$steam_root" 2>&1 &
# The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this).
# BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the
# desktop's launchers ask for; gamescope shows one window at a time and the game takes over.
@@ -822,7 +936,12 @@ 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
# The x86-64 client too: with -steamos3 its SteamOS audio manager hands libpulse an operation a
# failed request never returned, and libpulse aborted the client right after start; the session
# preload answers it (preload/pulseguard.c). Device: Deck mode, logged on, 140 fps menus.
# BL_X64_DECK=0 starts the x86-64 client without the Deck flags.
if [ "${BL_STEAMDECK:-0}" = 1 ] && [ "${BL_STEAM_UI:-}" != desktop ] &&
{ [ "$steam_x64" = 0 ] || [ "${BL_X64_DECK:-1}" != 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
@@ -834,7 +953,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" \
@@ -856,14 +975,14 @@ BUSCONF
_bl_lang=()
[ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE")
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
droiddeck-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
@@ -878,7 +997,7 @@ BUSCONF
done
) &
fi
echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -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[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -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
@@ -915,11 +1034,21 @@ BUSCONF
rm -f "$BL_LAUNCH_DIR/steam-stop" "$BL_LAUNCH_DIR/steam-game" "$BL_LAUNCH_DIR/steam-game.tmp"
(
while :; do
# A steam:// URL left in $BL_LAUNCH_DIR/steam-url is handed to the running client (the
# client binary started with a URL passes it on and exits): how a game is started, or
# anything else the client takes a URL for, without touching the screen.
if [ -s "$BL_LAUNCH_DIR/steam-url" ]; then
_bl_req=$(head -n 1 "$BL_LAUNCH_DIR/steam-url")
rm -f "$BL_LAUNCH_DIR/steam-url"
case $_bl_req in
steam://*) echo "== steam: handing the client $_bl_req"; "${steam_client[@]}" "$_bl_req" >/dev/null 2>&1 & ;;
esac
fi
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
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
if [ -f "$BL_LAUNCH_DIR/steam-game" ]; then
@@ -933,7 +1062,8 @@ BUSCONF
[ "$game_id_length" -eq 20 ] && [[ "$game_id" < "18446744073709551616" ]];
}; then
echo "== steam: launching requested game $game_id in the running client"
"$steam_root/steamrtarm64/steam" "steam://rungameid/$game_id" >/dev/null 2>&1 &
# Send the request to the active client, then bring its window forward.
"${steam_client[@]}" "steam://rungameid/$game_id" >/dev/null 2>&1 &
[ -n "${forward_pid:-}" ] && kill "$forward_pid" 2>/dev/null
steam_bring_forward &
forward_pid=$!
@@ -960,7 +1090,12 @@ BUSCONF
fi
# Between starts only: the client holds registry.vdf in memory and writes it back on exit.
if [ -n "${BL_STEAM_LANGUAGE:-}" ]; then
droiddeck-steam-language "$HOME" "$BL_STEAM_LANGUAGE" || true
# The registry of the client that starts: the x86-64 one has its own home.
if [ "$steam_x64" = 1 ]; then
droiddeck-steam-language "${BL_X64_HOME:-$HOME/.droiddeck-x64}" "$BL_STEAM_LANGUAGE" || true
else
droiddeck-steam-language "$HOME" "$BL_STEAM_LANGUAGE" || true
fi
fi
step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}"
desktop_ui_pid=
@@ -968,22 +1103,25 @@ BUSCONF
/usr/local/bin/droiddeck-steam-desktop-ui &
desktop_ui_pid=$!
fi
"$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -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[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -clientbeta "$steam_channel" \
-no-cef-sandbox "${steam_cef[@]}" "$@"
rc=$?
if [ -n "$desktop_ui_pid" ]; then
kill "$desktop_ui_pid" 2>/dev/null || true
wait "$desktop_ui_pid" 2>/dev/null || true
fi
echo "== steam exited rc=$rc"
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
collect_steam_logs
if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then
/usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true
fi
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
if [ "$steam_x64" = 0 ]; then
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
elif [ -n "${_bl_x64_tool:-}" ]; then
BL_COMPAT_TOOL=$_bl_x64_tool droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
fi
if [ -f "$HOME/.bl-stop-requested" ]; then
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
break
@@ -992,6 +1130,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.
[ "$steam_x64" = 1 ] || droiddeck-steam-compat "$steam_root" >/dev/null 2>&1 || true
set --
continue
fi
@@ -1,6 +1,14 @@
#!/bin/bash
# Fetches the native arm64 Steam client from Valve's client-update CDN into ~/.local/share/Steam.
# The client is not redistributable, so the runtime ships this script instead of the client.
#
# droiddeck-steam-install install once
# droiddeck-steam-install --sync CHANNEL bring the installed client to CHANNEL's current build
#
# --sync is for the x86-64 client option (droiddeck-steam-x64): it runs this client's web helper,
# which only talks to a client of its own build, and the x86-64 client updates itself on every start
# while this one only updates when it runs (device: 1790721607 against 1790904859, the helper restarted
# every 10 s and the interface never came up).
set -eu
cdn=https://client-update.fastly.steamstatic.com
steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta}
@@ -39,12 +47,25 @@ if [ -n "$repair" ]; then
[ "$(sha1sum "$part" | cut -d' ' -f1)" = "$sum" ] || rm -f "$part"
done
fi
[ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0
sync=
if [ "${1:-}" = --sync ]; then
case "${2:-}" in publicbeta|steamdeck_publicbeta) sync=$2 ;; *) sync=$steam_channel ;; esac
manifest=steam_client_${sync}_linuxarm64
fi
[ -z "$sync" ] && [ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
curl -fsSL "$cdn/$manifest" -o "$work/manifest"
version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
if [ -n "$sync" ]; then
installed=$(awk -F'"' '$2 == "version" { print $4; exit }' "$steam_root/package/$manifest.manifest" 2>/dev/null || true)
if [ -x "$steam_root/steamrtarm64/steam" ] && [ "$installed" = "$version" ]; then
chmod -R a+x "$steam_root/steamrtarm64" "$steam_root/linuxarm64" 2>/dev/null || true
exit 0
fi
echo "== STEP $(date -u +%H:%M:%S) updating the arm64 Steam client to $version (was ${installed:-none}) for the x86-64 client"
fi
# Every component block: its name, then the plain zip's file name and sha256.
awk -F'"' '
/^\t"/ && NF >= 3 && $4 == "" { block = $2 }
@@ -74,7 +95,16 @@ find "$steam_root" -depth -name '*\\*' | while read -r odd; do
mv -f "$odd" "$fixed"
fi
done
echo "$steam_channel" > "$steam_root/package/beta"
# The zips carry no permissions: the client marks its own programs executable when it runs, but the
# x86-64 client option runs this client's web helper without running this client (device: "Permission
# denied" on steamrtarm64/steamwebhelper after an update), so they are marked here.
chmod -R a+x "$steam_root/steamrtarm64" "$steam_root/linuxarm64" 2>/dev/null || true
if [ -n "$sync" ]; then
# The client's own record of what it has, so it does not take the new files for an update to undo.
cp -f "$work/manifest" "$steam_root/package/$manifest.manifest"
else
echo "$steam_channel" > "$steam_root/package/beta"
fi
ln -sfn "$steam_root/steamrtarm64" "$steam_root/steamrtarm32"
chmod +x "$steam_root/steamrtarm64/steam" 2>/dev/null || true
# What Valve's steam.sh sets up before it runs the client (it has no arm64 branch itself).
+897
View File
@@ -0,0 +1,897 @@
#!/bin/bash
# EXPERIMENT, opt-in: Valve's x86-64 Linux Steam client under FEX, beside the default arm64 one.
# droiddeck-steam-x64 [steam:// URL or client arguments] run it (the desktop menu entry)
# droiddeck-steam-x64 --prepare install and set up, then exit
# droiddeck-steam-x64 --sync-login copy the newer sign-in to the other client
# droiddeck-steam-x64 --exec ARGS... run the client binary with ARGS in
# its environment (droiddeck-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, config
# and library, and never touches the arm64 client's games. The sign-in is the one thing shared
# (x64_sync_login), so the user signs in once. 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-r3
FEX_SHA256=cf3a87805ec51f8c42e197ff4883575e5bdec5247abe4c0beefb1796db358b5f
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
ROOTFS_SHA256=2854b06d3ff1b8f6e526135bfb6dd5b7b30ab3ab73e79ae933a3d9fed959a178
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"; }
# The arm64 Proton wrapper this client maps Windows titles to: the newest GE-Proton, else the arm64
# client's own (droiddeck-proton-arm64, Proton Experimental). Prints its folder; --default-tool
# prints its tool name, which the session hands to droiddeck-steam-compat and the shortcuts.
x64_default_dir() {
local d
for d in $(ls -d "$STEAM_X64"/compatibilitytools.d/GE-Proton*-aarch64 2>/dev/null | sort -V -r); do
[ -f "$d/.droiddeck-arm64-proton" ] && { echo "$d"; return; }
done
echo "$STEAM_X64/compatibilitytools.d/droiddeck-proton-arm64"
}
x64_default_tool() {
sed -n 's/^[[:space:]]*"\([^"]*\)"[[:space:]]*$/\1/p' "$(x64_default_dir)/compatibilitytool.vdf" 2>/dev/null |
grep -v -x -e compatibilitytools -e compat_tools | head -n 1
}
x64_prepare() {
# --- FEX -------------------------------------------------------------------------------------
if [ ! -x /usr/local/bin/FEX ] || [ "$(cat /usr/local/share/fex-emu/.droiddeck-tag 2>/dev/null)" != "$FEX_TAG" ]; then
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)"
# The client takes this file in its folder as the opt-in to its SteamRT3 build (steamrt64/steam).
# Without it, it re-executes itself in the scout environment through steam.sh, whose start-up checks
# need what proot cannot give, and the session stalls before the interface (device, Bannerlator).
mkdir -p "$STEAM_X64" && : > "$STEAM_X64/.steam-enable-steamrt64-client"
# --- 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; }
}
if ! echo "$ROOTFS_SHA256 $X64_HOME/rootfs.sqsh" | sha256sum -c --quiet >/dev/null 2>&1; then
rm -f "$X64_HOME/rootfs.sqsh"; step "rootfs download did not match its checksum; try again"; exit 1
fi
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
# The 32-bit controller reader crashes Left 4 Dead 2: its fake devices reach the game's own SDL2
# through udev with a missing attribute, and SDL strdup()s the NULL (device, segvlog: strlen in
# libc <- __strdup <- libSDL2 <- udev_device_get_sysattr_value). Until it is fixed, 32-bit
# programs get only the session shim; its preload line names the x86_64 copy outright, since
# with $LIB FEX falls through to the session's own /usr/local/lib/i386-linux-gnu copy.
arches="x86_64 i386"; [ "$lib" = libfakeinput ] && arches=x86_64
rm -f "$ROOTFS/usr/local/lib/i386-linux-gnu/libfakeinput.so"
for arch in $arches; do
# The x86 builds the app stages for FEX programs (tools/linuxfs/build-x86-preloads.sh).
[ -f "/usr/local/lib/droiddeck-fex/$arch/$lib.so" ] || continue
mkdir -p "$ROOTFS/usr/local/lib/$arch-linux-gnu"
cp -f "/usr/local/lib/droiddeck-fex/$arch/$lib.so" "$ROOTFS/usr/local/lib/$arch-linux-gnu/$lib.so"
have=1
done
if [ "$have" = 1 ] && [ "$lib" = libfakeinput ]; then
echo "/usr/local/lib/x86_64-linux-gnu/$lib.so" >> "$ROOTFS/etc/ld.so.preload"
elif [ "$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/droiddeck-fex/x86_64/$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 droiddeck-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 (see the library folders below): the arm64 client's installs are not this one's.
[ -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
# droiddeck-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"
# steam-runtime-launch-client --alongside-steam [options] -- COMMAND: no container to leave here.
rm -f "$srt_shim/bin/steam-runtime-launch-client"
cat > "$srt_shim/bin/steam-runtime-launch-client" <<'LAUNCH'
#!/bin/sh
# droiddeck-steam-x64: no SteamRT container here, so "alongside Steam" is right here - run it.
while [ $# -gt 0 ]; do
case $1 in
--) shift; break ;;
--directory=*) cd "${1#--directory=}" || exit 127 ;;
--env=*) export "${1#--env=}" ;;
--unset-env=*) unset "${1#--unset-env=}" ;;
esac
shift
done
[ $# -gt 0 ] || exit 0
exec "$@"
LAUNCH
chmod 755 "$srt_shim/bin/steam-runtime-launch-client"
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
# Source games keep their console in console.log (-condebug): what the VAC diagnostics read - the
# server they joined and whether it is secure.
case "$*" in *hl2.sh*|*hl2_linux*|*srcds*) set -- "$@" -condebug ;; esac
# The GPU: FEX's host thunks and the session's GPU settings, saved by the launcher (the client
# itself draws in software). BL_X64_GAME_SOFTWARE=1 keeps the game in software too. game-gpu.env is
# rewritten whenever the launcher runs; game-gpu.local (yours, never rewritten) is read after it.
gpu=$HOME/game-gpu.env
if [ -f "$gpu" ] && [ "${BL_X64_GAME_SOFTWARE:-0}" != 1 ]; then
. "$gpu"
[ -f "$HOME/game-gpu.local" ] && . "$HOME/game-gpu.local"
else
# GL threading is a GPU-path tuning from the session; with software GL under FEX it is no help.
export mesa_glthread=false
fi
case $verb in
run|waitforexitandrun)
# Titles Valve gives a newer runtime (sniper, soldier; the launcher lists them in direct-sniper)
# are built for newer libraries than scout's: they run on the rootfs (Ubuntu 24.04) as it is.
if grep -qx "${STEAM_COMPAT_APP_ID:-${SteamAppId:-none}}" "$HOME/direct-sniper" 2>/dev/null; then
exec "$@"
fi
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" "$STEAM_X64/appcache/appinfo.vdf" "$X64_HOME/direct-auto.json" \
"$X64_HOME/direct-sniper" "$STEAM_X64/steamapps" "$(x64_default_tool)" ${BL_X64_DIRECT_APPS:-550} \
<<'PY' || step "could not map titles to DroidDeck direct"
# Every game with a Linux build goes to DroidDeck direct: Steam would run it in its Linux runtime
# container, which needs user namespaces no Android app has, and the game would not start. The
# client's own appinfo cache says which games have a Linux build and which runtime Valve gives them
# (config/app_mappings: SteamLinuxRuntime_sniper or _soldier; none = scout). Those ids go to
# direct-sniper for the tool's run script. A title on a tool chosen in Properties keeps it, except
# BL_X64_DIRECT_APPS (Left 4 Dead 2 by default), which is always taken back.
# direct-auto.json lists what this mapped.
# A game whose Windows build is the one installed (it ran on a Proton before) stays a Windows game, on
# the client's default Proton: Steam does not swap an installed build when the tool changes, so
# DroidDeck direct was handed Launcher.exe and the game ended at once (device: Borderlands 2), and
# getting the Linux build back means downloading the game again.
import json, os, re, struct, sys
path, appinfo, auto_file, sniper_file, steamapps, proton = sys.argv[1:7]
extra = sys.argv[7:]
WINDOWS_DEPOTS = {}
def read_kv(buf, pos, strings):
out = {}
while True:
t = buf[pos]
pos += 1
if t == 0x08:
return out, pos
if strings is not None:
key = strings[struct.unpack_from("<I", buf, pos)[0]]
pos += 4
else:
end = buf.index(b"\0", pos)
key = buf[pos:end].decode("utf-8", "replace")
pos = end + 1
if t == 0x00:
out[key], pos = read_kv(buf, pos, strings)
elif t == 0x01:
end = buf.index(b"\0", pos)
out[key] = buf[pos:end].decode("utf-8", "replace")
pos = end + 1
elif t in (0x02, 0x03, 0x04, 0x06):
pos += 4
elif t in (0x07, 0x0A):
pos += 8
else:
raise ValueError("type %#x" % t)
def linux_games(file):
"""{appid: runtime} for the games with a Linux build in the client's appinfo cache."""
try:
buf = open(file, "rb").read()
except OSError:
return {}
magic = struct.unpack_from("<I", buf, 0)[0]
strings, pos = None, 8
if magic == 0x07564429:
table = struct.unpack_from("<q", buf, 8)[0]
count, p = struct.unpack_from("<I", buf, table)[0], table + 4
strings = []
for _ in range(count):
end = buf.index(b"\0", p)
strings.append(buf[p:end].decode("utf-8", "replace"))
p = end + 1
pos = 16
elif magic != 0x07564428:
return {}
games = {}
while True:
appid, size = struct.unpack_from("<II", buf, pos)
if appid == 0:
break
try:
info = read_kv(buf, pos + 8 + 60, strings)[0].get("appinfo", {})
except (ValueError, IndexError, struct.error):
info = {}
pos += 8 + size
common = info.get("common", {})
if str(common.get("type", "")).lower() != "game" or "linux" not in str(common.get("oslist", "")):
continue
runtime = "scout"
for m in (info.get("config", {}).get("app_mappings") or {}).values():
if isinstance(m, dict) and m.get("platform") == "linux":
tool = str(m.get("tool", "")).lower()
runtime = "sniper" if "sniper" in tool else "soldier" if "soldier" in tool else runtime
games[str(appid)] = runtime
WINDOWS_DEPOTS[str(appid)] = {
d for d, v in (info.get("depots") or {}).items()
if isinstance(v, dict) and "windows" in str((v.get("config") or {}).get("oslist", ""))
and "linux" not in str((v.get("config") or {}).get("oslist", ""))
}
return games
def windows_installed(app):
"""Whether the installed build of `app` is its Windows one (a Windows depot among those installed)."""
try:
text = open(os.path.join(steamapps, "appmanifest_%s.acf" % app), encoding="utf-8", errors="replace").read()
except OSError:
return False
found = block(text, "InstalledDepots")
if not found:
return False
installed = set(re.findall(r'"(\d+)"\s*\{', text[found[1]:found[2]]))
return bool(installed & WINDOWS_DEPOTS.get(app, set()))
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
games = linux_games(appinfo)
for app in extra:
games.setdefault(app, "scout")
s = open(path, encoding="utf-8", errors="surrogateescape").read()
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]:]
mapped, windows = [], []
for app in sorted(games, key=int):
on_windows = windows_installed(app)
_, open_, close = block(s, "CompatToolMapping")
inner = s[open_:close]
old = block(inner, app)
if old:
name = re.search(r'"name"\s*"([^"]*)"', inner[old[1]:old[2]])
name = name.group(1) if name else ""
# Another tool chosen for it in Properties stays; the forced ones are retaken, and so are the
# automatic ones: Valve's own Proton names and droiddeck-proton-arm64, the arm64 client's
# name for its Proton, which only its live compat pass writes (here the wrapper is -a64).
automatic = name in ("", "droiddeck-direct", "droiddeck-proton-arm64") or name.startswith("proton_")
if not automatic and app not in extra:
continue
if on_windows and (name == proton or not proton):
continue
inner = inner[:old[0]] + inner[old[2] + 1:]
elif on_windows and not proton:
continue
tool = proton if on_windows else "droiddeck-direct"
entry = ('\n\t\t\t\t\t"%s"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"%s"\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, tool))
s = s[:open_] + entry + inner + s[close:]
(windows if on_windows else mapped).append(app)
open(path, "w", encoding="utf-8", errors="surrogateescape").write(s)
json.dump(mapped, open(auto_file, "w"))
open(sniper_file, "w").write("".join("%s\n" % a for a in mapped if games[a] != "scout"))
print("droiddeck-steam-x64: %d Linux title(s) on DroidDeck direct (%d on the newer runtime)%s"
% (len(mapped), sum(games[a] != "scout" for a in mapped),
"; %d installed as Windows games kept on %s" % (len(windows), proton) if windows else ""))
PY
fi
# The arm64 client's Proton tools (droiddeck-proton-arm64, GE-Proton, CachyOS): one wrapper per
# tool, same folder and tool name (Components swaps and per-title mappings are keyed by them). The
# wrapper is an x86 script under FEX; it starts droiddeck-x64-arm-proton through an arm64 binary,
# which FEX hands to the CPU, so Wine and the game run exactly as from the arm64 client.
arm_tools=$ARM_STEAM/compatibilitytools.d
mkdir -p "$X64_HOME/arm64"
cp -f /usr/bin/env "$X64_HOME/arm64/env"
for w in "$STEAM_X64"/compatibilitytools.d/*/.droiddeck-arm64-proton; do
[ -e "$w" ] || continue
d=${w%/.droiddeck-arm64-proton}
[ -d "$arm_tools/${d##*/}" ] && [ "${d##*/}" != droiddeck-fex ] || rm -rf "$d"
done
for src in "$arm_tools"/*/; do
src=${src%/}; name=${src##*/}
[ -f "$src/compatibilitytool.vdf" ] && [ -f "$src/toolmanifest.vdf" ] || continue
# droiddeck-fex is the arm64 client's way to x86 Linux titles; this client runs them itself. Its
# wrapper ran the arm64 client's droiddeck-fex when the client probed its tools after logon, which
# took that for an x86 program wanting FEX and had the arm64 client install Valve's FEX (device).
case $name in droiddeck-direct|droiddeck-fex) continue ;; esac
dst=$STEAM_X64/compatibilitytools.d/$name
mkdir -p "$dst"
: > "$dst/.droiddeck-arm64-proton"
# The tool's own name and display name, as the arm64 client shows it.
python3 - "$src/compatibilitytool.vdf" "$dst/compatibilitytool.vdf" <<'PY' || { rm -rf "$dst"; continue; }
import re, sys
s = open(sys.argv[1], encoding="utf-8", errors="replace").read()
m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', s)
if not m:
sys.exit(1)
# The x86-64 client takes a tool named *arm64* for one built for the arm64 client and ignores it
# ("for a different target platform linux arm64"): the wrapper's name says a64 instead.
key = re.sub(r"arm64|aarch64", "a64", m.group(1))
d = re.search(r'"display_name"\s*"([^"]*)"', s)
name = d.group(1) if d else key
open(sys.argv[2], "w").write(
'"compatibilitytools"\n{\n "compat_tools"\n {\n "%s"\n {\n "install_path" "."\n'
' "display_name" "%s"\n "from_oslist" "windows"\n "to_oslist" "linux"\n }\n }\n}\n'
% (key, name))
PY
cat > "$dst/toolmanifest.vdf" <<'VDF'
"manifest"
{
"version" "2"
"commandline" "/run %verb%"
}
VDF
printf '#!/bin/sh\n# The arm64 client'"'"'s %s, run natively (droiddeck-x64-arm-proton).\nexec "%s" /usr/local/bin/droiddeck-x64-arm-proton "%s" "$@"\n' \
"$name" "$X64_HOME/arm64/env" "$name" > "$dst/run"
chmod 755 "$dst/run"
done
x64_sync_login
# Its own library only. The arm64 client's folders (internal storage and the card) are not this
# client's: with them shared, each client could update, verify or switch the other's games - a
# title with a Linux build runs as that here and as the Windows build there, so opening it in the
# other client switched the files and the first one downloaded its version again (Left 4 Dead 2,
# 8.6 GB). A game wanted in both clients is installed in both. Folders an earlier version shared
# are taken out of this client's lists; the games stay where they are, in the arm64 client's
# library. Written while no x86 client runs: it rewrites these files on exit.
if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
python3 - "$STEAM_X64" "$STEAM_X64/config/libraryfolders.vdf" "$STEAM_X64/steamapps/libraryfolders.vdf" <<'PY' || step "could not check the x86-64 client's library folders"
import os, re, sys
own = os.path.realpath(sys.argv[1])
for path in sys.argv[2:]:
if not os.path.isfile(path):
continue
s = open(path, encoding="utf-8", errors="surrogateescape").read()
out, removed, i = [], [], 0
# Each library is a numbered block: "N" { "path" "..." ... "apps" { ... } }.
for m in re.finditer(r'^\t"(\d+)"\s*\n\t\{', s, re.M):
if m.start() < i:
continue
depth, k = 1, m.end()
while depth and k < len(s):
depth += s[k] == "{"
depth -= s[k] == "}"
k += 1
block = s[m.start():k]
p = re.search(r'"path"\s*"([^"]*)"', block)
if p and os.path.realpath(p.group(1)) != own:
out.append(s[i:m.start()])
i = k + (1 if s[k:k + 1] == "\n" else 0)
removed.append(p.group(1))
if not removed:
continue
out.append(s[i:])
open(path, "w", encoding="utf-8", errors="surrogateescape").write("".join(out))
print("droiddeck-steam-x64: no longer shared with the arm64 client: %s" % ", ".join(removed))
PY
fi
# Steam Play for every title not mapped otherwise: one of the arm64 client's own ARM64 Protons. The
# newest GE-Proton first:
# 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,
# same client, stayed flat. droiddeck-proton-a64 when no GE-Proton is installed.
default=$(x64_default_dir)
if [ -f "$default/compatibilitytool.vdf" ] && [ -f "$STEAM_X64/config/config.vdf" ] &&
! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
python3 - "$STEAM_X64/config/config.vdf" "$default/compatibilitytool.vdf" <<'PY' || step "could not make ARM64 Proton the default"
import re, sys
path = sys.argv[1]
tool = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', open(sys.argv[2]).read()).group(1)
s = open(path, encoding="utf-8", errors="surrogateescape").read()
m = re.search(r'"CompatToolMapping"\s*\{', s)
if not m:
sys.exit(0)
depth, k = 1, m.end()
while depth:
depth += s[k] == "{"
depth -= s[k] == "}"
k += 1
block = s[m.end():k]
z = re.search(r'^\s*"0"\s*\{([^{}]*)\}', block, re.M)
if z:
# Kept when the user chose another default; replaced when it names a tool this client ignores,
# or is the earlier default this script set (droiddeck-proton-a64 at priority 75).
ours = re.search(r'"name"\s*"droiddeck-proton-a64"', z.group(1)) and \
re.search(r'"priority"\s*"75"', z.group(1))
if "arm64" not in z.group(1) and not ours:
sys.exit(0)
if re.search(r'"name"\s*"%s"' % re.escape(tool), z.group(1)):
sys.exit(0)
s = s[:m.end() + z.start()] + s[m.end() + z.end():]
entry = ('\n\t\t\t\t\t"0"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"%s"\n' % tool +
'\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"75"\n\t\t\t\t\t}')
s = s[:m.end()] + entry + s[m.end():]
open(path, "w", encoding="utf-8", errors="surrogateescape").write(s)
PY
fi
# The startup movie the app stages for the arm64 client (its config/uioverrides/movies, chosen in
# its config.vdf as StartupMovie): the same one here, through a link to that folder and the same
# choice in this client's config. And Decky's marker for the client's CEF debugging port, which the
# loader talks to whichever client runs: the browser is the arm64 one either way.
if [ -d "$ARM_STEAM/config/uioverrides" ] && [ ! -e "$STEAM_X64/config/uioverrides" ]; then
ln -s "$ARM_STEAM/config/uioverrides" "$STEAM_X64/config/uioverrides"
fi
if [ -f "$ARM_STEAM/.cef-enable-remote-debugging" ]; then
: > "$STEAM_X64/.cef-enable-remote-debugging"
else
rm -f "$STEAM_X64/.cef-enable-remote-debugging"
fi
if [ -f "$ARM_STEAM/config/config.vdf" ] && [ -f "$STEAM_X64/config/config.vdf" ] &&
! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then
python3 - "$ARM_STEAM/config/config.vdf" "$STEAM_X64/config/config.vdf" <<'PY' || step "could not copy the startup movie choice"
import re, sys
movie = re.search(r'\n([ \t]*)"StartupMovie"\s*\{[^{}]*\}', open(sys.argv[1], encoding="utf-8", errors="replace").read())
if not movie:
sys.exit(0)
path = sys.argv[2]
s = open(path, encoding="utf-8", errors="surrogateescape").read()
old = re.search(r'\n[ \t]*"StartupMovie"\s*\{[^{}]*\}', s)
if old:
if old.group(0).split(None, 1)[1] == movie.group(0).split(None, 1)[1]:
sys.exit(0)
s = s[:old.start()] + s[old.end():]
steam = re.search(r'"Valve"\s*\{\s*"Steam"\s*\{', s)
if not steam:
sys.exit(0)
s = s[:steam.end()] + movie.group(0) + s[steam.end():]
open(path, "w", encoding="utf-8", errors="surrogateescape").write(s)
PY
fi
# A FEX process takes its rootfs from FEXServer, not from its own environment, and a server
# 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
}
# Whether the client draws on the GPU: FEX built with its host thunks (Vulkan and GL to the runtime's
# own drivers), unless BL_X64_SOFTWARE=1. Without them, software rendering (llvmpipe).
x64_gpu() {
[ "${BL_X64_SOFTWARE:-0}" != 1 ] && [ -d /usr/local/share/fex-emu/GuestThunks ] && [ -d /usr/local/lib/fex-emu/HostThunks ]
}
# The environment the client and everything it starts run in.
x64_env() {
# The arm64 session's environment as it is now, for the arm64 Proton tools this client starts
# (droiddeck-x64-arm-proton puts it back before Proton runs).
export -p | grep -v -E '^declare -x (PWD|OLDPWD|SHLVL|_)=' > "$X64_HOME/arm-session.env.new" || true
mv -f "$X64_HOME/arm-session.env.new" "$X64_HOME/arm-session.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
# The client and its browser draw in software, as they always have: with the thunks the browser's
# GPU process (even under -cef-disable-gpu it probes GL and Vulkan) gets the host drivers, fails
# ("MESA-LOADER: failed to retrieve device information") and dies, and the client never opens a
# window (device: 0 windows, "Starting display and audio" forever). Games get the GPU: the session's
# GPU settings are saved for DroidDeck direct, which hands them, and FEX's host thunks, to the game.
gpu_vars="GALLIUM_DRIVER MESA_LOADER_DRIVER_OVERRIDE VK_ICD_FILENAMES VK_DRIVER_FILES LIBGL_KOPPER_DRI2 ZINK_DEBUG ZINK_DESCRIPTORS TU_DEBUG MESA_NO_ERROR mesa_glthread"
rm -f "$X64_HOME/game-gpu.env"
if x64_gpu; then
printf '{"ThunksDB": {"GL": 1, "Vulkan": 1}}\n' > "$X64_HOME/thunks.json"
{
echo "export FEX_THUNKCONFIG='$X64_HOME/thunks.json'"
echo "unset LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE GALLIUM_DRIVER"
for v in $gpu_vars; do
eval "val=\${$v-}"; [ -n "$val" ] && printf "export %s='%s'\n" "$v" "$val"
done
} > "$X64_HOME/game-gpu.env"
fi
# The web helper is the arm64 client's (droiddeck-x64-arm-webhelper), which sets its own GPU
# environment, so the client itself can have the GPU now: it shows every Big Picture frame the helper
# draws, and in software (llvmpipe under FEX) that held the UI at ~20 fps. BL_X64_CLIENT_SOFTWARE=1
# keeps it in software.
if x64_gpu && [ "${BL_X64_CLIENT_SOFTWARE:-0}" != 1 ]; then
export FEX_THUNKCONFIG=$X64_HOME/thunks.json
unset LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE
else
unset FEX_THUNKCONFIG $gpu_vars
# 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.
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
# The X server hands out the GPU node (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
fi
# 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 (droiddeck-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 believes it is inside the SteamRT container (the recursion guard), so it 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 without one on PATH the
# launch fails (No such file). The shim's own launch client runs the command right here.
[ -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"
# The client's web helper is the arm64 client's (droiddeck-x64-arm-webhelper; FEX hands that arm64
# program to the CPU): the session shim turns steamrt64/steamwebhelper.sh's exec of ./steamwebhelper
# into it (preload/net.c). Nothing of the client's is edited - it checksums its files as it starts
# and reinstalled itself over an edited launcher (2-3 minutes) on every start.
# Only while both clients are one build: the helper talks to the client over IPC, and one of another
# build never comes up - the client restarted it every 10 s (device: 1790721607 against 1790904859).
# Otherwise the client keeps its own (x86, software) helper. The session updates the arm64 parts first.
# Neither manifest is there before that client's first run (a fresh install): no build to compare,
# and under set -e the failed read would end this script with rc=2 before the client starts.
if [ "${BL_X64_X86_WEBHELPER:-0}" != 1 ] && [ -x /usr/local/bin/droiddeck-x64-arm-webhelper ] &&
[ -x "$ARM_STEAM/steamrtarm64/steamwebhelper" ]; then
local chan x86_build arm_build
chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo publicbeta)
x86_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest" 2>/dev/null || true)
arm_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$ARM_STEAM/package/steam_client_${chan}_linuxarm64.manifest" 2>/dev/null || true)
if [ -n "$x86_build" ] && [ "$x86_build" = "$arm_build" ]; then
export BL_X64_ARM_WEBHELPER=/usr/local/bin/droiddeck-x64-arm-webhelper
else
echo "== x86-64 client build ${x86_build:-?}, arm64 web helper's ${arm_build:-?}: keeping the x86 web helper" >&2
fi
fi
# The session asks for the GPU on the arm64 helper's behalf; the x86 one has no driver for this GPU
# and its GPU process dies at once (exit_code=11), the client restarting the helper without end
# (device: a fresh install's first start, -startcount=15 in three minutes). It draws in software.
if [ -z "${BL_X64_ARM_WEBHELPER:-}" ] && [ "${BL_X64_CEF_GPU:-0}" != 1 ]; then
local a args=() soft=0
for a in "$@"; do
case $a in
-cef-force-gpu|-cef-ozone-platform=*|-cef-use-gl=*|-cef-use-angle=*) soft=1 ;;
*) args+=("$a") ;;
esac
done
[ "$soft" = 1 ] && set -- "${args[@]}" -cef-disable-gpu
fi
exec "${BL_FEX:-/usr/local/bin/FEX}" "$STEAM_X64/steamrt64/steam" "$@"
}
# One sign-in for both clients, games kept apart. Before either client starts, the newer sign-in -
# the account list, the login token in local.vdf, the auto-login name - is copied to the other, so
# signing in (or switching accounts) in one counts for both. Steam renews its token every few days and
# an old copy is refused (device: a 3-day-old one asked for the password), which is why it is the
# newest that is copied, every start, and not once. Never while either client runs: each rewrites
# these files. The rest of registry.vdf (language, skin) and everything else stay each client's.
x64_sync_login() {
pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1 && return 0
pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 && return 0
python3 - "$ARM_STEAM" "$STEAM_X64" "$REAL_HOME/.steam/registry.vdf" "$X64_HOME/.steam/registry.vdf" <<'PY' || step "could not share the sign-in between the clients"
import os, re, shutil, sys
arm, x64, arm_reg, x64_reg = sys.argv[1:5]
FILES = ("config/loginusers.vdf", "local.vdf")
def stamp(root):
if not all(os.path.isfile(os.path.join(root, f)) for f in FILES):
return None
# A client that refused its token rewrites local.vdf with an empty ConnectCache: newest, and
# nothing to share - copied over the other it would sign that one out too (device: the x86-64
# client's "Invalid Password" left one newer than the arm64 client's working sign-in).
text = open(os.path.join(root, "local.vdf"), encoding="utf-8", errors="surrogateescape").read()
if not re.search(r'"ConnectCache"\s*\{\s*"[^"]+"\s*"[^"]+"', text):
return None
return os.path.getmtime(os.path.join(root, "local.vdf"))
a, x = stamp(arm), stamp(x64)
if a is None and x is None:
sys.exit(0)
if a is not None and x is not None and abs(a - x) < 1:
sys.exit(0)
if x is None or (a is not None and a > x):
src, dst, sreg, dreg, who = arm, x64, arm_reg, x64_reg, "arm64"
else:
src, dst, sreg, dreg, who = x64, arm, x64_reg, arm_reg, "x86-64"
for f in FILES:
os.makedirs(os.path.dirname(os.path.join(dst, f)), exist_ok=True)
# copy2 keeps the time stamp, so both match until one client signs in again.
shutil.copy2(os.path.join(src, f), os.path.join(dst, f))
if os.path.isfile(sreg):
st = open(sreg, encoding="utf-8", errors="surrogateescape").read()
if not os.path.isfile(dreg):
os.makedirs(os.path.dirname(dreg), exist_ok=True)
shutil.copy2(sreg, dreg)
else:
dt = open(dreg, encoding="utf-8", errors="surrogateescape").read()
for key in ("AutoLoginUser", "RememberPassword"):
v = re.search(r'"%s"\s*"([^"]*)"' % key, st)
if v and re.search(r'"%s"\s*"' % key, dt):
dt = re.sub(r'("%s"\s*")[^"]*(")' % key, lambda m: m.group(1) + v.group(1) + m.group(2), dt, count=1)
open(dreg, "w", encoding="utf-8", errors="surrogateescape").write(dt)
print("droiddeck-steam-x64: sign-in shared from the %s client (the newer one)" % who)
PY
}
case "${1:-}" in
--sync-login) x64_sync_login; exit 0 ;;
--prepare) x64_prepare; exit 0 ;;
--exec) shift; x64_exec "$@" ;;
--gpu) x64_gpu; exit $? ;;
--default-tool) x64_default_tool; exit 0 ;;
esac
# --- standalone (the desktop menu entry) ------------------------------------------------------
# --- 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 droiddeck-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/droiddeck/system-bus.conf' >/dev/null 2>&1 \
&& command -v dbus-daemon >/dev/null 2>&1 && command -v droiddeck-netmanager >/dev/null 2>&1; then
mkdir -p /run/dbus /usr/local/share/droiddeck
cat > /usr/local/share/droiddeck/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/droiddeck/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
droiddeck-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 droiddeck-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
if [ "${BL_X64_X86_WEBHELPER:-0}" != 1 ] || { [ "${BL_X64_CEF_GPU:-0}" = 1 ] && x64_gpu; }; then cef=(-cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan); else cef=(-cef-disable-gpu); fi
( x64_exec -clientbeta publicbeta -no-cef-sandbox "${cef[@]}" "$@" ) >>"$LOG" 2>&1 || rc=$?
step "client exited rc=$rc"
[ "$rc" = 42 ] || break
done
@@ -0,0 +1,45 @@
#!/bin/bash
# The x86-64 client's way into the arm64 Proton tools (droiddeck-steam-x64 makes one wrapper per
# tool in its compatibilitytools.d). The wrapper is an x86 script under FEX; it starts this one
# through an arm64 binary, which FEX hands straight to the CPU, so from here on everything is native
# and sees the guest as the arm64 client does: the same Proton, launcher, Components swaps, per-game
# environment and Steam client library (~/.steam/sdk64) as a launch from the arm64 client.
# droiddeck-x64-arm-proton <tool folder> <verb> <command...>
tool=$1; shift
x64_home=$HOME
# The x86 client preloads its own (x86) game overlay; the arm64 client's takes its place below. It
# is what marks the game's windows with the Steam app ID - without it gamescope never shows them
# (device: FlatOut ran and played sound behind Steam's launch screen, "steam app id: 0").
overlay=0
case ${LD_PRELOAD:-} in *gameoverlayrenderer.so*) overlay=1 ;; esac
# Drop the x86 side: FEX's settings, the x86 library paths and preloads, the client's software GL.
for v in $(compgen -e); do
case $v in FEX_*) unset "$v" ;; esac
done
unset LD_LIBRARY_PATH LD_PRELOAD STEAM_RUNTIME_STEAMRT STEAM_STEAMRT_RECURSION_GUARD \
LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE GALLIUM_DRIVER BL_WEBHELPER_HIDE_TRACER PROOT_FP_OFF
# The arm64 session's environment, saved by droiddeck-steam-x64 before it made the x86 one: HOME,
# PATH, the GPU (Turnip, Zink), audio, input, the proot fast path. Steam's per-game variables
# (SteamAppId, STEAM_COMPAT_*) are not in it and stay as the client set them.
if [ -f "$x64_home/arm-session.env" ]; then
. "$x64_home/arm-session.env"
else
export HOME=/root
fi
# Proton takes its Steam client parts from the client's install folder; the x86-64 client's are x86.
export STEAM_COMPAT_CLIENT_INSTALL_PATH=$HOME/.local/share/Steam
arm_overlay=$STEAM_COMPAT_CLIENT_INSTALL_PATH/steamrtarm64/gameoverlayrenderer.so
if [ "$overlay" = 1 ] && [ -f "$arm_overlay" ]; then
export LD_PRELOAD=$arm_overlay${LD_PRELOAD:+:$LD_PRELOAD}
fi
dir=$HOME/.local/share/Steam/compatibilitytools.d/$tool
manifest=$dir/toolmanifest.vdf
[ -f "$manifest" ] || { echo "droiddeck-x64-arm-proton: no tool $dir" >&2; exit 127; }
# "commandline" "/droiddeck-proton %verb%": the entry point, relative to the tool's folder.
cmd=$(sed -n 's/^[[:space:]]*"commandline"[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -n 1)
entry=${cmd%% *}
exec "$dir/${entry#/}" "$@"
@@ -0,0 +1,28 @@
#!/bin/bash
# The x86-64 client's web helper (its whole UI) is the arm64 client's, run natively: both clients are
# the same Steam build, and the session shim turns the x86 client's exec of its own helper into this
# (preload/net.c, BL_X64_ARM_WEBHELPER); FEX hands the arm64 program here to the CPU. Under FEX the
# x86 helper could not have the GPU (its GPU process needs libgbm, which FEX does not thunk) and drew
# on SwiftShader; this one draws with ANGLE on Turnip and takes the session's preloads (KGSL poll fix,
# fast path), as from the arm64 client.
x64_home=$HOME
arm=/root/.local/share/Steam/steamrtarm64
# Drop the x86 side: FEX's settings, the x86 library paths and preloads, the client's software GL.
for v in $(compgen -e); do
case $v in FEX_*) unset "$v" ;; esac
done
unset LD_PRELOAD LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE BL_WEBHELPER_HIDE_TRACER BL_X64_ARM_WEBHELPER
# The arm64 session's GPU settings (saved by droiddeck-steam-x64 at start).
if [ -f "$x64_home/arm-session.env" ]; then
eval "$(grep -E '^declare -x (VK_ICD_FILENAMES|VK_DRIVER_FILES|GALLIUM_DRIVER|MESA_LOADER_DRIVER_OVERRIDE|LIBGL_KOPPER_DRI2|TU_DEBUG|ZINK_DEBUG|ZINK_DESCRIPTORS|MESA_NO_ERROR|MESA_DISK_CACHE_DATABASE)=' "$x64_home/arm-session.env")"
fi
export LD_LIBRARY_PATH=$arm:$arm/panorama LIBGL_KOPPER_DISABLE=true
# The client was started for a software browser; the arm64 client's helper is told what it is told.
args=()
for a in "$@"; do
case $a in --disable-gpu|--disable-gpu-compositing) ;; *) args+=("$a") ;; esac
done
cd "$arm" || exit 1
exec "$arm/steamwebhelper" "${args[@]}" --ignore-gpu-blocklist
@@ -254,6 +254,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 "droiddeck-proton: vac-diag: app $app already has -condebug" >&2 ;;
*) BL_EXTRA_ARGS+=(-condebug); added=yes
echo "droiddeck-proton: vac-diag: app $app is a Source title; launched with -condebug" >&2 ;;
esac
else
added=not-source
echo "droiddeck-proton: vac-diag: app $app is not a Source title; it keeps no console log to read" >&2
fi
case " $* " in
*" -insecure "*) echo "droiddeck-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
@@ -393,6 +424,7 @@ if [ -n "$depot" ]; then
bl_directaudio "$depot"
bl_game_cpus
bl_idtech3 "$@"
bl_vacdiag "$@"
bl_libpath
bl_store_launch "$@"
BL_STORE_ARGS=("$@")
@@ -410,7 +442,7 @@ def launcher_sh(sources=SOURCES):
# something else.
return (LAUNCHER_TEMPLATE % " ".join('"%s"' % n for n in sources)).replace(
"verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP
+ BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1)
+ BL_VACDIAG_SETUP + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1)
LAUNCHER_SH = launcher_sh()
@@ -461,6 +493,7 @@ bl_components "$here"
bl_directaudio "$here"
bl_game_cpus
bl_idtech3 "$@"
bl_vacdiag "$@"
bl_libpath
bl_store_launch "$@"
BL_STORE_ARGS=("$@")
@@ -693,7 +726,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 + BL_STORE_SETUP, 1)
+ BL_VACDIAG_SETUP + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1)
write_changed(wrapper, body, 0o755)
write_changed(manifest, EXTRA_MANIFEST_VDF)
write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, display_name(entry)))
@@ -1222,8 +1255,72 @@ def remove_tool(tool_dir):
shutil.rmtree(tool_dir)
def local_tools(steam_root):
"""The names of the compatibility tools installed in steam_root/compatibilitytools.d."""
names = set()
tools = os.path.join(steam_root, "compatibilitytools.d")
try:
entries = os.listdir(tools)
except OSError:
return names
for entry in entries:
try:
with open(os.path.join(tools, entry, "compatibilitytool.vdf"), encoding="utf-8", errors="replace") as f:
m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', f.read())
except OSError:
continue
if m:
names.add(m.group(1).lower())
return names
def x64_repoint(steam_root, tool, live=False):
"""The x86-64 client (droiddeck-steam-x64): its tools are arm64 wrappers that script makes and
its Linux titles run as Linux builds (FEX), so only the entries the client aimed at a Proton it
cannot start are repointed, to `tool`. Tools in its own compatibilitytools.d are kept: the
wrappers include proton-cachyos-*-a64, which starts with "proton" like Valve's own.
Returns the app ids repointed."""
config = os.path.join(steam_root, "config", "config.vdf")
if not tool or not os.path.isfile(config):
return []
with open(config, encoding="utf-8", errors="replace") as f:
original = f.read()
tokens = tokenize(original)
mapping = mapping_block(tokens)
if mapping is None:
return []
keep = local_tools(steam_root)
moved = []
current = read_mapping(tokens, mapping)
if "0" not in current:
set_mapping(tokens, mapping, "0", tool)
for app, name in current.items():
named = name.lower()
# droiddeck-proton-arm64: the arm64 client's name for its Proton, which this client ignores
# (its wrapper is -a64), left by an arm64 compat pass that once ran against it.
if (named.startswith("proton") or named == "droiddeck-proton-arm64") and named != tool.lower() \
and named not in keep:
set_mapping(tokens, mapping, app, tool)
if app != "0":
moved.append(app)
text = serialize(tokens)
if text != serialize(tokenize(original)):
write_changed(config, text)
print("steam-compatibility: x86-64 client's Proton entries point at %s" % tool)
# An entry changed while the client runs counts from its next start: the session restarts the
# client once the title has installed (droiddeck-session, .bl-compat-pending).
if moved and live:
with open(os.path.join(os.path.expanduser("~"), ".bl-compat-pending"), "a") as f:
f.write("".join("%s\n" % a for a in moved))
print("steam-compatibility: repointed while the client runs: %s" % " ".join(moved))
return moved
def main():
steam_root = sys.argv[1]
if "--x64" in sys.argv[2:]:
x64_repoint(steam_root, os.environ.get("BL_COMPAT_TOOL"), "--live" in sys.argv[2:])
return
tools = os.path.join(steam_root, "compatibilitytools.d")
os.makedirs(tools, exist_ok=True)
legacy = os.path.join(tools, LEGACY_TOOL)
+41
View File
@@ -14,6 +14,7 @@
#include <linux/netlink.h>
#include <netinet/in.h>
#include <stdio.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
@@ -313,10 +314,44 @@ static int maybe_answer_lsof(const char *path, char *const argv[]) {
_exit(0);
}
/* The x86-64 Steam client's web helper is the arm64 client's (droiddeck-x64-arm-webhelper, named
* by BL_X64_ARM_WEBHELPER, which droiddeck-steam-x64 sets for the client): steamrt64's
* steamwebhelper.sh ends in `exec ./steamwebhelper`, and that exec becomes the launcher with the same
* arguments. Steam's own files stay as shipped - the client checksums them as it starts and
* reinstalled itself over an edited launcher every time. The arm64 helper (steamrtarm64) never
* matches. Returns the launcher's argv to exec instead, or NULL. */
static char **web_helper_redirect(const char *path, char *const argv[], const char **launcher) {
const char *want = getenv("BL_X64_ARM_WEBHELPER");
if (!want || !*want || !path) return NULL;
const char *base = strrchr(path, '/');
if (strcmp(base ? base + 1 : path, "steamwebhelper") != 0) return NULL;
if (!strstr(path, "/steamrt64/")) {
char cwd[PATH_MAX];
size_t n;
if (path[0] == '/' || !getcwd(cwd, sizeof(cwd))) return NULL;
n = strlen(cwd);
if (n < 10 || strcmp(cwd + n - 10, "/steamrt64") != 0) return NULL;
}
int argc = 0;
while (argv && argv[argc]) argc++;
char **out = calloc((size_t)argc + 2, sizeof(char *));
if (!out) return NULL;
out[0] = (char *)want;
for (int i = 1; i < argc; i++) out[i] = argv[i];
*launcher = want;
return out;
}
int execve(const char *path, char *const argv[], char *const envp[]) {
static int (*real)(const char *, char *const[], char *const[]);
if (!real) real = (int (*)(const char *, char *const[], char *const[]))dlsym(RTLD_NEXT, "execve");
maybe_answer_lsof(path, argv);
const char *launcher = NULL;
char **redirected = web_helper_redirect(path, argv, &launcher);
if (redirected) {
real(launcher, redirected, envp);
free(redirected); /* only on failure: the original exec runs */
}
return real(path, argv, envp);
}
@@ -348,6 +383,12 @@ int execv(const char *path, char *const argv[]) {
static int (*real)(const char *, char *const[]);
if (!real) real = (int (*)(const char *, char *const[]))dlsym(RTLD_NEXT, "execv");
maybe_answer_lsof(path, argv);
const char *launcher = NULL;
char **redirected = web_helper_redirect(path, argv, &launcher);
if (redirected) {
real(launcher, redirected);
free(redirected);
}
return real(path, argv);
}
+2 -2
View File
@@ -487,12 +487,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")));
int bl_ntsync_open(const char *path, int flags) __attribute__((visibility("hidden")));
int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode,
@@ -74,7 +74,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 ((fd = bl_ntsync_open(path, flags)) != -2) return fd;
if (real_open == NULL) real_open = dlsym(RTLD_NEXT, "open");
if (real_open == NULL) {
@@ -100,7 +100,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 ((fd = bl_ntsync_open(path, flags)) != -2) return fd;
if (real_openat == NULL) real_openat = dlsym(RTLD_NEXT, "openat");
if (real_openat == NULL) {
+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);
}
+47
View File
@@ -0,0 +1,47 @@
/* PulseAudio operations that never started, for the x86-64 Steam client.
*
* In Deck mode (-steamos3) the x86-64 client's SteamOS audio manager passes libpulse the operation a
* failed request returned - none - and libpulse aborts the client on it: "Assertion 'o' failed at
* ../src/pulse/operation.c:136 pa_operation_get_state()" right after start (device, 2026-10-02).
* A missing operation is answered as cancelled and its unref/cancel ignored; every real one goes
* to libpulse. Built into the x86 libraries only: the arm64 client does not hit it. */
#if defined(__x86_64__) || defined(__i386__)
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stddef.h>
typedef struct pa_operation pa_operation;
#define PA_OPERATION_CANCELLED 2
int pa_operation_get_state(pa_operation *o) {
static int (*next)(pa_operation *);
if (!o)
return PA_OPERATION_CANCELLED;
if (!next)
next = (int (*)(pa_operation *))dlsym(RTLD_NEXT, "pa_operation_get_state");
return next ? next(o) : PA_OPERATION_CANCELLED;
}
void pa_operation_unref(pa_operation *o) {
static void (*next)(pa_operation *);
if (!o)
return;
if (!next)
next = (void (*)(pa_operation *))dlsym(RTLD_NEXT, "pa_operation_unref");
if (next)
next(o);
}
void pa_operation_cancel(pa_operation *o) {
static void (*next)(pa_operation *);
if (!o)
return;
if (!next)
next = (void (*)(pa_operation *))dlsym(RTLD_NEXT, "pa_operation_cancel");
if (next)
next(o);
}
#else
typedef int pulseguard_arm_build_has_nothing_to_do;
#endif
+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 droiddeck-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
@@ -191,6 +191,32 @@ 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 */);
}
/*
* proot canonicalised every binding's host path with realpath() in its own process, so a binding of
* /proc/self/fd/1 (/dev/stdout) names whatever proot's own descriptor 1 is - /dev/null in a session -
@@ -212,6 +238,7 @@ 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) {
+100
View File
@@ -0,0 +1,100 @@
import os
from pathlib import Path
import runpy
import tempfile
import unittest
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
CONFIG = '''"InstallConfigStore"
{
\t"Software"
\t{
\t\t"Valve"
\t\t{
\t\t\t"Steam"
\t\t\t{
\t\t\t\t"CompatToolMapping"
\t\t\t\t{
\t\t\t\t\t"550"
\t\t\t\t\t{
\t\t\t\t\t\t"name"\t\t"proton_experimental"
\t\t\t\t\t\t"config"\t\t""
\t\t\t\t\t\t"priority"\t\t"250"
\t\t\t\t\t}
\t\t\t\t\t"620"
\t\t\t\t\t{
\t\t\t\t\t\t"name"\t\t"proton-cachyos-a64"
\t\t\t\t\t\t"config"\t\t""
\t\t\t\t\t\t"priority"\t\t"250"
\t\t\t\t\t}
\t\t\t\t\t"880"
\t\t\t\t\t{
\t\t\t\t\t\t"name"\t\t"droiddeck-proton-arm64"
\t\t\t\t\t\t"config"\t\t""
\t\t\t\t\t\t"priority"\t\t"250"
\t\t\t\t\t}
\t\t\t\t\t"730"
\t\t\t\t\t{
\t\t\t\t\t\t"name"\t\t"steamlinuxruntime"
\t\t\t\t\t\t"config"\t\t""
\t\t\t\t\t\t"priority"\t\t"250"
\t\t\t\t\t}
\t\t\t\t}
\t\t\t}
\t\t}
\t}
}
'''
class X64RepointTest(unittest.TestCase):
"""steam-compatibility --x64: the x86-64 client's Proton entries go to its arm64 wrapper."""
def setUp(self):
self.home = tempfile.TemporaryDirectory()
self.root = Path(self.home.name) / "Steam"
(self.root / "config").mkdir(parents=True)
(self.root / "config" / "config.vdf").write_text(CONFIG)
# A wrapper of its own whose name starts with "proton": kept, never repointed.
own = self.root / "compatibilitytools.d" / "proton-cachyos-a64"
own.mkdir(parents=True)
(own / "compatibilitytool.vdf").write_text('"compatibilitytools"\n{\n\t"compat_tools"\n\t{\n\t\t"proton-cachyos-a64"\n\t\t{\n\t\t}\n\t}\n}\n')
self.old_home = os.environ.get("HOME")
os.environ["HOME"] = self.home.name
def tearDown(self):
if self.old_home is None:
os.environ.pop("HOME", None)
else:
os.environ["HOME"] = self.old_home
self.home.cleanup()
def mapping(self):
tokens = COMPAT["tokenize"]((self.root / "config" / "config.vdf").read_text())
return COMPAT["read_mapping"](tokens, COMPAT["mapping_block"](tokens))
def test_repoints_valve_proton_only(self):
moved = COMPAT["x64_repoint"](str(self.root), "GE-Proton11-7-a64")
self.assertEqual(moved, ["550", "880"])
m = self.mapping()
self.assertEqual(m["550"], "GE-Proton11-7-a64")
self.assertEqual(m["620"], "proton-cachyos-a64")
self.assertEqual(m["730"], "steamlinuxruntime")
# The arm64 client's own Proton name, which this client ignores.
self.assertEqual(m["880"], "GE-Proton11-7-a64")
self.assertEqual(m["0"], "GE-Proton11-7-a64")
def test_live_notes_pending_restart(self):
COMPAT["x64_repoint"](str(self.root), "GE-Proton11-7-a64", live=True)
self.assertEqual((Path(self.home.name) / ".bl-compat-pending").read_text(), "550\n880\n")
def test_no_tool_changes_nothing(self):
before = (self.root / "config" / "config.vdf").read_text()
self.assertEqual(COMPAT["x64_repoint"](str(self.root), None), [])
self.assertEqual((self.root / "config" / "config.vdf").read_text(), before)
if __name__ == "__main__":
unittest.main()