mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa479ee6d | ||
|
|
d9b86fe9b6 | ||
|
|
d06468b802 | ||
|
|
2cacc00047 | ||
|
|
c29362b53f | ||
|
|
f8c693b2dc | ||
|
|
87e1b7cf90 | ||
|
|
c0b8ae3937 | ||
|
|
23b88aa667 | ||
|
|
81d7c202de | ||
|
|
1603e76c88 | ||
|
|
65f5d0e45f |
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Executable
+79
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 (;;) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user