Compare commits

...
3 Commits
Author SHA1 Message Date
The412Banner cf6cf168b0 feat: x86-64 client follows DroidDeck-Components' fex.json; fuller x86-64 session logs (#1053)
FEX builds can reach devices without an app release: while the x86-64
client is selected the app reads DroidDeck-Components' fex.json (directly,
on its own thread, not in Offline Mode) and a session passes that build to
droiddeck-steam-x64, which installs it only once its sha256 matches and its
programs load, keeping the replaced files aside until the installed copy
loads too; it keeps a working installed build otherwise and falls back to
the build it pins. FEX is built and published by Components' build-fex.yml
on its single fex release; this repository's build-fex.yml only checks the
build. x86-64 session logs name the FEX build and its source, the rootfs and
the client's builds, report the rootfs download in tenths, and say in one
line when the web helper's GPU process keeps exiting, with x86 glxinfo and
vulkaninfo through FEX. Settings shows the FEX build installed.
2026-10-18 03:40:17 -04:00
The412Banner 7d21ba2233 fix: x86-64 Steam client fails without the KDE desktop (FEX r5 bundles libxxhash) (#1050)
FEX r4 links libxxhash, which the Linux runtime does not ship; devices
without the KDE desktop package (where the library came from) exited 127
at the x86-64 client's first FEX call. FEX r5 bundles it beside libFEXCore
with RUNPATH $ORIGIN/../lib, and the FEX build now fails when a program in
the package needs a library that is neither bundled nor listed in
tools/fex/runtime-libs.txt. droiddeck-steam-x64 moves to fex-2610-r5
(DroidDeck-Components #5). Progress log: the org review of #152 and this fix.
2026-10-18 02:00:29 -04:00
The412Banner ae94d9db54 feat: optional x86-64 Steam client (Valve's Linux client under FEX) beside the arm64 one (#152)
An opt-in second Steam client (Steam settings, Steam client: x86-64): Valve's
x86-64 Linux client, unchanged, runs under FEX in its own home with its own
game library and the same sign-in. Linux builds run directly, so titles such
as Team Fortress 2 and Half-Life use their Linux versions; Windows games use
DroidDeck-Proton as on arm64. The arm64 client stays the default.

- droiddeck-steam-x64: FEX and the x86-64 rootfs from DroidDeck-Components
  (sha256-pinned), client install and sync from Valve's CDN, sign-in shared
  with the arm64 client, its web helper on the GPU (ANGLE GL over FEX's GL
  thunk, zink, Turnip), Proton wrappers for the arm64 client's tools,
  DroidDeck direct for Linux builds, SD-card library (x86-64/ beside the
  arm64 one), repair.
- FEX 2610 built by build-fex.yml with thunks and three patches: an openat2
  fallback without openat2 (proot), newer-clang thunkgen, MAP_32BIT
  exhaustion returning ENOMEM. libblsession resolves RESOLVE_IN_ROOT paths
  inside the root.
- Session: x86-64 gating, shortcuts and compatibility tools per client,
  logs from the client that ran.
- App: client setting with the FEX version, an x86-64 chip on the Games
  header, the active client's library, art, storage, downloads, repair,
  Offline Mode, Decky, desktop mode, optional VAC diagnostics; per-client
  state for art, the content log, repair and leftovers.
- Strings in every locale.
- Review fixes: the library cache is saved only by the newest scan; an
  abandoned session's Steam logs come from the client it recorded; distinct
  VAC console names; FEXInterpreter in CPU pinning; clearer VAC hint in
  pl, ru, uk, cs and bg.
2026-10-17 23:57:52 -04:00
130 changed files with 5438 additions and 263 deletions

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
name: Build FEX (x86-64 Steam client)
# Checks tools/fex: the same build DroidDeck-Components' build-fex.yml runs from this repository to publish FEX
# (its single `fex` release and fex.json, which the app follows). Nothing is published from here.
on:
push:
paths:
- 'tools/fex/**'
- '.github/workflows/build-fex.yml'
workflow_dispatch:
inputs:
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)'
required: false
default: ''
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build in Arch Linux ARM
# Inputs reach the shell as environment variables, never pasted into the script.
env:
FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }}
FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }}
run: |
set -euo pipefail
docker run --rm -e FEX_EXTRA_CXXFLAGS="$FEX_EXTRA_CXXFLAGS" -e FEX_THUNKS="$FEX_THUNKS" -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
+10 -4
View File
@@ -26,7 +26,7 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v3
PRELOAD_CACHE_VERSION: v6
jobs:
build:
@@ -313,9 +313,15 @@ jobs:
set -euo pipefail
docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
set -euo pipefail
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
apt-get -o Acquire::Check-Valid-Until=false update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
install_compilers() {
apt-get -o Acquire::Check-Valid-Until=false update -qq &&
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
}
printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
if ! install_compilers; then
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
install_compilers
fi
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
'
sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
@@ -371,6 +371,7 @@ class MainActivity : ComponentActivity() {
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamClientArch by mutableStateOf("arm64")
private var runSteamAtStartup by mutableStateOf(false)
private var bootMovie by mutableStateOf(com.droiddeck.launcher.session.BootMovie.DROIDDECK_30)
private var bootMovieSound by mutableStateOf(true)
@@ -399,11 +400,13 @@ class MainActivity : ComponentActivity() {
}
private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
private var steamGamesX64 by mutableStateOf(false)
@Volatile private var componentSweepDone = false
@Volatile private var componentSweepSession: String? = null
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)
@@ -724,7 +727,9 @@ class MainActivity : ComponentActivity() {
theme = SessionPrefs.theme(this)
frameGenLabel = getString(R.string.frame_gen_off)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
val (cached, cachedX64) = com.droiddeck.launcher.frontend.LibraryCache.snapshot(this)
steamGames = if (shortcutPicker) emptyList() else cached
steamGamesX64 = cachedX64
backActionsInverted = SessionPrefs.backActionsInverted(this)
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
@@ -761,8 +766,9 @@ 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,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
@@ -911,6 +917,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)
@@ -1565,6 +1575,7 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1648,6 +1659,7 @@ class MainActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
@@ -1787,6 +1799,7 @@ class MainActivity : ComponentActivity() {
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamClientArch = SessionPrefs.steamClientArch(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
@@ -1863,6 +1876,12 @@ class MainActivity : ComponentActivity() {
}
private fun refresh() {
// Until the scan finishes, the other client's games would launch in this one.
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
steamGames = steamGames.filterNot(Library::fromSteamClient)
}
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
if (showComponents) drivers.refreshDrivers()
addedGamesArt = SessionPrefs.addedGamesArt(this)
@@ -1876,6 +1895,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)
@@ -1886,6 +1906,9 @@ class MainActivity : ComponentActivity() {
val scanGeneration = ++libraryScanGeneration
val artRefreshesAtStart = HashMap(steamArtRefreshes)
if (shortcutPicker) shortcutLibraryScanning = true
if (SessionPrefs.steamClientArch(this) == "x86_64" && !OfflineMode.enabled(this)) {
Thread({ com.droiddeck.launcher.session.FexCatalog.refresh(this) }, "fex-catalog").start()
}
Thread({
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
@@ -1902,9 +1925,11 @@ class MainActivity : ComponentActivity() {
}
}
}
val games = if (ready) Library.launchableGames(this) else emptyList()
val scannedArch = SessionPrefs.activeSteamClientArch(this)
val scannedX64 = scannedArch == "x86_64"
val games = if (ready) Library.launchableGames(this, arch = scannedArch) else emptyList()
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
if (ready && scanGeneration == libraryScanGeneration) com.droiddeck.launcher.frontend.LibraryCache.save(this, all, scannedX64)
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
if (ready && (!componentSweepDone || endedSession != null)) {
componentSweepDone = true
@@ -1914,6 +1939,7 @@ class MainActivity : ComponentActivity() {
ui.post {
if (scanGeneration == libraryScanGeneration) {
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
steamGamesX64 = scannedX64
steamGames = all.map { g ->
val newer = current[g.appId]
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
@@ -1211,7 +1211,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.frontend.Library.nameForProgram(program)?.let { getString(R.string.session_starting_named, it) }
?: getString(R.string.session_starting_program)
}
else -> getString(R.string.load_starting_steam)
else -> if (com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(this) == "x86_64") getString(R.string.load_starting_steam_x64)
else getString(R.string.load_starting_steam)
}
private fun shareCurrentSessionLogs() {
@@ -51,25 +51,29 @@ object LogRedactor {
@Volatile
private var accounts: List<Learned> = emptyList()
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Learned>()
try {
if (loginUsers.isFile) {
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
}
}
} catch (e: Exception) {
return
fun learnAccounts(vararg loginUsers: java.io.File) {
val names = LinkedHashSet<String>()
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
var unread = false
for (file in loginUsers) {
if (!file.isFile) continue
val text = try { file.readText() } catch (e: Exception) { unread = true; continue }
kv.findAll(text).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.forEach { names += it }
}
accounts = found
val learned = names.map { name ->
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
}
// A file that could not be read keeps the names learned from it before.
accounts = if (unread) (accounts + learned).distinctBy { it.regex.pattern } else learned
}
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"))
learnAccounts(
java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"),
java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf"),
)
}
@Volatile
@@ -139,6 +139,10 @@ object AddedGames {
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
)
/** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */
internal fun isSteamLibraryFolder(name: String): Boolean =
name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
GamesFolderMigration.run(context)
@@ -151,7 +155,7 @@ object AddedGames {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
if (folder.name.lowercase() in steamInstalls || isSteamLibraryFolder(folder.name)) continue
if (folder.path in removed) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
@@ -211,7 +215,7 @@ object AddedGames {
val base = canonical(dir)
if (!inside(path, base)) continue
val first = path.removePrefix("$base/").substringBefore('/')
File(dir, first).takeIf { it.isDirectory && first != "steamapps" }?.let { return it }
File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) }?.let { return it }
}
return null
}
@@ -271,7 +275,7 @@ object AddedGames {
)
for (dir in dirs.distinctBy { it.absolutePath }) {
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
if (folder.path in removed || folder.name.equals("steamapps", ignoreCase = true)) continue
if (folder.path in removed || isSteamLibraryFolder(folder.name)) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
out.add(canonical(folder))
}
@@ -312,7 +316,7 @@ object AddedGames {
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + autoName).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
val config = accountConfig(SessionPrefs.activeSteamRoot(context))
val record = config?.let { shortcutRecord(it, appId) }
val rev = record?.optInt("rev", 0) ?: 0
if (sidecar == null && record != null) adoptSteamEdits(context, folder, record)
@@ -348,7 +352,7 @@ object AddedGames {
}.getOrNull()
fun inSteam(context: Context, appId: Long): Boolean =
accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))?.let { shortcutRecord(it, appId) } != null
accountConfig(SessionPrefs.activeSteamRoot(context))?.let { shortcutRecord(it, appId) } != null
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
@@ -26,6 +26,7 @@ object AddedGamesPublisher {
fun republish(context: Context) {
val games = AddedGames.scan(context)
AddedGames.writeListing(context, games)
LibraryCache.save(context, Library.launchableGames(context, added = games))
val arch = com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(context)
LibraryCache.save(context, Library.launchableGames(context, added = games, arch = arch), arch == "x86_64")
}
}
@@ -38,7 +38,7 @@ object GameFileSync {
@Synchronized private fun sync(context: Context, folder: File) {
check(LinuxRuntime.isInstalled(context)) { context.getString(R.string.game_sync_err_runtime) }
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
check(File(SessionPrefs.activeSteamRoot(context), "steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
val storage = GameStorage.effective(context)
val storageSetting = SessionPrefs.gameStorage(context)
val previousStorage = prefs(context).getString("storagePath", null)
@@ -25,7 +25,7 @@ object GamesFolderMigration {
var n = 0
val steamInstalls = AddedGames.steamInstallDirs(root.host)
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (folder.name.equals("steamapps", ignoreCase = true) || folder.name.lowercase() in steamInstalls) continue
if (AddedGames.isSteamLibraryFolder(folder.name) || folder.name.lowercase() in steamInstalls) continue
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
@@ -74,11 +74,14 @@ object Library {
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
internal fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
val root = SessionPrefs.steamRoot(context, arch)
// The session gives the x86-64 client only its card folder; the games folders stay the arm64 client's.
val card = GameStorage.effective(context)
if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label })
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
card?.let { File(it.path) to it.label },
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
}
@@ -98,10 +101,10 @@ object Library {
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun steamGames(context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context)): List<SteamGame> {
val root = SessionPrefs.steamRoot(context, arch)
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val libraries = steamLibraries(context, arch)
val roots = libraries.map { it.first }
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
@@ -134,19 +137,24 @@ object Library {
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
return cache.listFiles().orEmpty().mapNotNull { dir ->
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
}
}
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM
fun launchableGames(
context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null,
arch: String = SessionPrefs.activeSteamClientArch(context),
): List<SteamGame> {
val roots = steamLibraries(context, arch).map { it.first }
val list = added ?: AddedGames.scan(context)
var steam = steamGames(context, strictRead)
val targets = steam.filter { it.library != ADDED && it.source == SOURCE_STEAM }.map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead)
var steam = steamGames(context, strictRead, arch)
val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch)
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
}
@@ -10,10 +10,14 @@ object LibraryCache {
private const val TAG = "LibraryCache"
private fun file(context: Context) = File(context.filesDir, "library-cache.json")
fun load(context: Context): List<Library.SteamGame> = runCatching {
/** The cached games and whether they are the x86-64 client's; a cache written as a bare list is arm64's. */
@Synchronized
fun snapshot(context: Context): Pair<List<Library.SteamGame>, Boolean> = runCatching {
val f = file(context)
if (!f.isFile) return emptyList()
val array = JSONArray(f.readText())
if (!f.isFile) return Pair(emptyList(), false)
val text = f.readText().trim()
val (array, x64) = if (text.startsWith("{")) JSONObject(text).let { it.getJSONArray("games") to (it.optString("client") == "x86_64") }
else JSONArray(text) to false
List(array.length()) { i ->
val o = array.getJSONObject(i)
Library.SteamGame(
@@ -24,11 +28,18 @@ object LibraryCache {
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
)
}
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
} to x64
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
fun save(context: Context, games: List<Library.SteamGame>) {
fun load(context: Context): List<Library.SteamGame> = snapshot(context).first
fun listedX64(context: Context): Boolean = snapshot(context).second
/** Without [x64] the list keeps the client already recorded. */
@Synchronized
fun save(context: Context, games: List<Library.SteamGame>, x64: Boolean? = null) {
runCatching {
val client = x64 ?: listedX64(context)
val array = JSONArray()
for (g in games) array.put(
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
@@ -38,8 +49,12 @@ object LibraryCache {
)
val f = file(context)
val tmp = File(f.path + ".tmp")
tmp.writeText(array.toString())
tmp.renameTo(f)
tmp.writeText(JSONObject().put("client", if (client) "x86_64" else "arm64").put("games", array).toString())
if (!tmp.renameTo(f)) {
tmp.delete()
f.delete()
Log.w(TAG, "not saved: rename failed; cache dropped so the next start scans")
}
}.onFailure { e -> Log.w(TAG, "not saved: ${e.message}") }
}
@@ -25,9 +25,16 @@ object SessionInstalls {
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
}.getOrNull()
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
internal fun steamappsDirs(context: Context): List<File> = (
if (com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(context) == "x86_64")
listOfNotNull(
File(com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context), "steamapps"),
GameStorage.x64CardLibrary(context)?.let { File(it, "steamapps") },
)
else listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
)
).filter { it.isDirectory }
internal class Watch(private val dirs: () -> List<File>) {
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.DataInputStream
@@ -20,7 +19,7 @@ object SteamAppInfo {
class Info(val name: String, val sharedDepots: List<String>)
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
fun appinfoFile(context: Context) = File(com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context), "appcache/appinfo.vdf")
@Synchronized
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
@@ -1,8 +1,8 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.stores.SteamLiveShortcuts
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
object SteamGameArt {
@@ -35,7 +35,7 @@ object SteamGameArt {
}
fun describe(context: Context, game: Library.SteamGame): List<AddedGameArt.SlotState> {
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/librarycache")
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
val own = withOverrides(context, game)
return AddedGameArt.Slot.values().map { slot ->
chosenFile(context, game.appId, slot)?.let {
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
@@ -28,8 +27,7 @@ object SteamGrid {
}
internal fun dir(context: Context): File? {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
return AddedGames.accountConfig(root)?.let { File(it, "grid") }
return AddedGames.accountConfig(SessionPrefs.activeSteamRoot(context))?.let { File(it, "grid") }
}
private fun files(grid: File, id: Long, slot: AddedGameArt.Slot): List<File> =
@@ -37,13 +35,18 @@ object SteamGrid {
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
private fun sigFile(target: Target, slot: AddedGameArt.Slot) = File(target.artDir, "grid-${slot.key}")
/** Each Steam client has its own grid folder, so what was last seen there is kept per client. */
private fun sigName(context: Context, key: String) =
if (SessionPrefs.activeSteamClientArch(context) == "x86_64") "grid-x86_64-$key" else "grid-$key"
private fun recorded(target: Target, slot: AddedGameArt.Slot): String? = sigFile(target, slot).takeIf { it.isFile }?.readText()
private fun sigFile(context: Context, target: Target, slot: AddedGameArt.Slot) = File(target.artDir, sigName(context, slot.key))
private fun record(target: Target, slot: AddedGameArt.Slot, value: String) {
private fun recorded(context: Context, target: Target, slot: AddedGameArt.Slot): String? =
sigFile(context, target, slot).takeIf { it.isFile }?.readText()
private fun record(context: Context, target: Target, slot: AddedGameArt.Slot, value: String) {
target.artDir.mkdirs()
sigFile(target, slot).writeText(value)
sigFile(context, target, slot).writeText(value)
}
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
@@ -59,7 +62,7 @@ object SteamGrid {
val grid = dir(context) ?: return true
if (file == null) {
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
record(target, slot, "")
record(context, target, slot, "")
return true
}
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
@@ -73,12 +76,12 @@ object SteamGrid {
return false
}
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
record(target, slot, signature(placed))
record(context, target, slot, signature(placed))
return true
}
private const val POSITION = "logo-position"
private const val POSITION_SIG = "grid-json"
private const val POSITION_SIG = "json"
private fun positionFile(grid: File, id: Long) = File(grid, "$id.json")
@@ -87,11 +90,12 @@ object SteamGrid {
/** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
private fun positionRecorded(target: Target): String? = File(target.artDir, POSITION_SIG).takeIf { it.isFile }?.readText()
private fun positionRecorded(context: Context, target: Target): String? =
File(target.artDir, sigName(context, POSITION_SIG)).takeIf { it.isFile }?.readText()
private fun recordPosition(target: Target, value: String) {
private fun recordPosition(context: Context, target: Target, value: String) {
target.artDir.mkdirs()
File(target.artDir, POSITION_SIG).writeText(value)
File(target.artDir, sigName(context, POSITION_SIG)).writeText(value)
}
private fun storePosition(target: Target, value: LogoPosition.Value?): Boolean {
@@ -115,7 +119,7 @@ object SteamGrid {
val next = if (value == null) existing?.let(LogoPosition::without) else LogoPosition.toJson(value, existing ?: JSONObject())
if (next == null) {
if (file.isFile && !file.delete()) return false
recordPosition(target, "")
recordPosition(context, target, "")
return true
}
grid.mkdirs()
@@ -125,7 +129,7 @@ object SteamGrid {
tmp.delete()
return false
}
recordPosition(target, signature(file))
recordPosition(context, target, signature(file))
return true
}
@@ -140,16 +144,16 @@ object SteamGrid {
if (hasUnknownPosition(file)) return
val theirs = positionIn(file)
val now = if (theirs != null) signature(file) else ""
val before = positionRecorded(target)
val before = positionRecorded(context, target)
val own = position(target)
when {
before == null && own != null && theirs == null -> placePosition(context, target, own)
before == null || now != before -> {
// Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
if (theirs != null) {
if (own == theirs || storePosition(target, theirs)) recordPosition(target, now)
if (own == theirs || storePosition(target, theirs)) recordPosition(context, target, now)
} else {
if (own == null || storePosition(target, null)) recordPosition(target, "")
if (own == null || storePosition(target, null)) recordPosition(context, target, "")
}
}
own != null && own != theirs -> placePosition(context, target, own)
@@ -169,20 +173,20 @@ object SteamGrid {
val present = files(grid, target.id, slot)
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
val now = signature(theirs)
val before = recorded(target, slot)
val before = recorded(context, target, slot)
when {
before == null && own != null && theirs == null -> write(context, target, slot, own)
before == null || now != before -> {
if (theirs != null) {
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(target, slot, now)
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(context, target, slot, now)
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
record(target, slot, now)
record(context, target, slot, now)
changed = true
}
} else {
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
record(target, slot, "")
record(context, target, slot, "")
}
}
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
@@ -154,7 +154,9 @@ object Lossless {
private fun steamDll(context: Context): File? {
val libraries = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
File(LinuxRuntime.rootDir(context), "root/.droiddeck-x64/.local/share/Steam"),
GameStorage.effective(context)?.path?.let(::File),
GameStorage.x64CardLibrary(context),
) + com.droiddeck.launcher.frontend.AddedGames.roots(context).map { it.host }
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
}
@@ -136,7 +136,7 @@ object SteamTouchConfig {
null
}
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun steamRoot(context: Context) = com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context)
fun configDir(context: Context): File? {
val base = File(steamRoot(context), "steamapps/common/Steam Controller Configs")
@@ -22,7 +22,11 @@ object DeckyManager {
private const val RELEASES = "https://api.github.com/repos/$REPOSITORY/releases?per_page=30"
private const val PATH = "root/homebrew/services/PluginLoader"
private const val VERSION = "root/homebrew/services/.droiddeck-decky-version"
private const val CEF_REMOTE_DEBUG_MARKER = "root/.local/share/Steam/.cef-enable-remote-debugging"
// droiddeck-session reads the marker from the root of the client it runs.
private val CEF_REMOTE_DEBUG_MARKERS = listOf(
"root/.local/share/Steam/.cef-enable-remote-debugging",
"root/.droiddeck-x64/.local/share/Steam/.cef-enable-remote-debugging",
)
@Volatile var downloadingBinary = false
private set
@@ -39,29 +43,34 @@ object DeckyManager {
fun loader(context: Context) = File(LinuxRuntime.rootDir(context), PATH)
fun supervisorEnabled(context: Context) = File(LinuxRuntime.rootDir(context), "root/.droiddeck-decky-enabled").isFile
private fun cefMarkers(context: Context, creating: Boolean = false) =
CEF_REMOTE_DEBUG_MARKERS.map { File(LinuxRuntime.rootDir(context), it) }.filter { marker ->
!creating || !marker.path.contains("/.droiddeck-x64/") ||
com.droiddeck.launcher.storage.SteamStorage.x64ClientPresent(marker.parentFile ?: marker)
}
fun syncCefMarker(context: Context) {
val marker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
if (!supervisorEnabled(context)) {
marker.delete()
cefMarkers(context).forEach { it.delete() }
return
}
runCatching {
marker.parentFile?.mkdirs()
marker.createNewFile()
for (marker in cefMarkers(context, creating = true)) {
runCatching {
marker.parentFile?.mkdirs()
marker.createNewFile()
}
}
}
fun setSupervisorEnabled(context: Context, enabled: Boolean) {
val marker = File(LinuxRuntime.rootDir(context), "root/.droiddeck-decky-enabled")
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
if (enabled) {
marker.parentFile?.mkdirs()
marker.writeText("enabled\n")
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile()
cefMarkers(context, creating = true).forEach { it.parentFile?.mkdirs(); it.createNewFile() }
} else {
marker.delete()
cefMarker.delete()
cefMarkers(context).forEach { it.delete() }
}
}
@@ -120,11 +129,12 @@ object DeckyManager {
if (!validElf(temp, release.machine)) { temp.delete(); return context.getString(R.string.deckymgr_not_elf) }
if (SessionState.running) { temp.delete(); return context.getString(R.string.deckymgr_session_started_loader) }
if (!temp.setExecutable(true, false)) { temp.delete(); return context.getString(R.string.deckymgr_loader_not_executable) }
val cefMarker = File(LinuxRuntime.rootDir(context), CEF_REMOTE_DEBUG_MARKER)
val cefReady = !supervisorEnabled(context) || cefMarker.isFile || runCatching {
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile() || cefMarker.isFile
}.getOrDefault(false)
val cefReady = !supervisorEnabled(context) || cefMarkers(context, creating = true).all { cefMarker ->
cefMarker.isFile || runCatching {
cefMarker.parentFile?.mkdirs()
cefMarker.createNewFile() || cefMarker.isFile
}.getOrDefault(false)
}
if (!cefReady) { temp.delete(); return context.getString(R.string.deckymgr_cef_failed) }
val staged = File(target.parentFile, "PluginLoader.new")
staged.delete()
@@ -140,7 +150,7 @@ object DeckyManager {
old.delete()
File(target.parentFile, ".droiddeck-decky-version").writeText(release.tag + "\n")
File(target.parentFile, ".droiddeck-decky-arch").writeText(if (release.machine == 183) "arm64\n" else "x86_64\n")
if (!supervisorEnabled(context)) cefMarker.delete()
if (!supervisorEnabled(context)) cefMarkers(context).forEach { it.delete() }
return null
}
@@ -272,7 +282,7 @@ object DeckyManager {
File(root, PATH).delete()
File(root, VERSION).delete()
File(root, "root/homebrew/services/.droiddeck-decky-arch").delete()
File(root, CEF_REMOTE_DEBUG_MARKER).delete()
CEF_REMOTE_DEBUG_MARKERS.forEach { File(root, it).delete() }
if (wipeData) File(root, "root/homebrew").deleteRecursively()
}
@@ -0,0 +1,71 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.Downloader
import org.json.JSONObject
import java.io.File
/**
* The FEX build DroidDeck-Components' fex.json names for the x86-64 client. A session uses the last copy read.
* Components' build-fex.yml replaces the archive on its single `fex` release and records the build's version;
* a release per build (fex-2610-r5) is still read.
*/
object FexCatalog {
private const val TAG = "FexCatalog"
const val CATALOG_URL = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/main/fex.json"
private const val RELEASES = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/"
private const val REFRESH_MS = 10 * 60 * 1000L
private val TAG_FORMAT = Regex("""fex-\d+-r\d+""")
private val VERSION_FORMAT = Regex("""\d+-r\d+""")
private val SHA256 = Regex("[0-9a-f]{64}")
data class Build(val tag: String, val url: String, val sha256: String)
@Volatile private var refreshedAt = 0L
private fun file(context: Context) = File(context.filesDir, "fex-catalog.json")
internal fun parse(text: String): Build? = runCatching {
val o = JSONObject(text)
val release = o.getString("release")
val url = o.getString("url")
val sha = o.getString("sha256").lowercase()
val version = o.optString("version")
val tag = when {
TAG_FORMAT.matches(release) -> release
release == "fex" && VERSION_FORMAT.matches(version) -> "fex-$version"
else -> return@runCatching null
}
Build(tag, url, sha).takeIf { url == "$RELEASES$release/fex.tzst" && SHA256.matches(sha) }
}.getOrNull()
fun cached(context: Context): Build? = runCatching { parse(file(context).readText()) }.getOrNull()
/** Reads fex.json straight from GitHub (a digest never comes from a mirror), at most every ten minutes. */
@Synchronized
fun refresh(context: Context, now: Long = System.currentTimeMillis()): Build? {
if (now - refreshedAt < REFRESH_MS) return cached(context)
refreshedAt = now
val text = Downloader.downloadDirect(CATALOG_URL) ?: return cached(context)
val build = parse(text) ?: run {
Log.w(TAG, "fex.json not usable; keeping the last one")
return cached(context)
}
runCatching {
val f = file(context)
val tmp = File(f.path + ".tmp")
tmp.writeText(text)
if (!tmp.renameTo(f)) tmp.delete()
}
return build
}
/** For droiddeck-steam-x64: the build and every source its archive may come from (it checks the sha256). */
fun environment(build: Build, sources: List<String> = DownloadSource.candidates(build.url)): List<String> = listOf(
"BL_FEX_TAG=${build.tag}",
"BL_FEX_SHA256=${build.sha256}",
"BL_FEX_URLS=" + sources.joinToString(" "),
)
}
@@ -50,6 +50,11 @@ object GameStorage {
}
}
const val X64_CARD_DIR = "x86-64"
const val X64_CARD_GUEST = "/mnt/droiddeck-sd/$X64_CARD_DIR"
fun x64CardLibrary(context: Context): File? = effective(context)?.let { File(it.path, X64_CARD_DIR) }
class Library(val host: File, val guest: String, val label: String)
fun gamesFolderLibraries(context: Context): List<Library> {
@@ -17,8 +17,13 @@ object OfflineMode {
private fun loginUsers(root: File) = File(root, "root/.local/share/Steam/config/loginusers.vdf")
private fun x64LoginUsers(root: File) = File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf")
fun account(context: Context): String? {
val file = loginUsers(LinuxRuntime.rootDir(context))
val root = LinuxRuntime.rootDir(context)
// Until its first start the x86-64 client has no sign-in; droiddeck-steam-x64 --sync-login copies the arm64 one.
val file = x64LoginUsers(root).takeIf { SessionPrefs.activeSteamClientArch(context) == "x86_64" && it.isFile }
?: loginUsers(root)
val text = try {
if (file.isFile) file.readText() else return null
} catch (e: Exception) {
@@ -36,7 +41,11 @@ object OfflineMode {
}
fun apply(context: Context, root: File) {
val file = loginUsers(root)
applyTo(context, loginUsers(root))
applyTo(context, x64LoginUsers(root))
}
private fun applyTo(context: Context, file: File) {
if (!file.isFile) return
val want = if (enabled(context)) "1" else "0"
try {
@@ -21,7 +21,45 @@ object SessionArtifacts {
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
fun collect(context: Context, dir: File, reason: String) {
private val collectingLock = java.util.concurrent.locks.ReentrantLock()
private val collectingDone = collectingLock.newCondition()
private val collecting = HashSet<File>()
/** Recovery leaves [dir] alone from the stop until [collect] has finished it. */
fun collectionStarted(dir: File) {
collectingLock.lock()
try { collecting.add(dir) } finally { collectingLock.unlock() }
}
private fun collectionDone(dir: File) {
collectingLock.lock()
try { collecting.remove(dir); collectingDone.signalAll() } finally { collectingLock.unlock() }
}
private fun inFlight(dir: File): Boolean {
collectingLock.lock()
try { return dir in collecting } finally { collectingLock.unlock() }
}
fun awaitCollections(timeoutMs: Long = 30_000) {
collectingLock.lock()
try {
var left = java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(timeoutMs)
while (collecting.isNotEmpty() && left > 0) left = collectingDone.awaitNanos(left)
} finally {
collectingLock.unlock()
}
}
fun collect(context: Context, dir: File, reason: String, steamArch: String? = null) {
try {
collectNow(context, dir, reason, steamArch)
} finally {
collectionDone(dir)
}
}
private fun collectNow(context: Context, dir: File, reason: String, steamArch: String?) {
try {
val wayland = File(dir, "wayland.log")
if (!wayland.exists()) {
@@ -31,7 +69,8 @@ object SessionArtifacts {
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
val record = Record(dir)
copySteamLogs(context, dir, record)
copySteamLogs(context, dir, record, steamArch)
VacDiagnostics.collect(context, dir)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
markScrubbed(scrubTree(record))
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
@@ -149,8 +188,18 @@ object SessionArtifacts {
return record
}
private fun copySteamLogs(context: Context, dir: File, record: Record) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
private const val CLIENT_FILE = "steam-client.txt"
fun recordClient(dir: File, arch: String) {
runCatching { File(dir, CLIENT_FILE).writeText("$arch\n") }
}
/** The Steam client a session ran, when it recorded one. */
internal fun recordedClient(dir: File): String? =
runCatching { File(dir, CLIENT_FILE).readText().trim() }.getOrNull()?.takeIf { it == "arm64" || it == "x86_64" }
private fun copySteamLogs(context: Context, dir: File, record: Record, steamArch: String? = null) {
val logs = File(steamArch?.let { SessionPrefs.steamRoot(context, it) } ?: 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 ->
@@ -171,7 +220,7 @@ object SessionArtifacts {
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.logDir(context)
val abandoned = parent.listFiles { f ->
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists() && !inFlight(f)
}?.sortedWith(SessionPaths.chronological) ?: return
if (abandoned.isEmpty()) return
val current = SessionPaths.current()
@@ -191,7 +240,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, recordedClient(dir))
VacDiagnostics.collect(context, dir)
}
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
markScrubbed(scrubTree(record))
@@ -72,6 +72,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",
"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",
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
"usr/local/bin/droiddeck-steamvr" to "usr/local/bin/droiddeck-steamvr",
"usr/local/bin/droiddeck-steamvr-settings" to "usr/local/bin/droiddeck-steamvr-settings",
"usr/local/bin/droiddeck-steamvr-game" to "usr/local/bin/droiddeck-steamvr-game",
@@ -194,13 +198,18 @@ object SessionFiles {
Log.e(TAG, "could not write steam-compatibility-label")
}
val startupMovieDir = File(root, "root/.local/share/Steam/config/uioverrides/movies")
val startupMovie = BootMovie.prepare(context)
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(root, "root/.local/share/Steam/config/config.vdf"))
// The x86-64 client's config is left alone until its first start creates it.
val steamRoots = listOf(File(root, "root/.local/share/Steam"), File(root, "root/.droiddeck-x64/.local/share/Steam"))
.filterIndexed { i, steam -> i == 0 || File(steam, "config").isDirectory }
for (steam in steamRoots) {
val startupMovieDir = File(steam, "config/uioverrides/movies")
val bigPictureMovieInstalled = startupMovie != null &&
stageStartupMovie(startupMovie, startupMovieDir, "bigpicture_startup.webm")
if (startupMovie != null) stageStartupMovie(startupMovie, startupMovieDir, "steam_os_startup.webm")
if (bigPictureMovieInstalled) {
ensureStartupMovieDefault(File(steam, "config/config.vdf"))
}
}
}
@@ -80,7 +80,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()
}
@@ -452,6 +452,12 @@ object SessionPrefs {
prefs(context).edit().putBoolean("storageDiagnostics", on).apply()
}
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()
}
private fun resolutionCap(context: Context, mode: String): Int = prefs(context).getInt("resolutionCap.$mode", 720)
@@ -499,6 +505,23 @@ object SessionPrefs {
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
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 running session's client, else the selected one: a changed setting applies from the next session. */
fun activeSteamClientArch(context: Context): String =
SessionState.steamClientArch?.takeIf { SessionState.running } ?: steamClientArch(context)
fun activeSteamRoot(context: Context): java.io.File = steamRoot(context, activeSteamClientArch(context))
fun steamRoot(context: Context, arch: String): java.io.File = java.io.File(
com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context),
if (arch == "x86_64") "root/.droiddeck-x64/.local/share/Steam" else "root/.local/share/Steam",
)
fun bootMovie(context: Context): String =
prefs(context).getString("bootMovie", BootMovie.DROIDDECK_30) ?: BootMovie.DROIDDECK_30
fun setBootMovie(context: Context, id: String) { prefs(context).edit().putString("bootMovie", id).apply() }
@@ -247,7 +247,10 @@ class SessionService : Service() {
SessionState.programArgs = intent?.getStringArrayExtra(EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent?.getStringExtra(EXTRA_STEAM_UI)
SessionState.steamUrl = intent?.getStringExtra(EXTRA_STEAM_URL)
SessionState.running = true
synchronized(stopLock) {
SessionState.steamClientArch = null
SessionState.running = true
}
if (SessionState.stopRequested) {
stopSession(0)
return START_NOT_STICKY
@@ -268,9 +271,9 @@ class SessionService : Service() {
return START_NOT_STICKY
}
private fun collectSessionArtifacts(dir: File) {
private fun collectSessionArtifacts(dir: File, steamArch: String?) {
try {
SessionArtifacts.collect(this, dir, "session stopped")
SessionArtifacts.collect(this, dir, "session stopped", steamArch)
} finally {
SessionLogCapture.stopFor(dir)
SessionPaths.release(this, dir)
@@ -317,6 +320,10 @@ class SessionService : Service() {
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
killStragglers()
SessionFiles.stage(this, root)
// Earlier sessions' reports still read the Steam logs and VAC marker this session replaces.
SessionArtifacts.awaitCollections()
SessionArtifacts.finishAbandoned(this)
VacDiagnostics.arm(this)
com.droiddeck.launcher.agent.AgentGuest.reset(this)
com.droiddeck.launcher.stores.gog.GogComet.begin()
com.droiddeck.launcher.stores.StoreLaunchRequests.start(this)
@@ -579,6 +586,12 @@ class SessionService : Service() {
guest.add("MESA_DISK_CACHE_DATABASE=1")
guest.add("GLIBC_TUNABLES=glibc.pthread.rseq=0:glibc.malloc.top_pad=16777216")
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
SessionState.steamClientArch = if (steamHere) SessionPrefs.steamClientArch(this) else null
SessionState.steamClientArch?.let { arch ->
guest.add("BL_STEAM_CLIENT_ARCH=$arch")
SessionPaths.current()?.let { SessionArtifacts.recordClient(it, arch) }
if (arch == "x86_64") FexCatalog.cached(this)?.let { guest.addAll(FexCatalog.environment(it)) }
}
if (SessionState.mode == MODE_STEAM) {
guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0"))
}
@@ -823,6 +836,40 @@ class SessionService : Service() {
}
}
}
val x64Libraries = org.json.JSONArray()
// Desktop mode offers the x86-64 client either way; droiddeck-steam-x64 drops library folders it is not given.
val x64Here = SessionPrefs.steamClientArch(this) == "x86_64" ||
(SessionState.mode == MODE_DESKTOP &&
com.droiddeck.launcher.storage.SteamStorage.x64ClientPresent(SessionPrefs.steamRoot(this, "x86_64")))
if (storageDiagnosticLibrary != null && library != null && x64Here) {
val x64 = File(library.path, GameStorage.X64_CARD_DIR)
val problem = GameStorage.prepare(this, x64.path)
if (problem != null) {
Log.w(TAG, "x86-64 steam library: $problem")
} else try {
val room = if (ProotFastPath.enabled(this)) {
ProotFastPath.MAX_BINDS - LinuxRuntime.binds(this, sessionRoot, runtimeDir, Environment.getExternalStorageDirectory(), binds).size
} else Int.MAX_VALUE
val extra = SecondaryLibrary.binds(filesDir, x64, listOf(GameStorage.X64_CARD_GUEST)).drop(1)
if (extra.size > room) {
Log.w(TAG, "x86-64 steam library: $x64 needs ${extra.size} binds, $room left for the fast path; not a library this session")
} else {
binds.addAll(extra)
x64Libraries.put(org.json.JSONObject().put("path", GameStorage.X64_CARD_GUEST).put("label", library.label.replace('"', ' ').replace('\\', ' ')))
Log.i(TAG, "x86-64 steam library: $x64 -> ${GameStorage.X64_CARD_GUEST} (\"${library.label}\")")
}
} catch (e: Exception) {
Log.w(TAG, "x86-64 steam library: $x64 private directories could not be prepared", e)
}
}
try {
val listing = File(filesDir, "session/steam-libraries-x64.json").apply { parentFile?.mkdirs() }
listing.writeText(x64Libraries.toString())
val envAt = guest.indexOf(LinuxRuntime.SESSION_SCRIPT).takeIf { it >= 0 } ?: guest.size
guest.add(envAt, "BL_X64_STEAM_LIBRARIES=" + listing.path)
} catch (e: Exception) {
Log.w(TAG, "x86-64 steam library: the list could not be written; internal storage alone this session", e)
}
try {
val listing = File(filesDir, "session/steam-libraries.json").apply { parentFile?.mkdirs() }
val json = org.json.JSONArray()
@@ -1356,9 +1403,10 @@ class SessionService : Service() {
private fun stopSession(status: Int) {
cancelQueuedSteamGame()
completeNativeSleep()
synchronized(stopLock) {
val (ended, endedArch) = synchronized(stopLock) {
if (!SessionState.running) return
SessionState.running = false
SessionPaths.take() to SessionState.steamClientArch
}
xrActivityVisible = false
updateDownloadMonitoring()
@@ -1383,8 +1431,11 @@ class SessionService : Service() {
SessionTerminal.clear(sessionGen)
val controller = suspendController
suspendController = null
val ended = SessionPaths.take()
if (ended != null) Thread({ collectSessionArtifacts(ended) }, "session-collect").start()
if (ended != null) {
VacDiagnostics.snapshot(this, ended)
SessionArtifacts.collectionStarted(ended)
Thread({ collectSessionArtifacts(ended, endedArch) }, "session-collect").start()
}
components.reversed().forEach {
try {
it.stop()
@@ -1584,6 +1635,7 @@ class SessionService : Service() {
downloadActive = steamDownloadMonitor.poll(
LinuxRuntime.rootDir(this@SessionService),
receivedBytes = TrafficStats.getUidRxBytes(android.os.Process.myUid()),
client = SessionState.steamClientArch,
)
if (wasActive != downloadActive) refreshNotification()
updateSuspendPolicy()
@@ -53,6 +53,8 @@ object SessionState {
var steamUi: String? = null
@Volatile
var steamUrl: String? = null
@Volatile
var steamClientArch: String? = null
// A Play SteamVR request can arrive while an existing session is visible.
// Wake the status/launch effect even if no glasses surface has attached yet.
var steamVr by mutableStateOf(false)
@@ -12,12 +12,13 @@ internal class SteamDownloadMonitor {
private var previous = emptyMap<String, ManifestState>()
private var lastProgressAt = 0L
private var contentLogOffset = 0L
private var contentLog: File? = null
private val activeContentUpdates = mutableSetOf<String>()
private var lastContentActivityAt = 0L
private var lastReceivedBytes = -1L
private var lastReceivedAt = 0L
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L): Boolean {
fun poll(runtimeRoot: File, now: Long = System.currentTimeMillis(), receivedBytes: Long = -1L, client: String? = null): Boolean {
val current = manifests(runtimeRoot).associate { it.path to read(it) }
val progressed = current.any { (path, state) ->
state.incomplete && state.bytesDownloaded > (previous[path]?.bytesDownloaded ?: state.bytesDownloaded)
@@ -27,7 +28,7 @@ internal class SteamDownloadMonitor {
val manifestActive = progressed || lastProgressAt > 0L && now - lastProgressAt <= MANIFEST_PROGRESS_GRACE_MS &&
current.values.any { it.incomplete }
if (receiving(receivedBytes, now)) lastContentActivityAt = now
return manifestActive || readContentActivity(runtimeRoot, now)
return manifestActive || readContentActivity(runtimeRoot, now, client)
}
private fun receiving(receivedBytes: Long, now: Long): Boolean {
@@ -43,16 +44,19 @@ internal class SteamDownloadMonitor {
previous = emptyMap()
lastProgressAt = 0L
contentLogOffset = 0L
contentLog = null
activeContentUpdates.clear()
lastContentActivityAt = 0L
lastReceivedBytes = -1L
lastReceivedAt = 0L
}
private fun readContentActivity(runtimeRoot: File, now: Long): Boolean {
val log = File(runtimeRoot, "root/.local/share/Steam/logs/content_log.txt")
private fun readContentActivity(runtimeRoot: File, now: Long, client: String?): Boolean {
val homes = client?.let { listOf(if (it == "x86_64") STEAM_HOMES[1] else STEAM_HOMES[0]) } ?: STEAM_HOMES
val log = homes.map { File(runtimeRoot, "$it/logs/content_log.txt") }.maxByOrNull { it.lastModified() }!!
if (!log.isFile) return false
if (log.length() < contentLogOffset) {
if (log != contentLog || log.length() < contentLogOffset) {
contentLog = log
contentLogOffset = 0L
activeContentUpdates.clear()
lastContentActivityAt = 0L
@@ -95,12 +99,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 ->
@@ -126,6 +133,7 @@ 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+\"([^\"]+)\"")
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""")
}
@@ -8,6 +8,7 @@ object SteamRepair {
private fun home(context: Context) = File(LinuxRuntime.rootDir(context), "root")
private fun request(context: Context) = File(home(context), ".bl-steam-repair")
private fun unconfirmed(context: Context) = File(home(context), ".local/share/Steam/package/droiddeck-unconfirmed")
private fun x64Unconfirmed(context: Context) = File(home(context), ".droiddeck-x64/.local/share/Steam/package/droiddeck-unconfirmed")
fun queued(context: Context): Boolean = request(context).exists()
@@ -16,6 +17,6 @@ object SteamRepair {
}.isSuccess
fun clientShown(context: Context) {
unconfirmed(context).delete()
(if (SessionPrefs.activeSteamClientArch(context) == "x86_64") x64Unconfirmed(context) else unconfirmed(context)).delete()
}
}
@@ -0,0 +1,223 @@
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
/**
* Source games state VAC-secure or insecure only in their console, which reaches a file (console.log in the
* mod folder) only with -condebug. The report quotes the lines it found and says inconclusive rather than guessing.
*/
object VacDiagnostics {
private const val TAG = "VacDiagnostics"
/**
* Checked by the launch wrapper. Lines: session start (epoch seconds), the Steam client it ran, then
* "length<tab>canonical path" for each console.log already there.
*/
private const val MARKER = "root/.droiddeck-vac-diagnostics"
private const val LAUNCHES = "root/.droiddeck-vac-launches"
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 refuses a VAC server with a dialog that never reaches the console.
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)
fun arm(context: Context) {
val root = LinuxRuntime.rootDir(context)
val marker = File(root, MARKER)
try {
File(root, LAUNCHES).delete()
if (enabled(context)) {
val arch = SessionPrefs.steamClientArch(context)
// -condebug appends, so each console.log's length now is where this session's lines start.
val sizes = consoleLogs(context, arch).joinToString("") { "${it.length()}\t${canonical(it)}\n" }
marker.writeText("${System.currentTimeMillis() / 1000}\n$arch\n$sizes")
} else marker.delete()
} catch (e: Exception) {
Log.w(TAG, "could not arm VAC diagnostics", e)
}
}
private const val SNAPSHOT_MARKER = ".vac-marker"
private const val SNAPSHOT_LAUNCHES = ".vac-launches"
/** Call when a session stops, before the next [arm] replaces its marker and launch list. */
fun snapshot(context: Context, dir: File) {
val root = LinuxRuntime.rootDir(context)
try {
File(root, MARKER).takeIf { it.isFile }?.copyTo(File(dir, SNAPSHOT_MARKER), overwrite = true) ?: return
File(root, LAUNCHES).takeIf { it.isFile }?.copyTo(File(dir, SNAPSHOT_LAUNCHES), overwrite = true)
} catch (e: Exception) {
Log.w(TAG, "could not keep this session's VAC diagnostics state", e)
}
}
fun collect(context: Context, dir: File) {
val root = LinuxRuntime.rootDir(context)
val kept = File(dir, SNAPSHOT_MARKER).takeIf { it.isFile }
val marker = kept ?: File(root, MARKER)
val launchList = if (kept != null) File(dir, SNAPSHOT_LAUNCHES) else File(root, LAUNCHES)
if (!marker.isFile) return
try {
val armed = marker.readLines().map { it.trim() }
val since = (armed.getOrNull(0)?.toLongOrNull() ?: 0L) * 1000 - 5_000
val arch = armed.getOrNull(1)?.takeIf { it.isNotEmpty() } ?: SessionPrefs.steamClientArch(context)
val armedSizes = armed.drop(2).mapNotNull { line ->
val (size, path) = line.split('\t', limit = 2).takeIf { it.size == 2 } ?: return@mapNotNull null
size.toLongOrNull()?.let { path to it }
}.toMap()
val out = File(dir, "vac").apply { mkdirs() }
val consoles = consoleLogs(context, arch).filter { writtenThisSession(it, since, armedSizes) }
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")
report.appendLine(" Client this session: " + if (arch == "x86_64") "x86-64 (Valve's x86-64 client under FEX)" else "arm64")
report.appendLine(" Files in its folder (/${SessionPrefs.steamRoot(context, arch).relativeTo(root).path}):")
clientFiles(context, arch).forEach { report.appendLine(" $it") }
report.appendLine()
report.appendLine("== Game launches this session (from the launch wrapper)")
val launches = launchList.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).")
}
val names = consoleNames(consoles)
consoles.forEachIndexed { i, log ->
val name = names[i]
val lines = tail(log, armedSizes[canonical(log)] ?: 0L)
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())
File(dir, SNAPSHOT_MARKER).delete()
File(dir, SNAPSHOT_LAUNCHES).delete()
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."
}
/** Source writes <game>/<mod>/console.log, Source 2 <game>/game/<mod>/console.log. */
private fun consoleLogs(context: Context, arch: String): List<File> {
val found = LinkedHashMap<String, File>()
for (library in libraryRoots(context, arch)) {
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[canonical(log)] = log
}
}
}
return found.values.toList()
}
private fun libraryRoots(context: Context, arch: String): List<File> {
val root = LinuxRuntime.rootDir(context)
val steam = SessionPrefs.steamRoot(context, arch)
val roots = mutableListOf(steam)
(if (arch == "x86_64") GameStorage.x64CardLibrary(context) else GameStorage.effective(context)?.let { File(it.path) })
?.let { roots.add(it) }
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) }
}
/** Two libraries can hold the same game and mod folder; a repeated name takes a tag from the log's path. */
internal fun consoleNames(logs: List<File>): List<String> {
val used = HashSet<String>()
return logs.map { log ->
val base = consoleName(log)
if (used.add(base)) base else "$base-%08x".format(canonical(log).hashCode()).also { used.add(it) }
}
}
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 canonical(file: File): String = runCatching { file.canonicalPath }.getOrDefault(file.path)
internal fun writtenThisSession(log: File, since: Long, armedSizes: Map<String, Long>): Boolean =
log.lastModified() >= since && log.length() != armedSizes[canonical(log)]
internal fun tail(file: File, from: Long = 0L): List<String> = try {
RandomAccessFile(file, "r").use { raf ->
val length = raf.length()
val since = from.takeIf { it <= length } ?: 0L
val start = maxOf(since, length - CONSOLE_TAIL_BYTES)
raf.seek(start)
val bytes = ByteArray((length - start).toInt())
raf.readFully(bytes)
String(bytes, Charsets.UTF_8).lines().let { if (start > since) it.drop(1) else it }
}
} catch (e: Exception) {
Log.w(TAG, "could not read ${file.name}", e)
emptyList()
}
private fun clientFiles(context: Context, arch: String): List<String> {
val steam = SessionPrefs.steamRoot(context, arch)
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)",
"steamrt64/steamclient.so" to "x86-64 Linux client (steamrt64)",
).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())
}
@@ -46,6 +46,10 @@ object SteamStorage {
)
}
/** The folder alone does not mean installed: Decky's CEF marker creates it. */
fun x64ClientPresent(root: File): Boolean = File(root, "steamrt64/steam").isFile ||
File(root, "package/droiddeck-installed").isFile || File(root, "steamapps").isDirectory
class Listing(val manifests: List<Manifest>, val otherIds: Set<Int>, val complete: Boolean) {
val ids: Set<Int> get() = manifests.map { it.appId }.toSet() + otherIds
}
@@ -116,6 +120,17 @@ object SteamStorage {
fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1)
private val ARM_IN_NAME = Regex("arm64|aarch64")
// The x86-64 client ignores tools named for arm64; droiddeck-steam-x64 names its wrappers with a64 instead.
fun x64ToolName(name: String): String = name.replace(ARM_IN_NAME, "a64")
class ToolUse(val mappings: Map<String, String>, val installed: Set<String>, val x64: Boolean = false) {
private fun key(tool: String) = if (x64) x64ToolName(tool) else tool
fun games(tool: String): Int = mappings.count { (app, name) -> name == key(tool) && app in installed }
fun isDefault(tool: String): Boolean = mappings["0"] == key(tool)
}
fun leftoverId(name: String): Int? = name.toLongOrNull()?.takeIf { it in 1 until (1L shl 31) }?.toInt()
fun isStubPrefix(prefix: File): Boolean = prefix.walkTopDown().onEnter { !java.nio.file.Files.isSymbolicLink(it.toPath()) }
@@ -11,6 +11,7 @@ import com.droiddeck.launcher.runtime.FlatpakManager
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.SecondaryLibrary
import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM, DROIDDECK }
@@ -18,13 +19,14 @@ enum class Bucket { GAMES, GAME_DATA, TOOLS, COMPONENTS, STORES, DESKTOP, SYSTEM
class GameUsage(
val appId: Int, val name: String, val art: File?, val library: String, val internal: Boolean, val lastPlayed: Long,
val game: Long, val prefix: Long, val shaders: Long, val workshop: Long, val shaderDir: File?,
val x64: Boolean = false,
) {
val data: Long get() = prefix + shaders + workshop
val total: Long get() = game + data
}
enum class ToolState { IN_USE, REQUIRED, UNUSED }
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN }
enum class ToolReason { DEFAULT_PROTON, PROTON, FEX_RUNTIME, ARM_RUNTIME, REDISTRIBUTABLES, FEX, STEAM_TOOL, OLD_RUNTIME, X86_PROTON, CUSTOM, CUSTOM_UNKNOWN, X64_CLIENT }
class ToolUsage(
val key: String, val name: String, val bytes: Long, val state: ToolState, val reason: ToolReason,
@@ -87,31 +89,51 @@ object StorageScan {
fun measure(file: File): Long = size(file).also { known[file.path] = it }
}
fun steamRoot(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
// Proton tools' files live only in the arm64 client's root; the x86-64 client has wrappers of them.
fun steamRoot(context: Context) = SessionPrefs.steamRoot(context, "arm64")
fun scan(context: Context): StorageReport {
val walker = Walker()
val files = context.filesDir
val rootfs = LinuxRuntime.rootDir(context)
val steamRoot = steamRoot(context)
val libraries = Library.steamLibraries(context)
val primary = runCatching { steamRoot.canonicalPath }.getOrDefault(steamRoot.path)
val art = runCatching { Library.steamGames(context) }.getOrDefault(emptyList()).associateBy { it.appId }
val x64Root = SessionPrefs.steamRoot(context, "x86_64").takeIf { SteamStorage.x64ClientPresent(it) }
val clients = listOfNotNull("arm64", "x86_64".takeIf { x64Root != null })
fun canonical(f: File) = runCatching { f.canonicalPath }.getOrDefault(f.path)
val art = clients.associate { arch ->
(arch == "x86_64") to runCatching { Library.steamGames(context, arch = arch) }.getOrDefault(emptyList()).associateBy { it.appId }
}
class Lib(val root: File, val label: String, val internal: Boolean, val data: File, val listing: SteamStorage.Listing?) {
class Lib(val root: File, val label: String, val internal: Boolean, val data: File, val listing: SteamStorage.Listing?, val x64: Boolean) {
val manifests: List<SteamStorage.Manifest>? get() = listing?.manifests
}
val libs = libraries.map { (root, label) ->
val internal = runCatching { root.canonicalPath }.getOrDefault(root.path) == primary
val data = if (internal) root else SecondaryLibrary.privateRoot(files, root)
Lib(root, label, internal, data, SteamStorage.manifests(root))
val libs = clients.flatMap { arch ->
val x64 = arch == "x86_64"
val primary = canonical(SessionPrefs.steamRoot(context, arch))
Library.steamLibraries(context, arch).map { (root, label) ->
val internal = canonical(root) == primary
val data = if (internal) root else SecondaryLibrary.privateRoot(files, root)
// The x86-64 card folder and its steamapps appear only with that client's first session and library write.
val listing = SteamStorage.manifests(root)
?: SteamStorage.Listing(emptyList(), emptySet(), true).takeIf {
x64 && (if (internal) !File(root, "steamapps").exists() else !root.exists() && root.parentFile?.isDirectory == true)
}
Lib(root, label, internal, data, listing, x64)
}
}
val everyLibraryRead = libs.all { it.listing?.complete == true }
val everyId = libs.flatMap { it.listing?.ids.orEmpty() }.toSet()
val mappings = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull()
val installedGames = libs.flatMap { it.manifests.orEmpty() }.filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet()
fun gamesOn(tool: String) = mappings.orEmpty().count { (app, name) -> name == tool && app in installedGames }
val defaultTool = mappings?.get("0")
val clientIds = libs.groupBy { it.x64 }.mapValues { (_, own) -> own.flatMap { it.listing?.ids.orEmpty() }.toSet() }
fun installedGames(x64: Boolean) = libs.filter { it.x64 == x64 }.flatMap { it.manifests.orEmpty() }
.filter { !SteamStorage.isTool(it) && it.installed }.map { it.appId.toString() }.toSet()
val armUse = runCatching { SteamStorage.compatMappings(File(steamRoot, "config/config.vdf").readText()) }.getOrNull()
?.let { SteamStorage.ToolUse(it, installedGames(false)) }
// An x86-64 client without a config.vdf has never mapped a game to anything.
val x64Vdf = x64Root?.let { File(it, "config/config.vdf") }
val x64Use = if (x64Vdf == null || !x64Vdf.exists()) SteamStorage.ToolUse(emptyMap(), emptySet(), x64 = true)
else runCatching { SteamStorage.ToolUse(SteamStorage.compatMappings(x64Vdf.readText()), installedGames(true), x64 = true) }.getOrNull()
val mappingsRead = armUse != null && x64Use != null
val uses = listOfNotNull(armUse, x64Use)
fun gamesOn(tool: String) = uses.sumOf { it.games(tool) }
val tools = LinkedHashMap<String, ToolUsage>()
val games = ArrayList<GameUsage>()
@@ -125,7 +147,7 @@ object StorageScan {
).filter { it.exists() }
val bytes = dirs.sumOf { walker.measure(it) }
val kind = SteamStorage.kind(m)
val (state, reason) = when (kind) {
val (state, reason) = if (lib.x64) ToolState.REQUIRED to ToolReason.X64_CLIENT else when (kind) {
SteamStorage.Kind.OLD_RUNTIME -> ToolState.UNUSED to ToolReason.OLD_RUNTIME
SteamStorage.Kind.X86_PROTON -> ToolState.UNUSED to ToolReason.X86_PROTON
SteamStorage.Kind.ARM_PROTON -> ToolState.IN_USE to (if (m.appId == SteamStorage.PROTON_ARM) ToolReason.DEFAULT_PROTON else ToolReason.PROTON)
@@ -137,12 +159,13 @@ object StorageScan {
else -> ToolReason.STEAM_TOOL
}
}
val count = when (m.appId) {
SteamStorage.PROTON_ARM -> gamesOn(SteamStorage.ARM_TOOL)
SteamStorage.PROTON_11_ARM -> gamesOn(SteamStorage.ARM_11_TOOL)
val count = when {
lib.x64 -> 0
m.appId == SteamStorage.PROTON_ARM -> gamesOn(SteamStorage.ARM_TOOL)
m.appId == SteamStorage.PROTON_11_ARM -> gamesOn(SteamStorage.ARM_11_TOOL)
else -> 0
}
val key = "app:${m.appId}"
val key = if (lib.x64) "x64:app:${m.appId}" else "app:${m.appId}"
val prior = tools[key]
tools[key] = ToolUsage(
key, m.name.ifEmpty { dir ?: m.appId.toString() }, bytes + (prior?.bytes ?: 0), state, reason, count,
@@ -156,10 +179,10 @@ object StorageScan {
val shaderDir = File(lib.data, "steamapps/shadercache/${m.appId}").takeIf { it.isDirectory }
val shaders = shaderDir?.let { walker.measure(it) } ?: 0
val workshop = File(lib.root, "steamapps/workshop/content/${m.appId}").takeIf { it.isDirectory }?.let { walker.measure(it) } ?: 0
val known = art[m.appId]
val known = art[lib.x64]?.get(m.appId)
games.add(GameUsage(
m.appId, known?.name ?: m.name, known?.art, lib.label, lib.internal, m.lastPlayed,
gameBytes, prefix, shaders, workshop, shaderDir,
gameBytes, prefix, shaders, workshop, shaderDir, lib.x64,
))
}
}
@@ -171,21 +194,22 @@ object StorageScan {
!File(dir, "toolmanifest.vdf").isFile) return@forEach
val name = runCatching { SteamStorage.compatToolName(File(dir, "compatibilitytool.vdf").readText()) }.getOrNull() ?: dir.name
val count = gamesOn(name)
val isDefault = defaultTool == name
val isDefault = uses.any { it.isDefault(name) }
tools["dir:${dir.name}"] = ToolUsage(
"dir:${dir.name}", dir.name, bytes,
when {
mappings == null -> ToolState.REQUIRED
!mappingsRead -> ToolState.REQUIRED
count > 0 || isDefault -> ToolState.IN_USE
else -> ToolState.UNUSED
},
if (mappings == null) ToolReason.CUSTOM_UNKNOWN else ToolReason.CUSTOM, count, isDefault, emptyList(), listOf(dir),
if (!mappingsRead) ToolReason.CUSTOM_UNKNOWN else ToolReason.CUSTOM, count, isDefault, emptyList(), listOf(dir),
)
}
val leftovers = ArrayList<Leftover>()
for (lib in libs) {
if (!everyLibraryRead) break
val everyId = clientIds[lib.x64].orEmpty()
val prefixes = File(lib.data, "steamapps/compatdata").listFiles().orEmpty()
.mapNotNull { f -> SteamStorage.leftoverId(f.name)?.takeIf { it !in everyId && f.isDirectory }?.let { it to f } }.toMap()
val caches = File(lib.data, "steamapps/shadercache").listFiles().orEmpty()
@@ -221,24 +245,26 @@ object StorageScan {
File(lib.data, "steamapps/compatdata").listFiles().orEmpty().filter { it.isDirectory && SteamStorage.leftoverId(it.name) == null }
}.sumOf { walker.measure(it) }
val apps = File(steamRoot, "steamapps")
val clientRoots = listOfNotNull(steamRoot, x64Root)
val apps = clientRoots.map { File(it, "steamapps") }
val privateRoots = File(files, "steam-libraries").listFiles().orEmpty().filter { it.isDirectory }
fun m(file: File): Long = if (file.exists()) walker.measure(file) else 0
fun inApps(name: String): Long = apps.sumOf { m(File(it, name)) }
val toolDirs = tools.values.flatMap { it.dirs }.distinctBy { it.path }
val toolsInApps = toolDirs.filter { it.path.startsWith(apps.path + "/") }.sumOf { walker.known[it.path] ?: 0 }
val toolsInApps = toolDirs.filter { d -> apps.any { d.path.startsWith(it.path + "/") } }.sumOf { walker.known[it.path] ?: 0 }
val toolsPrivate = toolDirs.filter { d -> privateRoots.any { d.path.startsWith(it.path + "/") } }.sumOf { walker.known[it.path] ?: 0 }
val compatTools = m(File(steamRoot, "compatibilitytools.d"))
val installs = m(File(apps, "common")) + m(File(apps, "downloading")) + m(File(apps, "temp"))
val compatTools = clientRoots.sumOf { m(File(it, "compatibilitytools.d")) }
val installs = inApps("common") + inApps("downloading") + inApps("temp")
val privatePrefixes = privateRoots.sumOf { m(File(it, "steamapps/compatdata")) }
val privateShaders = privateRoots.sumOf { m(File(it, "steamapps/shadercache")) }
val prefixes = m(File(apps, "compatdata")) + privatePrefixes
val shaders = m(File(apps, "shadercache")) + privateShaders
val workshop = m(File(apps, "workshop"))
val prefixes = inApps("compatdata") + privatePrefixes
val shaders = inApps("shadercache") + privateShaders
val workshop = inApps("workshop")
val stores = m(File(rootfs, "root/Games"))
val flatpak = m(File(rootfs, "root/.local/share/flatpak"))
val rootfsBytes = m(rootfs)
val privateBytes = privateRoots.sumOf { m(it) }
val system = rootfsBytes - installs - m(File(apps, "compatdata")) - m(File(apps, "shadercache")) - workshop -
val system = rootfsBytes - installs - inApps("compatdata") - inApps("shadercache") - workshop -
compatTools - stores - flatpak
val componentBytes = m(componentsDir)
val droiddeck = m(files) + m(context.cacheDir) - rootfsBytes - privateBytes - componentBytes +
@@ -134,13 +134,15 @@ object StorageState {
}, "storage-$name").start()
}
fun clearShaders(context: Context, appId: Int) = act(context, "shaders-$appId", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == appId }?.shaderDir ?: return@act false
// A game can be installed in both Steam clients, and an id can be left over in several libraries.
fun clearShaders(context: Context, appId: Int, x64: Boolean = false) = act(context, "shaders-$appId", { _, fresh ->
val dir = fresh.games.firstOrNull { it.appId == appId && it.x64 == x64 }?.shaderDir ?: return@act false
FileOps.delete(dir)
})
fun removeLeftover(context: Context, appId: Int) = act(context, "leftover-$appId", { _, fresh ->
val leftover = fresh.leftovers.firstOrNull { it.appId == appId } ?: return@act false
fun removeLeftover(context: Context, l: Leftover) = act(context, "leftover-${l.appId}", { _, fresh ->
val leftover = fresh.leftovers.firstOrNull { it.appId == l.appId && it.prefix?.path == l.prefix?.path && it.shaders?.path == l.shaders?.path }
?: return@act false
deleteLeftover(leftover)
true
})
@@ -58,7 +58,8 @@ object StoreInstalls {
try {
val games = AddedGames.scan(app)
AddedGames.writeListing(app, games)
LibraryCache.save(app, Library.launchableGames(app, added = games))
val arch = SessionPrefs.activeSteamClientArch(app)
LibraryCache.save(app, Library.launchableGames(app, added = games, arch = arch), arch == "x86_64")
SteamLiveShortcuts.sync(app, games)
} catch (e: Exception) {
Log.w(TAG, "registration: ${e.message}")
@@ -87,7 +87,12 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
}
if (current == null || gameCount == 0) {
Column(modifier = modifier.padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
Rise(0) { PageHeader(stringResource(R.string.content_games)) { onAdd?.let { AddGameButton(it) } } }
Rise(0) {
PageHeader(stringResource(R.string.content_games)) {
if (s.steamGamesX64) ClientTag()
onAdd?.let { AddGameButton(it) }
}
}
Rise(1) { Note(stringResource(if (s.shortcutPicker && s.shortcutLibraryScanning) R.string.game_shortcut_scanning else R.string.games_empty)) }
if (!s.shortcutPicker) Rise(2) {
Actions {
@@ -102,7 +107,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val host = rememberMenuHost()
if (games.size == 1) {
Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
if (onAdd != null) Rise(0) { GamesHeader(gameCount, onAdd, Modifier.padding(bottom = 10.dp)) }
if (onAdd != null) Rise(0) { GamesHeader(gameCount, s.steamGamesX64, onAdd, Modifier.padding(bottom = 10.dp)) }
Rise(0) {
Row(verticalAlignment = Alignment.Bottom) {
GameHero(current, Modifier.weight(1f).heightIn(min = if (narrow) 190.dp else 250.dp)) {
@@ -120,7 +125,7 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
}
val pal = LocalPalette.current
Row(modifier = modifier) {
GameList(games, gameCount, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = launch, onAdd = onAdd,
GameList(games, gameCount, s.steamGamesX64, current, onSelect = { onSelect("app:${it.appId}") }, onLaunch = launch, onAdd = onAdd,
modifier = Modifier.width(if (narrow) 168.dp else 250.dp).fillMaxHeight())
Box(Modifier.width(1.dp).fillMaxHeight().background(pal.line))
Column(
@@ -328,7 +333,7 @@ private fun ColumnScope.GameHeroCopy(g: Library.SteamGame, titleSize: androidx.c
@Composable
private fun GameList(
games: List<Library.SteamGame>, count: Int, current: Library.SteamGame,
games: List<Library.SteamGame>, count: Int, x64: Boolean, current: Library.SteamGame,
onSelect: (Library.SteamGame) -> Unit, onLaunch: (Library.SteamGame) -> Unit, onAdd: (() -> Unit)?, modifier: Modifier,
) {
val colors = MaterialTheme.colorScheme
@@ -336,7 +341,7 @@ private fun GameList(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
) {
GamesHeader(count, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
GamesHeader(count, x64, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
val seen = remember { HashSet<Int>() }
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
for (g in games) key(g.appId) {
@@ -352,15 +357,25 @@ private fun GameList(
}
@Composable
private fun GamesHeader(count: Int, onAdd: (() -> Unit)?, modifier: Modifier) {
private fun GamesHeader(count: Int, x64: Boolean, onAdd: (() -> Unit)?, modifier: Modifier) {
val colors = MaterialTheme.colorScheme
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier) {
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(count.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant)
if (onAdd != null) AddGameButton(onAdd)
val narrow = LocalNarrowPane.current
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.content_games), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1)
Text(count.toString(), fontSize = 13.sp, color = colors.onSurfaceVariant)
if (x64 && !narrow) ClientTag()
if (onAdd != null) AddGameButton(onAdd)
}
if (x64 && narrow) Box(Modifier.padding(top = 4.dp)) { ClientTag() }
}
}
@Composable
private fun ClientTag() {
Box(Modifier.testTag("games-client-x64")) { StateChip(stringResource(R.string.mode_client_arch_x64), ChipTone.PLAIN) }
}
internal object GamesDialogState {
var adding by androidx.compose.runtime.mutableStateOf(false)
var summary by androidx.compose.runtime.mutableStateOf<Pair<List<com.droiddeck.launcher.frontend.AddedGames.Game>, Int>?>(null)
@@ -98,8 +98,10 @@ class FrontEndState(
val frameGenLabel: String,
val romsDir: String?,
val logsEnabled: Boolean,
val vacDiagnostics: Boolean = false,
val agentCommands: Boolean = false,
val steamGames: List<Library.SteamGame>,
val steamGamesX64: Boolean = false,
val emulators: List<Library.Emulator>,
val running: String?,
val frameGen: FrameGen.Mode = FrameGen.Mode.OFF,
@@ -193,6 +195,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 = {},
@@ -436,6 +436,7 @@ internal fun SetupPanel(
4 -> {
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
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)
@@ -80,6 +80,7 @@ class ModeSettings(
val syncBackend: String? = null,
val picturePosition: String? = null,
val stretch16x9: Boolean? = null,
val steamClientArch: String? = null,
val steamRepairQueued: Boolean = false,
val mangoapp: Boolean = true,
val steamController: String? = null,
@@ -130,6 +131,7 @@ class ModeSettingsActions(
val onPicturePosition: (String) -> Unit = {},
val onStretch16x9: (Boolean) -> Unit = {},
val onSteamRepair: () -> Unit = {},
val onSteamClientArch: (String) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onUnfoldedControls: (Boolean) -> Unit = {},
@@ -148,6 +150,29 @@ class ModeSettingsActions(
val onDismiss: () -> Unit,
)
/** "2610 r3" from droiddeck-steam-x64's FEX_TAG line (fex-2610-r3), the build the x86-64 client downloads. */
internal fun fexVersionOf(script: String): String? =
Regex("""(?m)^FEX_TAG=(fex-\d+-r\d+)\s*$""").find(script)?.let { fexVersionOfTag(it.groupValues[1]) }
/** "2610 r5" from a tag such as fex-2610-r5. */
internal fun fexVersionOfTag(tag: String): String? =
Regex("""fex-(\d+)-r(\d+)""").matchEntire(tag.trim())?.let { "${it.groupValues[1]} r${it.groupValues[2]}" }
/** The build installed in the runtime, else the one fex.json names, else the one the launcher pins. */
@Composable
private fun x64FexVersion(context: android.content.Context): String? = remember {
val root = com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context)
runCatching { java.io.File(root, "usr/local/share/fex-emu/.droiddeck-tag").readText() }.getOrNull()?.let(::fexVersionOfTag)
?: com.droiddeck.launcher.session.FexCatalog.cached(context)?.tag?.let(::fexVersionOfTag)
?: runCatching {
context.assets.open("linuxfs/usr/local/bin/droiddeck-steam-x64").bufferedReader().use { fexVersionOf(it.readText()) }
}.getOrNull()
}
@Composable
private fun clientArchHint(hint: String, fex: String?): String =
if (fex == null) hint else hint + " " + stringResource(R.string.mode_client_arch_fex, fex)
internal fun modeSheetTabs(steam: Boolean): List<String> =
if (steam) listOf(SheetTab.STEAM, SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.AUDIO, SheetTab.SESSION, SheetTab.LIBRARY)
else listOf(SheetTab.DISPLAY, SheetTab.CONTROLS, SheetTab.SESSION)
@@ -435,6 +460,12 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
) {
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),
clientArchHint(stringResource(R.string.common_applies_next_session), x64FexVersion(LocalContext.current)),
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 (s.bootMovie != null) SettingsGroup(stringResource(R.string.boot_movie)) {
ChoiceRow(
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.storage.Bucket
import com.droiddeck.launcher.storage.CleanItem
import com.droiddeck.launcher.storage.ComponentUsage
@@ -469,7 +470,7 @@ private fun GamesView(report: StorageReport, sessionRunning: Boolean, a: FrontEn
if (g.art != null) AsyncImage(model = g.art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(42.dp))
}
Column(Modifier.weight(1.3f)) {
Text(g.name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(gameName(g), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(
g.library.takeIf { !g.internal },
@@ -499,9 +500,20 @@ private fun GamesView(report: StorageReport, sessionRunning: Boolean, a: FrontEn
Spacer(Modifier.height(8.dp))
Note(stringResource(R.string.storage_games_note))
confirm?.let { g ->
AlertDialog(
// steam://uninstall reaches only the session's client, the selected one.
val otherClient = g.x64 != (SessionPrefs.steamClientArch(LocalContext.current) == "x86_64")
if (otherClient) AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(stringResource(R.string.storage_uninstall_title, g.name)) },
title = { Text(stringResource(R.string.storage_uninstall_title, gameName(g))) },
text = { Text(stringResource(if (g.x64) R.string.storage_uninstall_in_x64 else R.string.storage_uninstall_in_arm64), fontSize = 14.sp) },
confirmButton = {
val ok = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { ok.requestFocus() } }
FocusText(stringResource(R.string.common_ok), pal.signal, modifier = Modifier.focusRequester(ok)) { confirm = null }
},
) else AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(stringResource(R.string.storage_uninstall_title, gameName(g))) },
text = { Text(stringResource(R.string.storage_uninstall_body, gb(g.game)), fontSize = 14.sp) },
confirmButton = { FocusText(stringResource(R.string.storage_open_steam), pal.signal) { confirm = null; a.onSteamUrl("steam://uninstall/${g.appId}") } },
dismissButton = {
@@ -513,6 +525,9 @@ private fun GamesView(report: StorageReport, sessionRunning: Boolean, a: FrontEn
}
}
@Composable
private fun gameName(g: GameUsage): String = if (g.x64) stringResource(R.string.storage_game_x64, g.name) else g.name
@Composable
private fun ToolsView(report: StorageReport, sessionRunning: Boolean, onBack: () -> Unit) {
val ctx = LocalContext.current
@@ -583,6 +598,7 @@ private fun toolDetail(t: ToolUsage): String = when (t.reason) {
ToolReason.OLD_RUNTIME -> stringResource(R.string.storage_tool_old_runtime)
ToolReason.X86_PROTON -> stringResource(R.string.storage_tool_x86_proton)
ToolReason.CUSTOM_UNKNOWN -> stringResource(R.string.storage_tool_custom_unknown)
ToolReason.X64_CLIENT -> stringResource(R.string.storage_tool_x64_client)
ToolReason.CUSTOM -> when {
t.isDefault -> stringResource(R.string.storage_tool_custom_default)
t.games > 0 -> pluralStringResource(R.plurals.storage_tool_custom_games, t.games, t.games)
@@ -722,8 +738,8 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack:
if (games.isEmpty()) Text(stringResource(R.string.storage_no_games), fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(16.dp))
games.forEachIndexed { i, g ->
ItemRow(
g.name, stringResource(R.string.storage_data_parts, gb(g.prefix), gb(g.shaders), gb(g.workshop)), g.data, i == games.lastIndex,
action = { SecondaryButton(stringResource(R.string.storage_clear_shaders), compact = true, enabled = !busy && g.shaders >= ONE_MB) { StorageState.clearShaders(ctx, g.appId) } },
gameName(g), stringResource(R.string.storage_data_parts, gb(g.prefix), gb(g.shaders), gb(g.workshop)), g.data, i == games.lastIndex,
action = { SecondaryButton(stringResource(R.string.storage_clear_shaders), compact = true, enabled = !busy && g.shaders >= ONE_MB) { StorageState.clearShaders(ctx, g.appId, g.x64) } },
)
}
}
@@ -759,7 +775,7 @@ private fun GameDataView(report: StorageReport, sessionRunning: Boolean, onBack:
ConfirmDialog(
stringResource(R.string.storage_remove_title, stringResource(R.string.storage_steam_app, l.appId)),
stringResource(R.string.storage_leftover_confirm, gb(l.bytes)), stringResource(R.string.storage_remove),
onConfirm = { StorageState.removeLeftover(ctx, l.appId) }, onDismiss = { confirm = null },
onConfirm = { StorageState.removeLeftover(ctx, l) }, onDismiss = { confirm = null },
)
}
}
+11
View File
@@ -2436,6 +2436,17 @@
<string name="stores_sync_gog_unreachable">تعذّر الاتصال بـ GOG</string>
<string name="stores_sync_gog_unreadable">تعذّر قراءة مكتبتك على GOG</string>
<string name="stores_registering">جارٍ التسجيل في Steam…</string>
<string name="mode_client_arch">عميل Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">تشخيص VAC</string>
<string name="setup_vac_diag_hint">تسجّل ألعاب Source وحدة التحكم الخاصة بها (-condebug)؛ ويذكر vac.txt في سجلات الجلسة إن كانت آمنة أو غير آمنة</string>
<string name="load_starting_steam_x64">جارٍ تشغيل Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">يستخدمه عميل Steam x86-64</string>
<string name="storage_uninstall_in_x64">هذه النسخة موجودة في عميل Steam x86-64. اختر x86-64 في عميل Steam، ثم ألغِ تثبيتها هنا.</string>
<string name="storage_uninstall_in_arm64">هذه النسخة موجودة في عميل Steam ARM64. اختر ARM64 في عميل Steam، ثم ألغِ تثبيتها هنا.</string>
<string name="drawer_page_glasses">النظارة</string>
<string name="glasses_open">نظارة VITURE</string>
<string name="glasses_control">التحكم في النظارة</string>
@@ -2384,6 +2384,17 @@
<string name="stores_sync_gog_unreachable">No se pudo conectar con GOG</string>
<string name="stores_sync_gog_unreadable">No se pudo leer tu biblioteca de GOG</string>
<string name="stores_registering">Registrando con Steam…</string>
<string name="mode_client_arch">Cliente de Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</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 de sesión dice si es seguro o no</string>
<string name="load_starting_steam_x64">Iniciando Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Lo usa el cliente de Steam x86-64</string>
<string name="storage_uninstall_in_x64">Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí.</string>
<string name="storage_uninstall_in_arm64">Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí.</string>
<string name="drawer_page_glasses">Gafas</string>
<string name="glasses_open">Gafas VITURE</string>
<string name="glasses_control">Control de las gafas</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Неуспешно свързване с GOG</string>
<string name="stores_sync_gog_unreadable">Библиотеката ви в GOG не можа да се прочете</string>
<string name="stores_registering">Регистриране в Steam…</string>
<string name="mode_client_arch">Клиент на Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Диагностика на VAC</string>
<string name="setup_vac_diag_hint">Игрите на Source записват конзолата си (-condebug); vac.txt в дневниците на сесията показва дали играта е защитена от VAC</string>
<string name="load_starting_steam_x64">Стартиране на Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Използва се от клиента на Steam x86-64</string>
<string name="storage_uninstall_in_x64">Това копие е в клиента на Steam x86-64. Изберете x86-64 в „Клиент на Steam“, след което го деинсталирайте тук.</string>
<string name="storage_uninstall_in_arm64">Това копие е в клиента на Steam ARM64. Изберете ARM64 в „Клиент на Steam“, след което го деинсталирайте тук.</string>
<string name="drawer_page_glasses">Очила</string>
<string name="glasses_open">Очила VITURE</string>
<string name="glasses_control">Управление на очилата</string>
+11
View File
@@ -2386,6 +2386,17 @@
<string name="stores_sync_gog_unreachable">Nepodařilo se spojit s GOG</string>
<string name="stores_sync_gog_unreadable">Vaši knihovnu GOG se nepodařilo načíst</string>
<string name="stores_registering">Registrace ve Steamu…</string>
<string name="mode_client_arch">Klient Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnostika VAC</string>
<string name="setup_vac_diag_hint">Hry na Source zaznamenávají svou konzoli (-condebug); vac.txt v protokolech relace uvádí, zda je hra chráněna VAC</string>
<string name="load_starting_steam_x64">Spouštění Steamu x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Používá klient Steam x86-64</string>
<string name="storage_uninstall_in_x64">Tato kopie je v klientovi Steam x86-64. V nastavení Klient Steam vyberte x86-64 a pak ji odinstalujte tady.</string>
<string name="storage_uninstall_in_arm64">Tato kopie je v klientovi Steam ARM64. V nastavení Klient Steam vyberte ARM64 a pak ji odinstalujte tady.</string>
<string name="drawer_page_glasses">Brýle</string>
<string name="glasses_open">Brýle VITURE</string>
<string name="glasses_control">Ovládání brýlí</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Kunne ikke oprette forbindelse til GOG</string>
<string name="stores_sync_gog_unreadable">Dit GOG-bibliotek kunne ikke læses</string>
<string name="stores_registering">Registrerer hos Steam…</string>
<string name="mode_client_arch">Steam-klient</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-diagnostik</string>
<string name="setup_vac_diag_hint">Source-spil logger deres konsol (-condebug); vac.txt i sessionslogs viser, om det er sikret eller ej</string>
<string name="load_starting_steam_x64">Starter Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Bruges af x86-64-Steam-klienten</string>
<string name="storage_uninstall_in_x64">Denne kopi ligger i x86-64-Steam-klienten. Vælg x86-64 under Steam-klient, og afinstaller den så her.</string>
<string name="storage_uninstall_in_arm64">Denne kopi ligger i ARM64-Steam-klienten. Vælg ARM64 under Steam-klient, og afinstaller den så her.</string>
<string name="drawer_page_glasses">Brille</string>
<string name="glasses_open">VITURE-brille</string>
<string name="glasses_control">Brillestyring</string>
+11
View File
@@ -2322,6 +2322,17 @@
<string name="stores_sync_gog_unreachable">GOG konnte nicht erreicht werden</string>
<string name="stores_sync_gog_unreadable">Deine GOG-Bibliothek konnte nicht gelesen werden</string>
<string name="stores_registering">Wird bei Steam registriert…</string>
<string name="mode_client_arch">Steam-Client</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-Diagnose</string>
<string name="setup_vac_diag_hint">Source-Spiele protokollieren ihre Konsole (-condebug); vac.txt in den Sitzungslogs zeigt gesichert oder ungesichert</string>
<string name="load_starting_steam_x64">Steam x86-64 wird gestartet</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Wird vom x86-64-Steam-Client verwendet</string>
<string name="storage_uninstall_in_x64">Diese Kopie liegt im x86-64-Steam-Client. Wähle x86-64 unter Steam-Client und deinstalliere sie dann hier.</string>
<string name="storage_uninstall_in_arm64">Diese Kopie liegt im ARM64-Steam-Client. Wähle ARM64 unter Steam-Client und deinstalliere sie dann hier.</string>
<string name="drawer_page_glasses">Brille</string>
<string name="glasses_open">VITURE-Brille</string>
<string name="glasses_control">Brillensteuerung</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Δεν ήταν δυνατή η σύνδεση με το GOG</string>
<string name="stores_sync_gog_unreadable">Δεν ήταν δυνατή η ανάγνωση της βιβλιοθήκης σας στο GOG</string>
<string name="stores_registering">Εγγραφή στο Steam…</string>
<string name="mode_client_arch">Πελάτης Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Διαγνωστικά VAC</string>
<string name="setup_vac_diag_hint">Τα παιχνίδια Source καταγράφουν την κονσόλα τους (-condebug)· το vac.txt στα αρχεία καταγραφής συνεδρίας δείχνει αν είναι ασφαλές ή όχι</string>
<string name="load_starting_steam_x64">Εκκίνηση του Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Χρησιμοποιείται από τον πελάτη Steam x86-64</string>
<string name="storage_uninstall_in_x64">Αυτό το αντίγραφο βρίσκεται στον πελάτη Steam x86-64. Επιλέξτε x86-64 στο «Πελάτης Steam» και, στη συνέχεια, απεγκαταστήστε το εδώ.</string>
<string name="storage_uninstall_in_arm64">Αυτό το αντίγραφο βρίσκεται στον πελάτη Steam ARM64. Επιλέξτε ARM64 στο «Πελάτης Steam» και, στη συνέχεια, απεγκαταστήστε το εδώ.</string>
<string name="drawer_page_glasses">Γυαλιά</string>
<string name="glasses_open">Γυαλιά VITURE</string>
<string name="glasses_control">Έλεγχος γυαλιών</string>
+11
View File
@@ -2497,6 +2497,17 @@
<string name="drawer_component_downloads">Descargar componentes</string>
<string name="drawer_component_downloads_hint">Las descargas se comparten con la página Componentes. Elige arriba una versión descargada para usarla al iniciar el próximo juego.</string>
<string name="drawer_component_choose_download">Elige una versión para descargar</string>
<string name="mode_client_arch">Cliente de Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</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="load_starting_steam_x64">Iniciando Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Lo usa el cliente de Steam x86-64</string>
<string name="storage_uninstall_in_x64">Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí.</string>
<string name="storage_uninstall_in_arm64">Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí.</string>
<string name="mode_zram">Compresión zRAM</string>
<string name="mode_zram_hint">Mientras se ejecuta un juego, la memoria del cliente de Steam se comprime en la zram del dispositivo, lo que deja más RAM para el juego. Se aplica la próxima vez que se inicie Steam.</string>
<string name="mode_zram_unsupported">No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4.</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">GOG:iin ei saatu yhteyttä</string>
<string name="stores_sync_gog_unreadable">GOG-kirjastoasi ei voitu lukea</string>
<string name="stores_registering">Rekisteröidytään Steamiin…</string>
<string name="mode_client_arch">Steam-asiakasohjelma</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-diagnostiikka</string>
<string name="setup_vac_diag_hint">Source-pelit kirjaavat konsolinsa (-condebug); istunnon lokien vac.txt kertoo, onko suojattu vai ei</string>
<string name="load_starting_steam_x64">Käynnistetään Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">x86-64-Steam-asiakasohjelman käytössä</string>
<string name="storage_uninstall_in_x64">Tämä kopio on x86-64-Steam-asiakasohjelmassa. Valitse x86-64 kohdassa Steam-asiakasohjelma ja poista sen asennus sitten täältä.</string>
<string name="storage_uninstall_in_arm64">Tämä kopio on ARM64-Steam-asiakasohjelmassa. Valitse ARM64 kohdassa Steam-asiakasohjelma ja poista sen asennus sitten täältä.</string>
<string name="drawer_page_glasses">Lasit</string>
<string name="glasses_open">VITURE-lasit</string>
<string name="glasses_control">Lasien hallinta</string>
+11
View File
@@ -2355,6 +2355,17 @@
<string name="stores_sync_gog_unreachable">Impossible de contacter GOG</string>
<string name="stores_sync_gog_unreadable">Impossible de lire votre bibliothèque GOG</string>
<string name="stores_registering">Enregistrement auprès de Steam…</string>
<string name="mode_client_arch">Client Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnostic VAC</string>
<string name="setup_vac_diag_hint">Les jeux Source journalisent leur console (-condebug) ; vac.txt dans les journaux de session indique sécurisé ou non sécurisé</string>
<string name="load_starting_steam_x64">Démarrage de Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Utilisé par le client Steam x86-64</string>
<string name="storage_uninstall_in_x64">Cette copie se trouve dans le client Steam x86-64. Choisissez x86-64 sous Client Steam, puis désinstallez-la ici.</string>
<string name="storage_uninstall_in_arm64">Cette copie se trouve dans le client Steam ARM64. Choisissez ARM64 sous Client Steam, puis désinstallez-la ici.</string>
<string name="drawer_page_glasses">Lunettes</string>
<string name="glasses_open">Lunettes VITURE</string>
<string name="glasses_control">Contrôle des lunettes</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">GOG से कनेक्ट नहीं हो सका</string>
<string name="stores_sync_gog_unreadable">आपकी GOG लाइब्रेरी पढ़ी नहीं जा सकी</string>
<string name="stores_registering">Steam में रजिस्टर हो रहा है…</string>
<string name="mode_client_arch">Steam क्लाइंट</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC डायग्नोस्टिक्स</string>
<string name="setup_vac_diag_hint">Source गेम अपना कंसोल लॉग करते हैं (-condebug); सेशन लॉग में vac.txt बताता है कि सुरक्षित है या नहीं</string>
<string name="load_starting_steam_x64">Steam x86-64 शुरू हो रहा है</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">x86-64 Steam क्लाइंट इसका उपयोग करता है</string>
<string name="storage_uninstall_in_x64">यह कॉपी x86-64 Steam क्लाइंट में है। Steam क्लाइंट में x86-64 चुनें, फिर इसे यहाँ अनइंस्टॉल करें।</string>
<string name="storage_uninstall_in_arm64">यह कॉपी ARM64 Steam क्लाइंट में है। Steam क्लाइंट में ARM64 चुनें, फिर इसे यहाँ अनइंस्टॉल करें।</string>
<string name="drawer_page_glasses">चश्मा</string>
<string name="glasses_open">VITURE चश्मा</string>
<string name="glasses_control">चश्मे का कंट्रोल</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Nem sikerült csatlakozni a GOG-hoz</string>
<string name="stores_sync_gog_unreadable">A GOG-könyvtáradat nem sikerült beolvasni</string>
<string name="stores_registering">Regisztráció a Steamnél…</string>
<string name="mode_client_arch">Steam-kliens</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-diagnosztika</string>
<string name="setup_vac_diag_hint">A Source-játékok naplózzák a konzoljukat (-condebug); a munkamenet-naplókban lévő vac.txt mutatja, hogy védett-e</string>
<string name="load_starting_steam_x64">Steam x86-64 indítása</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Az x86-64-es Steam-kliens használja</string>
<string name="storage_uninstall_in_x64">Ez a példány az x86-64 Steam-kliensben van. Válassza az x86-64 lehetőséget a Steam-kliens beállításnál, majd itt távolítsa el.</string>
<string name="storage_uninstall_in_arm64">Ez a példány az ARM64 Steam-kliensben van. Válassza az ARM64 lehetőséget a Steam-kliens beállításnál, majd itt távolítsa el.</string>
<string name="drawer_page_glasses">Szemüveg</string>
<string name="glasses_open">VITURE szemüveg</string>
<string name="glasses_control">Szemüveg vezérlése</string>
+11
View File
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">Tidak dapat menghubungi GOG</string>
<string name="stores_sync_gog_unreadable">Pustaka GOG kamu tidak dapat dibaca</string>
<string name="stores_registering">Mendaftar ke Steam…</string>
<string name="mode_client_arch">Klien Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnostik VAC</string>
<string name="setup_vac_diag_hint">Game Source mencatat konsolnya (-condebug); vac.txt di log sesi menunjukkan aman atau tidak</string>
<string name="load_starting_steam_x64">Memulai Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Digunakan oleh klien Steam x86-64</string>
<string name="storage_uninstall_in_x64">Salinan ini ada di klien Steam x86-64. Pilih x86-64 di Klien Steam, lalu hapus instalasinya di sini.</string>
<string name="storage_uninstall_in_arm64">Salinan ini ada di klien Steam ARM64. Pilih ARM64 di Klien Steam, lalu hapus instalasinya di sini.</string>
<string name="drawer_page_glasses">Kacamata</string>
<string name="glasses_open">Kacamata VITURE</string>
<string name="glasses_control">Kontrol kacamata</string>
+11
View File
@@ -2347,6 +2347,17 @@
<string name="stores_sync_gog_unreachable">Impossibile contattare GOG</string>
<string name="stores_sync_gog_unreadable">Impossibile leggere la tua libreria GOG</string>
<string name="stores_registering">Registrazione su Steam…</string>
<string name="mode_client_arch">Client Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnostica VAC</string>
<string name="setup_vac_diag_hint">I giochi Source registrano la console (-condebug); vac.txt nei log della sessione indica se è protetto o no</string>
<string name="load_starting_steam_x64">Avvio di Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Usato dal client Steam x86-64</string>
<string name="storage_uninstall_in_x64">Questa copia è nel client Steam x86-64. Scegli x86-64 in Client Steam, poi disinstallala qui.</string>
<string name="storage_uninstall_in_arm64">Questa copia è nel client Steam ARM64. Scegli ARM64 in Client Steam, poi disinstallala qui.</string>
<string name="drawer_page_glasses">Occhiali</string>
<string name="glasses_open">Occhiali VITURE</string>
<string name="glasses_control">Controllo degli occhiali</string>
+11
View File
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">GOGに接続できませんでした</string>
<string name="stores_sync_gog_unreadable">GOGのライブラリを読み込めませんでした</string>
<string name="stores_registering">Steamに登録中…</string>
<string name="mode_client_arch">Steamクライアント</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC診断</string>
<string name="setup_vac_diag_hint">Sourceゲームはコンソールを記録します(-condebug)。セッションログのvac.txtで保護の有無がわかります</string>
<string name="load_starting_steam_x64">Steam x86-64を起動中</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">x86-64 Steamクライアントが使用</string>
<string name="storage_uninstall_in_x64">このコピーはx86-64のSteamクライアントにあります。Steamクライアントでx86-64を選んでから、ここでアンインストールしてください。</string>
<string name="storage_uninstall_in_arm64">このコピーはARM64のSteamクライアントにあります。SteamクライアントでARM64を選んでから、ここでアンインストールしてください。</string>
<string name="drawer_page_glasses">グラス</string>
<string name="glasses_open">VITURE グラス</string>
<string name="glasses_control">グラスの制御</string>
+11
View File
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">GOG에 연결할 수 없습니다</string>
<string name="stores_sync_gog_unreadable">GOG 라이브러리를 읽을 수 없습니다</string>
<string name="stores_registering">Steam에 등록 중…</string>
<string name="mode_client_arch">Steam 클라이언트</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC 진단</string>
<string name="setup_vac_diag_hint">Source 게임은 콘솔을 기록합니다(-condebug). 세션 로그의 vac.txt에서 보안 여부를 확인할 수 있습니다</string>
<string name="load_starting_steam_x64">Steam x86-64 시작 중</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">x86-64 Steam 클라이언트에서 사용</string>
<string name="storage_uninstall_in_x64">이 사본은 x86-64 Steam 클라이언트에 있습니다. Steam 클라이언트에서 x86-64를 선택한 다음 여기에서 제거하세요.</string>
<string name="storage_uninstall_in_arm64">이 사본은 ARM64 Steam 클라이언트에 있습니다. Steam 클라이언트에서 ARM64를 선택한 다음 여기에서 제거하세요.</string>
<string name="drawer_page_glasses">안경</string>
<string name="glasses_open">VITURE 안경</string>
<string name="glasses_control">안경 제어</string>
+11
View File
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">Tidak dapat menyambung ke GOG</string>
<string name="stores_sync_gog_unreadable">Pustaka GOG anda tidak dapat dibaca</string>
<string name="stores_registering">Mendaftar dengan Steam…</string>
<string name="mode_client_arch">Klien Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnostik VAC</string>
<string name="setup_vac_diag_hint">Permainan Source merekodkan konsolnya (-condebug); vac.txt dalam log sesi menunjukkan selamat atau tidak</string>
<string name="load_starting_steam_x64">Memulakan Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Digunakan oleh klien Steam x86-64</string>
<string name="storage_uninstall_in_x64">Salinan ini berada dalam klien Steam x86-64. Pilih x86-64 di bawah Klien Steam, kemudian nyahpasangnya di sini.</string>
<string name="storage_uninstall_in_arm64">Salinan ini berada dalam klien Steam ARM64. Pilih ARM64 di bawah Klien Steam, kemudian nyahpasangnya di sini.</string>
<string name="drawer_page_glasses">Cermin mata</string>
<string name="glasses_open">Cermin mata VITURE</string>
<string name="glasses_control">Kawalan cermin mata</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Kunne ikke nå GOG</string>
<string name="stores_sync_gog_unreadable">Kunne ikke lese GOG-biblioteket ditt</string>
<string name="stores_registering">Registrerer hos Steam…</string>
<string name="mode_client_arch">Steam-klient</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-diagnostikk</string>
<string name="setup_vac_diag_hint">Source-spill logger konsollen sin (-condebug); vac.txt i sesjonsloggene viser om det er sikret eller ikke</string>
<string name="load_starting_steam_x64">Starter Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Brukes av x86-64-Steam-klienten</string>
<string name="storage_uninstall_in_x64">Denne kopien ligger i x86-64-Steam-klienten. Velg x86-64 under Steam-klient, og avinstaller den så her.</string>
<string name="storage_uninstall_in_arm64">Denne kopien ligger i ARM64-Steam-klienten. Velg ARM64 under Steam-klient, og avinstaller den så her.</string>
<string name="drawer_page_glasses">Briller</string>
<string name="glasses_open">VITURE-briller</string>
<string name="glasses_control">Brillestyring</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Kan GOG niet bereiken</string>
<string name="stores_sync_gog_unreadable">Je GOG-bibliotheek kon niet worden gelezen</string>
<string name="stores_registering">Registreren bij Steam…</string>
<string name="mode_client_arch">Steam-client</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-diagnose</string>
<string name="setup_vac_diag_hint">Source-games leggen hun console vast (-condebug); vac.txt in de sessielogs vermeldt beveiligd of onbeveiligd</string>
<string name="load_starting_steam_x64">Steam x86-64 wordt gestart</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Gebruikt door de x86-64-Steam-client</string>
<string name="storage_uninstall_in_x64">Deze kopie staat in de x86-64-Steam-client. Kies x86-64 bij Steam-client en verwijder hem daarna hier.</string>
<string name="storage_uninstall_in_arm64">Deze kopie staat in de ARM64-Steam-client. Kies ARM64 bij Steam-client en verwijder hem daarna hier.</string>
<string name="drawer_page_glasses">Bril</string>
<string name="glasses_open">VITURE-bril</string>
<string name="glasses_control">Brilbediening</string>
+11
View File
@@ -2386,6 +2386,17 @@
<string name="stores_sync_gog_unreachable">Nie udało się połączyć z GOG</string>
<string name="stores_sync_gog_unreadable">Nie udało się wczytać Twojej biblioteki GOG</string>
<string name="stores_registering">Rejestrowanie w Steam…</string>
<string name="mode_client_arch">Klient Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnostyka VAC</string>
<string name="setup_vac_diag_hint">Gry na Source zapisują swoją konsolę (-condebug); vac.txt w logach sesji pokazuje, czy gra jest chroniona przez VAC</string>
<string name="load_starting_steam_x64">Uruchamianie Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Używane przez klienta Steam x86-64</string>
<string name="storage_uninstall_in_x64">Ta kopia jest w kliencie Steam x86-64. Wybierz x86-64 w ustawieniu Klient Steam, a potem odinstaluj ją tutaj.</string>
<string name="storage_uninstall_in_arm64">Ta kopia jest w kliencie Steam ARM64. Wybierz ARM64 w ustawieniu Klient Steam, a potem odinstaluj ją tutaj.</string>
<string name="drawer_page_glasses">Okulary</string>
<string name="glasses_open">Okulary VITURE</string>
<string name="glasses_control">Sterowanie okularami</string>
@@ -2347,6 +2347,17 @@
<string name="stores_sync_gog_unreachable">Não foi possível contactar a GOG</string>
<string name="stores_sync_gog_unreadable">Não foi possível ler a tua biblioteca da GOG</string>
<string name="stores_registering">A registar na Steam…</string>
<string name="mode_client_arch">Cliente Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnóstico do VAC</string>
<string name="setup_vac_diag_hint">Os jogos Source registam a consola (-condebug); o vac.txt nos registos da sessão indica se está seguro ou não</string>
<string name="load_starting_steam_x64">A iniciar o Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Usado pelo cliente Steam x86-64</string>
<string name="storage_uninstall_in_x64">Esta cópia está no cliente Steam x86-64. Escolha x86-64 em Cliente Steam e depois desinstale-a aqui.</string>
<string name="storage_uninstall_in_arm64">Esta cópia está no cliente Steam ARM64. Escolha ARM64 em Cliente Steam e depois desinstale-a aqui.</string>
<string name="drawer_page_glasses">Óculos</string>
<string name="glasses_open">Óculos VITURE</string>
<string name="glasses_control">Controlo dos óculos</string>
+11
View File
@@ -2347,6 +2347,17 @@
<string name="stores_sync_gog_unreachable">Não foi possível conectar à GOG</string>
<string name="stores_sync_gog_unreadable">Não foi possível ler sua biblioteca da GOG</string>
<string name="stores_registering">Registrando na Steam…</string>
<string name="mode_client_arch">Cliente Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnóstico do VAC</string>
<string name="setup_vac_diag_hint">Jogos Source registram o console (-condebug); o vac.txt nos logs da sessão informa se está seguro ou não</string>
<string name="load_starting_steam_x64">Iniciando o Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Usado pelo cliente Steam x86-64</string>
<string name="storage_uninstall_in_x64">Esta cópia está no cliente Steam x86-64. Escolha x86-64 em Cliente Steam e depois desinstale-a aqui.</string>
<string name="storage_uninstall_in_arm64">Esta cópia está no cliente Steam ARM64. Escolha ARM64 em Cliente Steam e depois desinstale-a aqui.</string>
<string name="drawer_page_glasses">Óculos</string>
<string name="glasses_open">Óculos VITURE</string>
<string name="glasses_control">Controle dos óculos</string>
+11
View File
@@ -2361,6 +2361,17 @@
<string name="stores_sync_gog_unreachable">Nu s-a putut contacta GOG</string>
<string name="stores_sync_gog_unreadable">Biblioteca ta GOG nu a putut fi citită</string>
<string name="stores_registering">Se înregistrează la Steam…</string>
<string name="mode_client_arch">Client Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Diagnosticare VAC</string>
<string name="setup_vac_diag_hint">Jocurile Source își înregistrează consola (-condebug); vac.txt din jurnalele sesiunii arată dacă e securizat sau nu</string>
<string name="load_starting_steam_x64">Se pornește Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Folosit de clientul Steam x86-64</string>
<string name="storage_uninstall_in_x64">Această copie este în clientul Steam x86-64. Alege x86-64 la Client Steam, apoi dezinstaleaz-o aici.</string>
<string name="storage_uninstall_in_arm64">Această copie este în clientul Steam ARM64. Alege ARM64 la Client Steam, apoi dezinstaleaz-o aici.</string>
<string name="drawer_page_glasses">Ochelari</string>
<string name="glasses_open">Ochelari VITURE</string>
<string name="glasses_control">Controlul ochelarilor</string>
+11
View File
@@ -2380,6 +2380,17 @@
<string name="stores_sync_gog_unreachable">Не удалось связаться с GOG</string>
<string name="stores_sync_gog_unreadable">Не удалось прочитать вашу библиотеку GOG</string>
<string name="stores_registering">Регистрация в Steam…</string>
<string name="mode_client_arch">Клиент Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Диагностика VAC</string>
<string name="setup_vac_diag_hint">Игры на Source записывают консоль (-condebug); vac.txt в журналах сессии показывает, защищена ли игра VAC</string>
<string name="load_starting_steam_x64">Запуск Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Используется клиентом Steam x86-64</string>
<string name="storage_uninstall_in_x64">Эта копия находится в клиенте Steam x86-64. Выберите x86-64 в пункте «Клиент Steam», затем удалите её здесь.</string>
<string name="storage_uninstall_in_arm64">Эта копия находится в клиенте Steam ARM64. Выберите ARM64 в пункте «Клиент Steam», затем удалите её здесь.</string>
<string name="drawer_page_glasses">Очки</string>
<string name="glasses_open">Очки VITURE</string>
<string name="glasses_control">Управление очками</string>
+11
View File
@@ -2336,6 +2336,17 @@
<string name="stores_sync_gog_unreachable">Det gick inte att nå GOG</string>
<string name="stores_sync_gog_unreadable">Det gick inte att läsa ditt GOG-bibliotek</string>
<string name="stores_registering">Registrerar hos Steam…</string>
<string name="mode_client_arch">Steam-klient</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC-diagnostik</string>
<string name="setup_vac_diag_hint">Source-spel loggar sin konsol (-condebug); vac.txt i sessionsloggarna visar om det är säkrat eller inte</string>
<string name="load_starting_steam_x64">Startar Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Används av x86-64-Steam-klienten</string>
<string name="storage_uninstall_in_x64">Den här kopian finns i x86-64-Steam-klienten. Välj x86-64 under Steam-klient och avinstallera den sedan här.</string>
<string name="storage_uninstall_in_arm64">Den här kopian finns i ARM64-Steam-klienten. Välj ARM64 under Steam-klient och avinstallera den sedan här.</string>
<string name="drawer_page_glasses">Glasögon</string>
<string name="glasses_open">VITURE-glasögon</string>
<string name="glasses_control">Glasögonstyrning</string>
+11
View File
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">เชื่อมต่อกับ GOG ไม่ได้</string>
<string name="stores_sync_gog_unreadable">ไม่สามารถอ่านคลังเกม GOG ของคุณได้</string>
<string name="stores_registering">กำลังลงทะเบียนกับ Steam…</string>
<string name="mode_client_arch">ไคลเอนต์ Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">การวินิจฉัย VAC</string>
<string name="setup_vac_diag_hint">เกม Source บันทึกคอนโซลของเกม (-condebug); vac.txt ในล็อกเซสชันบอกว่าปลอดภัยหรือไม่</string>
<string name="load_starting_steam_x64">กำลังเริ่ม Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">ใช้โดยไคลเอนต์ Steam x86-64</string>
<string name="storage_uninstall_in_x64">สำเนานี้อยู่ในไคลเอนต์ Steam x86-64 เลือก x86-64 ในไคลเอนต์ Steam แล้วถอนการติดตั้งที่นี่</string>
<string name="storage_uninstall_in_arm64">สำเนานี้อยู่ในไคลเอนต์ Steam ARM64 เลือก ARM64 ในไคลเอนต์ Steam แล้วถอนการติดตั้งที่นี่</string>
<string name="drawer_page_glasses">แว่นตา</string>
<string name="glasses_open">แว่นตา VITURE</string>
<string name="glasses_control">การควบคุมแว่นตา</string>
+11
View File
@@ -2322,6 +2322,17 @@
<string name="stores_sync_gog_unreachable">GOG\'a bağlanılamadı</string>
<string name="stores_sync_gog_unreadable">GOG kitaplığın okunamadı</string>
<string name="stores_registering">Steam\'e kaydediliyor…</string>
<string name="mode_client_arch">Steam istemcisi</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC tanılaması</string>
<string name="setup_vac_diag_hint">Source oyunları konsollarını kaydeder (-condebug); oturum günlüklerindeki vac.txt güvenli olup olmadığını gösterir</string>
<string name="load_starting_steam_x64">Steam x86-64 başlatılıyor</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">x86-64 Steam istemcisi tarafından kullanılıyor</string>
<string name="storage_uninstall_in_x64">Bu kopya x86-64 Steam istemcisinde. Steam istemcisi altında x86-64 seçin, ardından burada kaldırın.</string>
<string name="storage_uninstall_in_arm64">Bu kopya ARM64 Steam istemcisinde. Steam istemcisi altında ARM64 seçin, ardından burada kaldırın.</string>
<string name="drawer_page_glasses">Gözlük</string>
<string name="glasses_open">VITURE gözlüğü</string>
<string name="glasses_control">Gözlük kontrolü</string>
+11
View File
@@ -2386,6 +2386,17 @@
<string name="stores_sync_gog_unreachable">Не вдалося з\'єднатися з GOG</string>
<string name="stores_sync_gog_unreadable">Не вдалося прочитати вашу бібліотеку GOG</string>
<string name="stores_registering">Реєстрація в Steam…</string>
<string name="mode_client_arch">Клієнт Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Діагностика VAC</string>
<string name="setup_vac_diag_hint">Ігри на Source записують свою консоль (-condebug); vac.txt у журналах сеансу показує, чи захищена гра VAC</string>
<string name="load_starting_steam_x64">Запуск Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Використовується клієнтом Steam x86-64</string>
<string name="storage_uninstall_in_x64">Ця копія в клієнті Steam x86-64. Виберіть x86-64 у пункті «Клієнт Steam», потім видаліть її тут.</string>
<string name="storage_uninstall_in_arm64">Ця копія в клієнті Steam ARM64. Виберіть ARM64 у пункті «Клієнт Steam», потім видаліть її тут.</string>
<string name="drawer_page_glasses">Окуляри</string>
<string name="glasses_open">Окуляри VITURE</string>
<string name="glasses_control">Керування окулярами</string>
+11
View File
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">Không thể kết nối với GOG</string>
<string name="stores_sync_gog_unreadable">Không thể đọc thư viện GOG của bạn</string>
<string name="stores_registering">Đang đăng ký với Steam…</string>
<string name="mode_client_arch">Client Steam</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">Chẩn đoán VAC</string>
<string name="setup_vac_diag_hint">Game Source ghi lại console (-condebug); vac.txt trong log phiên cho biết có an toàn hay không</string>
<string name="load_starting_steam_x64">Đang khởi động Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Được client Steam x86-64 sử dụng</string>
<string name="storage_uninstall_in_x64">Bản này nằm trong client Steam x86-64. Chọn x86-64 trong Client Steam, rồi gỡ cài đặt tại đây.</string>
<string name="storage_uninstall_in_arm64">Bản này nằm trong client Steam ARM64. Chọn ARM64 trong Client Steam, rồi gỡ cài đặt tại đây.</string>
<string name="drawer_page_glasses">Kính</string>
<string name="glasses_open">Kính VITURE</string>
<string name="glasses_control">Điều khiển kính</string>
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">无法连接到 GOG</string>
<string name="stores_sync_gog_unreadable">无法读取你的 GOG 游戏库</string>
<string name="stores_registering">正在向 Steam 注册…</string>
<string name="mode_client_arch">Steam 客户端</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC 诊断</string>
<string name="setup_vac_diag_hint">Source 游戏会记录控制台(-condebug);会话日志中的 vac.txt 会显示是否安全</string>
<string name="load_starting_steam_x64">正在启动 Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">由 x86-64 Steam 客户端使用</string>
<string name="storage_uninstall_in_x64">此副本位于 x86-64 Steam 客户端中。请在“Steam 客户端”中选择 x86-64,然后在此处卸载。</string>
<string name="storage_uninstall_in_arm64">此副本位于 ARM64 Steam 客户端中。请在“Steam 客户端”中选择 ARM64,然后在此处卸载。</string>
<string name="drawer_page_glasses">眼镜</string>
<string name="glasses_open">VITURE 眼镜</string>
<string name="glasses_control">眼镜控制</string>
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">無法連線至 GOG</string>
<string name="stores_sync_gog_unreadable">無法讀取你的 GOG 遊戲庫</string>
<string name="stores_registering">正在向 Steam 註冊…</string>
<string name="mode_client_arch">Steam 用戶端</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC 診斷</string>
<string name="setup_vac_diag_hint">Source 遊戲會記錄主控台(-condebug);會話記錄中的 vac.txt 會顯示是否安全</string>
<string name="load_starting_steam_x64">正在啟動 Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">由 x86-64 Steam 用戶端使用</string>
<string name="storage_uninstall_in_x64">此副本位於 x86-64 Steam 用戶端中。請在「Steam 用戶端」中選擇 x86-64,然後在此解除安裝。</string>
<string name="storage_uninstall_in_arm64">此副本位於 ARM64 Steam 用戶端中。請在「Steam 用戶端」中選擇 ARM64,然後在此解除安裝。</string>
<string name="drawer_page_glasses">眼鏡</string>
<string name="glasses_open">VITURE 眼鏡</string>
<string name="glasses_control">眼鏡控制</string>
@@ -2311,6 +2311,17 @@
<string name="stores_sync_gog_unreachable">無法連線到 GOG</string>
<string name="stores_sync_gog_unreadable">無法讀取你的 GOG 遊戲庫</string>
<string name="stores_registering">正在向 Steam 註冊…</string>
<string name="mode_client_arch">Steam 用戶端</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</string>
<string name="setup_vac_diag">VAC 診斷</string>
<string name="setup_vac_diag_hint">Source 遊戲會記錄主控台(-condebug);工作階段日誌中的 vac.txt 會顯示是否安全</string>
<string name="load_starting_steam_x64">正在啟動 Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">由 x86-64 Steam 用戶端使用</string>
<string name="storage_uninstall_in_x64">此副本位於 x86-64 Steam 用戶端中。請在「Steam 用戶端」中選擇 x86-64,然後在此解除安裝。</string>
<string name="storage_uninstall_in_arm64">此副本位於 ARM64 Steam 用戶端中。請在「Steam 用戶端」中選擇 ARM64,然後在此解除安裝。</string>
<string name="drawer_page_glasses">眼鏡</string>
<string name="glasses_open">VITURE 眼鏡</string>
<string name="glasses_control">眼鏡控制</string>
+11
View File
@@ -2386,6 +2386,17 @@
<string name="stores_sync_gog_unreachable">Couldn\'t reach GOG</string>
<string name="stores_sync_gog_unreadable">Could not read your GOG library</string>
<string name="stores_registering">Registering with Steam…</string>
<string name="mode_client_arch">Steam client</string>
<string name="mode_client_arch_fex">FEX %1$s</string>
<string name="mode_client_arch_arm64">ARM64</string>
<string name="mode_client_arch_x64">x86-64</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="load_starting_steam_x64">Starting Steam x86-64</string>
<string name="storage_game_x64">%1$s · x86-64</string>
<string name="storage_tool_x64_client">Used by the x86-64 Steam client</string>
<string name="storage_uninstall_in_x64">This copy is in the x86-64 Steam client. Choose x86-64 under Steam client, then uninstall it here.</string>
<string name="storage_uninstall_in_arm64">This copy is in the ARM64 Steam client. Choose ARM64 under Steam client, then uninstall it here.</string>
<string name="drawer_page_glasses">Glasses</string>
<string name="glasses_open">VITURE glasses</string>
<string name="glasses_control">Glasses control</string>
@@ -147,4 +147,23 @@ class LogRedactorTest {
val once = LogRedactor.redactForShare("resolver: 192.168.0.1")
assertEquals(once, LogRedactor.redactForShare(once))
}
@Test fun learnedAccountsComeFromBothClients() {
val root = java.nio.file.Files.createTempDirectory("redactor").toFile()
try {
val arm = java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf")
arm.parentFile.mkdirs()
arm.writeText("\"users\" { \"7656\" { \"AccountName\" \"armplayer\" \"PersonaName\" \"Arm Persona\" } }")
LogRedactor.learnFromRuntime(root)
assertEquals("hi <redacted:account> and <redacted:account>", LogRedactor.redact("hi armplayer and Arm Persona"))
val x64 = java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf")
x64.parentFile.mkdirs()
x64.writeText("\"users\" { \"7656\" { \"AccountName\" \"x64player\" } }")
LogRedactor.learnFromRuntime(root)
assertEquals("<redacted:account> <redacted:account>", LogRedactor.redact("armplayer x64player"))
} finally {
LogRedactor.learnAccounts()
root.deleteRecursively()
}
}
}
@@ -28,6 +28,30 @@ class AddedGamesScanTest {
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
}
private fun cardWithX8664Library(): File {
val card = tmp.newFolder("Card")
File(card, "Example").mkdirs()
File(card, "Example/Example.exe").writeText("game")
File(card, "x86-64/steamapps/common/FlatOut").mkdirs()
File(card, "x86-64/steamapps/common/FlatOut/FlatOut.exe").writeText("steam game")
return card
}
@Test fun theX8664ClientsCardLibraryIsNotAGame() {
val card = cardWithX8664Library()
SessionPrefs.setGameStorage(app, card.path, "Card")
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
assertTrue(AddedGames.isSteamLibraryFolder("x86-64"))
assertFalse(AddedGames.isSteamLibraryFolder("x86-64 games"))
}
@Test fun anOldGamesFolderHoldingTheX8664LibraryDoesNotMigrateItAsAGame() {
val card = cardWithX8664Library()
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
SessionPrefs.setAddedGamesDirs(app, listOf(card.path))
assertEquals(listOf("Example"), AddedGames.scan(app).map { it.name })
}
@Test fun aStorePackageLaunchesTheExecutableItDeclares() {
val game = tmp.newFolder("Minecraft Dungeons")
File(game, "Dungeons.exe").writeText("unreal bootstrap")
@@ -0,0 +1,25 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class LibraryArchTest {
private val app get() = RuntimeEnvironment.getApplication()
@Test fun aScanReadsTheClientItWasGivenNotTheSelectedOne() {
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
SessionPrefs.setSteamClientArch(app, "arm64")
val apps = File(SessionPrefs.steamRoot(app, "x86_64"), "steamapps").apply { mkdirs() }
File(apps, "appmanifest_440.acf").writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"440\"\n\t\"name\"\t\t\"Team Fortress 2\"\n\t\"StateFlags\"\t\t\"4\"\n}\n")
assertEquals(listOf("Team Fortress 2"), Library.launchableGames(app, added = emptyList(), arch = "x86_64").map { it.name })
assertEquals(emptyList<String>(), Library.launchableGames(app, added = emptyList(), arch = "arm64").map { it.name })
}
}
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.frontend
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class LibraryCacheClientTest {
private val app get() = RuntimeEnvironment.getApplication()
private val games = listOf(Library.SteamGame(440, "Team Fortress 2", null, "Steam library"))
@Test fun aCacheFromBeforeTheClientWasRecordedIsArm64s() {
java.io.File(app.filesDir, "library-cache.json").writeText("""[{"appId":440,"name":"Team Fortress 2","library":"Steam library","gameId":440}]""")
val (games, x64) = LibraryCache.snapshot(app)
assertEquals(listOf("Team Fortress 2"), games.map { it.name })
assertFalse(x64)
}
@Test fun theListAndItsClientAreOneFile() {
LibraryCache.save(app, games, x64 = true)
assertEquals(listOf("library-cache.json"), app.filesDir.list().orEmpty().filter { it.startsWith("library-cache") })
assertEquals(listOf("Team Fortress 2") to true, LibraryCache.snapshot(app).let { (g, x) -> g.map { it.name } to x })
}
@Test fun theListsClientIsKeptUntilAListForAnotherIsSaved() {
LibraryCache.save(app, games, x64 = true)
assertTrue(LibraryCache.listedX64(app))
LibraryCache.save(app, emptyList())
assertTrue(LibraryCache.listedX64(app))
LibraryCache.save(app, games, x64 = false)
assertFalse(LibraryCache.listedX64(app))
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.frontend
import org.junit.Assert.assertEquals
import org.junit.Test
class LibraryFromSteamClientTest {
@Test fun onlyTheClientsOwnInstallsAreDroppedOnASwitch() {
val games = listOf(
Library.SteamGame(440, "Team Fortress 2", null, "Steam library"),
Library.SteamGame(1, "God of War", null, Library.ADDED, gameId = 9_000_000_001L),
Library.SteamGame(2, "Store game", null, "Games", gameId = 9_000_000_002L, source = "gog"),
)
assertEquals(listOf("God of War", "Store game"), games.filterNot(Library::fromSteamClient).map { it.name })
}
}
@@ -129,6 +129,23 @@ class SteamGridTest {
assertNull(SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.BACKGROUND))
}
@Test fun theOtherClientsEmptyGridGetsTheChoiceInsteadOfDroppingIt() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val picked = image("mine.png", 12)
SteamGameArt.choose(app, game(), AddedGameArt.Slot.COVER, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))
assertFalse(SteamGrid.sync(app, listOf(target())))
val x64 = File(SessionPrefs.steamRoot(app, "x86_64"), "userdata/123/config").apply { mkdirs() }
SessionPrefs.setSteamClientArch(app, "x86_64")
assertFalse(SteamGrid.sync(app, listOf(target())))
assertEquals(12L, SteamGameArt.chosenFile(app, 620, AddedGameArt.Slot.COVER)?.length())
assertEquals(12L, File(x64, "grid/620p.png").length())
SessionPrefs.setSteamClientArch(app, "arm64")
assertFalse(SteamGrid.sync(app, listOf(target())))
assertEquals(12L, File(grid, "620p.png").length())
}
@Test fun aChoiceMadeHereIsNotTakenBackAsASteamChange() {
File(LinuxRuntime.rootDir(app), "root/.local/share/Steam/userdata/123/config").mkdirs()
val picked = image("mine.png", 12)
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class FexCatalogTest {
private val sha = "0fc1ca2ba13817ad70d1ea157119a75c9c02446b57d166e6d676cce0807c19c4"
private val url = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/fex-2610-r5/fex.tzst"
private fun json(release: String = "fex-2610-r5", url: String = this.url, sha256: String = sha) =
"""{"schemaVersion": 1, "category": "fex", "release": "$release", "url": "$url", "sha256": "$sha256", "size": 4083539}"""
@Test fun readsTheBuildComponentsNames() {
assertEquals(FexCatalog.Build("fex-2610-r5", url, sha), FexCatalog.parse(json()))
}
@Test fun readsTheSingleReleaseByItsVersion() {
val single = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/fex/fex.tzst"
assertEquals(FexCatalog.Build("fex-2610-r6", single, sha), FexCatalog.parse(json(release = "fex", url = single).replace("}", ", \"version\": \"2610-r6\"}")))
assertNull(FexCatalog.parse(json(release = "fex", url = single).replace("}", ", \"version\": \"latest\"}")))
assertNull(FexCatalog.parse(json(release = "fex", url = single)))
}
@Test fun refusesAnArchiveOutsideItsOwnRelease() {
assertNull(FexCatalog.parse(json(url = "https://example.com/fex.tzst")))
assertNull(FexCatalog.parse(json(url = url.replace("fex-2610-r5/", "fex-2610-r4/"))))
assertNull(FexCatalog.parse(json(release = "latest", url = url.replace("fex-2610-r5", "latest"))))
}
@Test fun refusesAMissingOrMalformedDigest() {
assertNull(FexCatalog.parse(json(sha256 = "abc")))
assertNull(FexCatalog.parse("""{"release": "fex-2610-r5", "url": "$url"}"""))
assertNull(FexCatalog.parse("not json"))
}
@Test fun theSessionGetsTheBuildAndEverySource() {
val env = FexCatalog.environment(FexCatalog.Build("fex-2610-r5", url, sha), listOf(url, "https://ghfast.top/$url"))
assertEquals(listOf("BL_FEX_TAG=fex-2610-r5", "BL_FEX_SHA256=$sha", "BL_FEX_URLS=$url https://ghfast.top/$url"), env)
}
}
@@ -0,0 +1,20 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class SessionClientRecordTest {
@get:Rule val tmp = TemporaryFolder()
@Test fun aSessionFolderKeepsTheClientItRan() {
val dir = tmp.newFolder("2026-10-18-01-steam")
assertNull(SessionArtifacts.recordedClient(dir))
SessionArtifacts.recordClient(dir, "x86_64")
assertEquals("x86_64", SessionArtifacts.recordedClient(dir))
java.io.File(dir, "steam-client.txt").writeText("something else\n")
assertNull(SessionArtifacts.recordedClient(dir))
}
}
@@ -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 {
@@ -67,6 +82,51 @@ class SteamDownloadMonitorTest {
}
}
@Test fun theOtherClientsLogIsReadFromItsStart() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
File(root, "root/.local/share/Steam/logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[2026-10-06 14:00:00] Client version: 1\n" + "[2026-10-06 14:00:01] nothing to download\n".repeat(20))
setLastModified(10_000L)
}
val monitor = SteamDownloadMonitor()
assertFalse(monitor.poll(root, now = 1_000L))
File(root, "root/.droiddeck-x64/.local/share/Steam/logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[2026-10-06 14:44:17] AppID 44 App update changed : Running Update,Downloading,Staging,\n" +
"[2026-10-06 14:44:18] Current download rate: 0.000 Mbps\n".repeat(30))
setLastModified(20_000L)
}
assertTrue(monitor.poll(root, now = 2_000L))
} finally {
root.deleteRecursively()
}
}
@Test fun theRunningClientsLogIsFollowedWhenTheOtherOneIsNewer() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
File(root, "root/.local/share/Steam/logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[2026-10-06 14:44:17] AppID 44 App update changed : Running Update,Downloading,Staging,\n")
setLastModified(10_000L)
}
File(root, "root/.droiddeck-x64/.local/share/Steam/logs/content_log.txt").apply {
check(parentFile?.mkdirs() != false)
writeText("[2026-10-06 14:00:00] Client version: 1\n")
setLastModified(20_000L)
}
val monitor = SteamDownloadMonitor()
assertTrue(monitor.poll(root, now = 1_000L, client = "arm64"))
assertTrue(monitor.poll(root, now = 2_000L, client = "arm64"))
assertFalse(SteamDownloadMonitor().poll(root, now = 1_000L, client = "x86_64"))
} finally {
root.deleteRecursively()
}
}
@Test fun receivedTrafficBridgesSteamRateLines() {
val root = Files.createTempDirectory("droiddeck-steam-download-").toFile()
try {
@@ -0,0 +1,43 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class SteamRepairTest {
private val app get() = RuntimeEnvironment.getApplication()
@Before @After fun idle() {
SessionState.running = false
SessionState.steamClientArch = null
}
private fun marker(home: String) = File(LinuxRuntime.rootDir(app), "root/$home/package/droiddeck-unconfirmed").apply {
parentFile?.mkdirs()
writeText("tracked\nstart\nstart\n")
}
@Test fun aShownClientClearsOnlyItsOwnUnconfirmedStarts() {
val arm = marker(".local/share/Steam")
val x64 = marker(".droiddeck-x64/.local/share/Steam")
SteamRepair.clientShown(app)
assertFalse(arm.exists())
assertTrue(x64.exists())
marker(".local/share/Steam")
SessionPrefs.setSteamClientArch(app, "x86_64")
SteamRepair.clientShown(app)
assertTrue(arm.exists())
assertFalse(x64.exists())
}
}
@@ -0,0 +1,57 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class VacDiagnosticsTest {
@Test fun tailReadsOnlyWhatThisSessionAppended() {
val log = java.io.File.createTempFile("console", ".log")
try {
log.writeText("You are in insecure mode.\nConnected to 1.2.3.4\n")
val armed = log.length()
log.appendText("Connecting to 5.6.7.8\n")
assertEquals(listOf("Connecting to 5.6.7.8", ""), VacDiagnostics.tail(log, armed))
} finally {
log.delete()
}
}
@Test fun theSameGameInTwoLibrariesGetsTwoConsoleNames() {
val a = java.io.File("/lib1/steamapps/common/Team Fortress 2/tf/console.log")
val b = java.io.File("/lib2/steamapps/common/Team Fortress 2/tf/console.log")
val c = java.io.File("/lib1/steamapps/common/Half-Life 2/hl2/console.log")
val names = VacDiagnostics.consoleNames(listOf(a, b, c))
assertEquals("Team Fortress 2-tf", names[0])
assertTrue(names[1].startsWith("Team Fortress 2-tf-") && names[1] != names[0])
assertEquals("Half-Life 2-hl2", names[2])
assertEquals(names, VacDiagnostics.consoleNames(listOf(a, b, c)))
}
@Test fun tailReadsAllOfAReplacedFile() {
val log = java.io.File.createTempFile("console", ".log")
try {
log.writeText("new\n")
assertEquals(listOf("new", ""), VacDiagnostics.tail(log, 1000L))
} finally {
log.delete()
}
}
@Test fun onlyConsoleLogsThisSessionWroteAreReported() {
val log = java.io.File.createTempFile("console", ".log")
try {
log.writeText("Connected to 1.2.3.4\n")
val since = log.lastModified() - 5_000
val armed = mapOf(log.canonicalPath to log.length())
assertFalse(VacDiagnostics.writtenThisSession(log, since, armed))
log.appendText("Connecting to 5.6.7.8\n")
assertTrue(VacDiagnostics.writtenThisSession(log, since, armed))
assertTrue(VacDiagnostics.writtenThisSession(log, since, emptyMap()))
assertFalse(VacDiagnostics.writtenThisSession(log, log.lastModified() + 1_000, emptyMap()))
} finally {
log.delete()
}
}
}
@@ -97,6 +97,23 @@ class SteamStorageTest {
assertEquals("GE-Proton11-9-aarch64", SteamStorage.compatToolName(vdf))
}
@Test fun countsToolUseInBothClients() {
assertEquals("GE-Proton11-9-a64", SteamStorage.x64ToolName("GE-Proton11-9-aarch64"))
assertEquals("droiddeck-proton-a64", SteamStorage.x64ToolName("droiddeck-proton-arm64"))
assertEquals("GE-Proton10-1", SteamStorage.x64ToolName("GE-Proton10-1"))
val arm = SteamStorage.ToolUse(mapOf("0" to "droiddeck-proton-arm64", "10" to "GE-Proton11-9-aarch64", "20" to "GE-Proton11-9-aarch64"), setOf("10"))
val x64 = SteamStorage.ToolUse(
mapOf("0" to "GE-Proton11-9-a64", "30" to "GE-Proton11-9-a64", "40" to "droiddeck-direct", "50" to "GE-Proton11-9-aarch64"),
setOf("30", "40", "50"), x64 = true,
)
assertEquals(1, arm.games("GE-Proton11-9-aarch64"))
assertEquals(1, x64.games("GE-Proton11-9-aarch64"))
assertFalse(arm.isDefault("GE-Proton11-9-aarch64"))
assertTrue(x64.isDefault("GE-Proton11-9-aarch64"))
assertTrue(arm.isDefault("droiddeck-proton-arm64"))
assertFalse(x64.isDefault("droiddeck-proton-arm64"))
}
@Test fun listingCountsPendingDownloadsAndUnreadableManifests() {
val library = tmp.newFolder("lib")
val apps = File(library, "steamapps").apply { mkdirs() }
@@ -117,6 +134,19 @@ class SteamStorageTest {
assertNull(SteamStorage.manifests(File(library, "missing")))
}
@Test fun anEmptyX64FolderIsNoClient() {
val root = tmp.newFolder("x64")
File(root, ".cef-enable-remote-debugging").createNewFile()
assertFalse(SteamStorage.x64ClientPresent(root))
assertFalse(SteamStorage.x64ClientPresent(File(root, "missing")))
File(root, "package").mkdirs()
File(root, "package/droiddeck-installed").writeText("1\n")
assertTrue(SteamStorage.x64ClientPresent(root))
val bare = tmp.newFolder("x64-steamapps")
File(bare, "steamapps").mkdirs()
assertTrue(SteamStorage.x64ClientPresent(bare))
}
@Test fun leftoverIdsSkipTheSharedPrefixAndShortcuts() {
assertNull(SteamStorage.leftoverId("0"))
assertNull(SteamStorage.leftoverId("3207321049"))
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.storage
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class StorageScanTest {
private val app get() = RuntimeEnvironment.getApplication()
@Test fun eachClientsLeftoversAreCheckedAgainstItsOwnGames() {
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
val arm = SessionPrefs.steamRoot(app, "arm64")
val x64 = SessionPrefs.steamRoot(app, "x86_64")
File(arm, "steamapps/compatdata/10").mkdirs()
File(arm, "steamapps/appmanifest_10.acf").writeText("\"appid\" \"10\"\n\"StateFlags\" \"4\"\n\"installdir\" \"Example\"\n")
val removed = File(x64, "steamapps/compatdata/10").apply { mkdirs() }
File(removed, "user.reg").writeText("x")
val report = StorageScan.scan(app)
assertTrue(report.leftoversChecked)
assertEquals(listOf(removed.path), report.leftovers.map { it.prefix?.path })
}
}
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FexVersionTest {
@Test fun readsTheTagTheLauncherPins() {
assertEquals("2610 r3", fexVersionOf("#!/bin/bash\nset -eu\nFEX_TAG=fex-2610-r3\nFEX_SHA256=abc\n"))
}
@Test fun nothingWithoutAPinnedTag() {
assertNull(fexVersionOf("FEX_TAG=\$1\n"))
assertNull(fexVersionOf("# FEX_TAG=fex-2610-r3\n"))
}
@Test fun readsAnInstalledOrCatalogTag() {
assertEquals("2610 r5", fexVersionOfTag("fex-2610-r5\n"))
assertNull(fexVersionOfTag("latest"))
}
@Test fun theShippedLauncherHasOne() {
val script = java.io.File("../tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64").takeIf { it.isFile }
?: java.io.File("tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64")
assertEquals(true, fexVersionOf(script.readText()) != null)
}
}
@@ -0,0 +1,70 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithTag
import com.droiddeck.launcher.frontend.Library
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], qualifiers = "w1200dp-h752dp-land", application = android.app.Application::class)
class GamesClientTagTest {
@get:Rule val compose = createRobolectricComposeRule()
private val two = listOf(
Library.SteamGame(440, "Team Fortress 2", null, "Steam library"),
Library.SteamGame(70, "Half-Life", null, "Steam library"),
)
@Test fun theArm64ClientsListHasNoTag() {
show(two, x64 = false)
compose.onAllNodesWithTag("games-client-x64").assertCountEquals(0)
}
@Test fun theX8664ClientsListIsTagged() {
show(two, x64 = true)
compose.onAllNodesWithTag("games-client-x64").assertCountEquals(1)
}
@Test fun anEmptyX8664LibraryIsTaggedToo() {
show(emptyList(), x64 = true)
compose.onAllNodesWithTag("games-client-x64").assertCountEquals(1)
}
@Test fun aNarrowPaneStillShowsTheTag() {
show(two, x64 = true, narrow = true)
compose.onAllNodesWithTag("games-client-x64").assertCountEquals(1)
}
private fun show(games: List<Library.SteamGame>, x64: Boolean, narrow: Boolean = false) {
compose.mainClock.autoAdvance = false
compose.setContent {
DroidDeckTheme {
CompositionLocalProvider(LocalFrontFocus provides FrontFocus(), LocalNarrowPane provides narrow) {
FocusGlideHost {
GamesPage(FrontEndState(
installed = "r9", ready = true, available = null, busy = false, stage = "", percent = -1,
desktopInstalled = true, offlineAccount = null, offline = false, frameGenLabel = "",
romsDir = null, logsEnabled = false, steamGames = games, steamGamesX64 = x64, emulators = emptyList(), running = null,
), FrontEndActions(
onPlay = {}, onPlaySteamVr = {}, onPlayDesktopUi = {}, onSteamGame = {}, onDesktop = {}, onEmulator = {},
onRom = {}, onResume = {}, onSteamSettings = {}, onDesktopSettings = {},
onInstallPackage = {}, onRemovePackage = {}, onRuntime = {}, onFrameGenPick = {},
onImportLossless = {}, onProtons = {}, onComponents = {}, onPerformance = {},
onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {},
onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> },
onSetPhantomProcessLimit = { _, _ -> },
), selected = "app:440", onSelect = {}, modifier = Modifier.fillMaxSize())
}
}
}
}
compose.mainClock.advanceTimeBy(2_000)
}
}
+1 -1
View File
@@ -187,7 +187,7 @@ Checked against bionic `android16-release`:
|---|---|---|
| `rseq`, `set_robust_list` | **blocked** | glibc, at the start of every thread, while all signals are blocked |
| `faccessat2` | **blocked** | glibc's `faccessat()`, which tries it on **every** call before falling back, so 2 round trips (`access()` calls the plain `faccessat` syscall directly) |
| `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx, FEX's rootfs lookups (`libblsession.so` turns `openat2` into `openat`) |
| `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx, FEX's rootfs lookups (`libblsession.so` turns `openat2` into `openat`, resolving `RESOLVE_IN_ROOT` paths component by component inside the root) |
| `setuid`, `setgid` and the rest of the family | **blocked** | Xwayland (hence `-i`) |
| `futex_waitv`, `io_uring_*`, `landlock_*` | **blocked** | Proton's fsync (droiddeck-fsync answers `futex_waitv` in `libblsession.so`), some engines |
| `clone3` | blocked before Android 15 | glibc `pthread_create` |
+451
View File
@@ -0,0 +1,451 @@
# x86-64 Steam client
Valve's x86-64 Linux Steam client, run unchanged under FEX beside the default arm64 client. It is
opt-in: Steam settings, **Steam client: x86-64** (`steamClientArch`, passed to the
session as `BL_STEAM_CLIENT_ARCH=x86_64`, applies from the next session), or the desktop menu entry
**Steam x86-64 (FEX, experimental)**. The Games tab lists the active client's games and tags its
header "x86-64" while that client's list is shown. This page records why each workaround exists and what was seen
on devices. The timeline is in `docs/progress-log.md`.
**Why.** The arm64 Linux client never runs VAC: Left 4 Dead 2 and Team Fortress 2 are refused by
VAC-secured servers ("Connection Blocked ... VAC secured server") and no client log mentions VAC. VAC
modules are x86 code. Under FEX the x86-64 client has a module it can run, as SteamLite does in Wine.
**Layout.** Everything lives in its own home, `$X64_HOME` (`BL_X64_HOME`, default `~/.droiddeck-x64`):
its own `~/.steam` links, config, libraries, x86 rootfs and log (`steam-x64.log`). Shared with the arm64
client: the sign-in, and its `config/uioverrides` (Steam's startup movie overrides), which the x86 client
links. One client runs at a time, since both use the same account; the standalone launcher shuts the
arm64 client down first.
| Command | Does |
| --- | --- |
| `droiddeck-steam-x64 [steam:// URL or client arguments]` | standalone run (the desktop menu entry) |
| `droiddeck-steam-x64 --prepare` | install and set up, then exit |
| `droiddeck-steam-x64 --exec ARGS...` | run the client binary in its environment (how the session starts it) |
| `droiddeck-steam-x64 --sync-login` | copy the newer sign-in to the other client |
| `droiddeck-steam-x64 --map-direct [--live]` | map Linux titles to DroidDeck direct |
| `droiddeck-steam-x64 --default-tool` | print the default ARM64 Proton wrapper's tool name |
| `droiddeck-steam-x64 --gpu` / `--webhelper-gpu` | exit status: client / x86 web helper on the GPU |
## Client install and update
- The first run downloads FEX (release `FEX_TAG` in Droid-Deck/DroidDeck-Components, sha256-pinned), FEX's Ubuntu 24.04 x86
rootfs (about 525 MB, 1.5 GB unpacked, sha256-pinned; the same repository's copy of FEX-Emu's 2026-08-11 image) and the client (about 600 MB) from Valve's CDN,
part by part with sha256 checks. A part-done rootfs download resumes; a finished one answers 416,
which is not a failure. `unsquashfs -no-xattrs -ignore-errors`: ownership and xattrs mean nothing in
the guest, and device nodes cannot be made and are not needed (FEX passes `/dev` through).
- Channel `steamdeck_publicbeta`, the `-clientbeta` the session starts it with. `package/beta` must
match `-clientbeta` before every start, or the client loops on rc 42 (its restart request, which the
standalone launcher honours up to five times, as `steam.sh` does).
- Before each start `x64_sync_client` brings the client to Valve's current build, downloading only the
changed parts (9 of 28 on a device). If that fails, the client updates itself.
- `.steam-enable-steamrt64-client` in the client folder opts into the SteamRT3 build
(`steamrt64/steam`). Without it the client re-executes itself in the scout environment through
`steam.sh`, whose start-up checks need what proot cannot give; the session stalled before the
interface. The client is started directly, as the session starts `steamrtarm64/steam`:
`STEAM_RUNTIME=1`, its own `steamrt64` libraries first, and the `~/.steam` links `steam.sh` makes.
- **User namespaces.** Unlike the arm64 client, this one carries pressure-vessel and refuses to start
("Steam now requires user namespaces") when `steam-runtime-check-requirements`' bwrap probe cannot
create a user namespace, which no Android app can. `STEAM_RUNTIME_STEAMRT` points at
`steamrt-shim`: Valve's runtime linked file by file with only the probe replaced (`exit 0`). proot
is the container, as for the arm64 client.
- **Re-exec after an update.** The client re-executes itself inside the SteamRT container through
`pressure-vessel-wrap` (bwrap). `STEAM_STEAMRT_RECURSION_GUARD=1` says it is already inside one; the
FEX rootfs with the client's `steamrt64` libraries is that container.
- **Starting games.** Believing itself inside the container, the client starts games and system
commands "alongside Steam" through `steam-runtime-launch-client`. Valve's asks a launcher service on
the session bus (there is none: the game ended at once), and with none on `PATH` the launch fails
(No such file). The shim's version applies `--directory`, `--env` and `--unset-env` and runs the
command.
- **Libraries.** The rootfs lacks what the SteamRT container gives the client (GTK 2 for
`steamui.so`, ...). Valve's steamrt3c platform libraries are linked into `steamrt-libs`; they ship
without soname links (pressure-vessel makes those when it builds the container), so the rootfs's
`ldconfig` adds them there. `LD_LIBRARY_PATH` beats the system folders whatever its order, so only
libraries the rootfs lacks are kept: the platform's older glibc must not shadow the rootfs's.
- **System V IPC.** Android kernels have none, and the client's tier0 gave up on start ("Function not
implemented" from `semget`; traced with a `-DDEBUG_STRACE` FEX). The session shim (`preload/*.c`) is
also built for x86-64 and i386 and preloaded through the rootfs's own `/etc/ld.so.preload`, one copy
per architecture under the multiarch folder named through glibc's `$LIB`. Without a list in the
rootfs, FEX falls through to the guest's, which names the session's arm64 preloads.
- **Tracer.** proot is a tracer, so tier0 believes a debugger is attached and breaks into it with int3
on the first failed assertion: the web helper died of SIGTRAP a second after every start.
`preload/tracer.c` exempts web helpers 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
without the tracer.
- **FEXServer owns the rootfs.** A FEX process takes its rootfs from FEXServer, and a server started
earlier without `FEX_ROOTFS` (any FEX run in the guest) serves none: every x86 library lookup fell
through to the arm64 guest. It is stopped while no x86 client runs.
- **`/dev/dri`.** x86 programs enumerated `/dev/dri`, found the session's KGSL node and spun on it (x86
Turnip "failed to query kernel driver version", KMS dumb-buffer ioctls in a loop); the web helper
never got a browser up. FEX looks in the rootfs first, so an empty `rootfs/dev/dri` hides the node.
- **Scout runtime.** Valve ships it as `ubuntu12_32/steam-runtime.tar.xz` and `steam.sh` unpacks it.
Since `steam.sh` is not used, `--prepare` unpacks it when its checksum changes.
- **Repair.** Repair Steam (`BL_X64_REPAIR=1`), or two starts in a row that never showed the client
(`package/droiddeck-unconfirmed`; the app clears the count once the client is on screen), removes
the install stamp so the client is reinstalled. Counts from before this was tracked are dropped
rather than taken as failed starts. `droiddeck-steam-install` removes the repair request when it is
done, so the session reads it first and both clients are repaired.
- **Stale state.** A killed client leaves `.crash`, which opens the update UI on its spinner, and
`htmlcache/Singleton*` locks, which can leave the UI blank. Both are removed while no client runs.
- **Network.** The client's connection header and Settings > Internet come from NetworkManager on the
system bus; without it the client shows no connection while downloads run. The standalone launcher
starts the session's system bus configuration and the `droiddeck-netmanager` stand-in when no
system bus is up (a desktop has none).
- **FEX settings.** The app's PERFORMANCE_TSO preset (`core/FexPreset.kt`, WinNative's default). FEX's
own defaults keep half-barrier TSO and full-precision x87, which cost every x86 program time. Values
already in the environment win.
- **Fast path.** libblfastpath, preloaded into every guest process and so into FEX, broke FEX's 32-bit
mode (SIGILL at start). It stands down when FEX is about to run a 32-bit program; 64-bit programs
keep it. `BL_X64_FP_OFF=1` turns it off for everything.
- **Diagnostics.** `BL_FEX` runs another FEX build (a `DEBUG_STRACE` one, say). `BL_FEXSERVER_LOG`
starts a FEXServer 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 (file logs overwrite each
other).
## Web helper on the GPU, QAM/WSI and flicker
The client's web helper draws its whole UI. There are two:
- **The arm64 client's helper** (`droiddeck-x64-arm-webhelper`), used when both clients are the same
Steam build (unless `BL_X64_X86_WEBHELPER=1`). FEX hands the arm64 program to the CPU, so it runs
natively with the session's preloads (KGSL poll fix, fast path) and the arm64 session's GPU
settings. It talks to the client over IPC, and a helper of another build never comes up: the client
restarted it every 10 s (device: 1790721607 against 1790904859). The session therefore updates the
arm64 client's parts on the same channel first (`droiddeck-steam-install --sync
steamdeck_publicbeta`). With this helper the client itself gets the GPU: it shows every Big Picture
frame the helper draws, and in software (llvmpipe under FEX) that held the UI at about 20 fps
(`BL_X64_CLIENT_SOFTWARE=1` keeps it in software). Device: 144 fps menus.
- **The client's own x86 helper on the GPU** (`droiddeck-x64-webhelper`), otherwise: ANGLE's GL
backend through FEX's GL thunk to zink and Turnip. On ANGLE's Vulkan backend the x86 compositor
thread was the bottleneck (16 fps against 60). Never `--in-process-gpu`, the preload's default: a
GPU crash there took the whole helper down (102 restarts). `BL_X64_CEF_GPU=0` keeps it in software.
The session shim (`preload/net.c`) turns `steamrt64/steamwebhelper.sh`'s exec of `./steamwebhelper`
into `BL_X64_ARM_WEBHELPER`. Nothing in the client's folder is edited: the client checksums its files
as it starts and reinstalled itself over an edited launcher (2-3 minutes) on every start.
Quick Access Menu and flicker, x86 helper:
- Steam's compositor in the helper draws each browser window (main UI, QAM) from that window's X
pixmap. gamescope's WSI layer took the helper's zink swapchains and sent the frames to gamescope
instead: the QAM opened blank until a d-pad press and the screen behind it went black. The helper
runs with `DISABLE_GAMESCOPE_WSI=1`; games keep the layer. Not FEX: a texture-from-pixmap test
behaved the same under FEX and natively.
- gamescope sets `vk_xwayland_wait_ready=false` for its children because its layer does the waiting.
Without the layer, unfinished frames reached the compositor as black flashes (3 in 10 QAM opens,
screen-recorded and counted frame by frame). The helper sets `vk_xwayland_wait_ready=true`.
- zink's kopper swapchain still flashed on about 1 in 20 opens with the wait. With
`LIBGL_KOPPER_DISABLE=true` none did in 60 opens, at 60 fps instead of about 140 (Xwayland's
per-window timing for child windows). No flashes was chosen over frame rate.
Software path (no host thunks, or `BL_X64_SOFTWARE=1`): x86 Mesa has no driver for this GPU, so
llvmpipe for GL (the client's own window needs GLX) and the rootfs's own Vulkan ICDs (lavapipe). GLX
first failed because the desktop exports zink over the arm64 Turnip ICD; those variables are cleared.
`LIBGL_DRI3_DISABLE=1`: over DRI3 the X server hands out the GPU node and Mesa's software path then
tries KMS dumb buffers on it in a loop.
Decky's loader talks to the client's CEF debugging port. Its marker
(`.cef-enable-remote-debugging`) is kept in both clients' roots, because the session reads it from the
client it runs. The app creates it in the x86-64 root only once that client is installed: the folder
alone would then read as an installed client.
## FEX build and patches
- `tools/fex/build-in-arch.sh` runs in an Arch Linux ARM container (`menci/archlinuxarm:base-devel`)
on an arm64 runner: Arch Linux ARM has no FEX package and FEX publishes no Linux binaries. It packs
FEX and the runtime's own `unsquashfs` as `fex.tzst` (`usr/local/...`), with `fex-needed.txt` (every
needed soname must resolve in a stock guest) and `fex-files.txt`. `TUNE_CPU=none`: the default
(native) would tune for the runner's Neoverse cores, not the device.
- **Host thunks** (`FEX_THUNKS=1`): an x86 program's libvulkan, libGL, libX11, ... calls go to the
runtime's arm64 libraries (Turnip, Mesa). The host half needs the graphics development packages. The
guest half is cross-compiled with clang against an x86 sysroot of Arch x86_64 and multilib i686
packages, unpacked by pacman into their own root and never run (no scriptlets, no signature checks).
FEX's x86 toolchain files assume an x86 build machine, so on arm64 the sysroot also supplies headers,
C runtime objects and libgcc.
- **Stack protector.** Arch's clang builds the guest thunks with stack protection. The canary load in
`CallHostFunction`'s prologue (`mov %fs:0x28, %r11`) overwrote r11, which carries the host function's
address, and FEX called the canary. 9 of 521 Vulkan and 66 of 736 GL host calls (those with 7 or
more arguments) were affected, `vkCmdBindDescriptorSets` from TF2's DXVK among them; 32-bit games pass
the host address in mm0. The toolchain files add `-fno-stack-protector` (`fex-2610-r2`).
- **`vkGetDeviceProcAddr`.** FEX-2609's guest version passed a lookup of `vkGetDeviceProcAddr` itself
to the host and handed back the raw host function, so every pointer fetched through it was unlinked.
TF2's native DXVK (2.3) makes that lookup. FEX-2610 answers self-lookups in the guest (FEX-Emu/FEX
3397eb9b).
- Patches in `tools/fex/patches`:
- `0001-openat2-enosys-fallback`: our proot answers `openat2` with ENOSYS (`tools/proot/PATCHES.md`,
0008). FEX opened every rootfs path with `openat2(RESOLVE_IN_ROOT)` and fell through to the arm64
guest on anything but EXDEV; x86 bash could not find libtinfo. Since r4 the fallback resolves the
path inside the rootfs itself (absolute symlinks restart at the rootfs, as `RESOLVE_IN_ROOT` does);
before, they resolved against the arm64 guest. `libblsession.so` (`preload/robust.c`) answers
`openat2` first in practice and does the same walk for `RESOLVE_IN_ROOT` callers, so Valve's FEX-Emu
on the arm64 client gets it too. Each path component is then its own system call.
- `0002-thunkgen-newer-clang`: the container's newer clang has no `FileManager::clearStatCache()`,
which the thunk generator called after each run.
- `0003-allocator32-map32bit-exhaustion`: Counter-Strike 2 reserves the low 2 GB with `MAP_32BIT`
in shrinking sizes. FEX's `MemAllocator32Bit` met EEXIST, stepped down one page per retry and
restarted from the top forever (found with `perf_event_open` IP sampling, since proot holds
ptrace). Such a range is now skipped whole and a second exhausted scan returns `-ENOMEM`, as the
kernel does (`fex-2610-r3`).
- **Building and publishing.** Droid-Deck/DroidDeck-Components' `build-fex.yml` builds `tools/fex` from
this repository at a chosen ref (default `main`) with host thunks, in the same Arch Linux ARM container
with a read-only token, then a separate job replaces `fex.tzst` on Components' single `fex` release,
checks the digest GitHub stored, and records the build's version, sha256 and size in its `fex.json`
(in that order: a device that reads the old `fex.json` meanwhile gets a mismatching archive and keeps
its FEX). This repository's `build-fex.yml` only builds `tools/fex` when it changes (and on dispatch,
for diagnostic builds such as `-DDEBUG_STRACE`); nothing is published from it.
- Releases so far: `fex-2609-r2` (no thunks), `fex-2609-r3` (thunks), `fex-2610-r1`, `fex-2610-r2`,
`fex-2610-r3`, `fex-2610-r4` (in-root resolution when openat2 is missing, exact MAP_32BIT
marking), `fex-2610-r5` (current: bundles libxxhash, which the Linux runtime does not ship; the
build fails when a program in the package needs a library that is neither bundled nor listed in
`tools/fex/runtime-libs.txt`). They were built and published in The412Banner/DroidDeck; up to r3 their tags point at that
repository's `main`, from r4 at the commit each was built from. Later builds are versions on the single
`fex` release; `fex-2610-r5` stays published as the build `droiddeck-steam-x64` pins.
- **Where the app gets it.** The app downloads FEX from Droid-Deck/DroidDeck-Components, where nothing is
uploaded by hand. The app follows its `fex.json`: while the x86-64 client is selected it reads it from
Components' `main` (directly, at most every ten minutes) and keeps the last copy, and a session passes
that build to `droiddeck-steam-x64` (`BL_FEX_TAG`, `BL_FEX_SHA256`, `BL_FEX_URLS` with the download
source's mirrors). The script installs a build only after its archive matches the sha256 and every
program in it loads (`ld-linux --list`); the files it replaces are kept aside until the installed copy
loads too and put back otherwise, and the tag is written last. Otherwise it keeps the installed one while that still loads,
then falls back to `FEX_TAG`/`FEX_SHA256` built in, which stay the first-start default when no copy
of `fex.json` has been read. Publishing a FEX build therefore takes a Components run only; a
replaced archive cannot be pointed back to, so undoing a build means building an earlier ref. The rootfs is
FEX-Emu's own image, unmodified: `fex-rootfs.json` there names its rootfs.fex-emu.gg link, sha256 and
size, and `mirror-upstream.yml` fetches it into the catalog's release only when both match. A newer
image is a new catalog release; `ROOTFS_URL` and `ROOTFS_SHA256` then move to it, and a device that
already unpacked one keeps it.
- **What a session log shows.** `FEX ready (<tag>, <source>)` names the build and where it came from
(`catalog`, `built-in`, or `kept` when a newer one did not install, with the reason a line above);
`x86-64 rootfs:` names the image; `x86-64 client:` the client's ubuntu12 and steamrt64 builds at start
(it may update itself after). The rootfs download reports every tenth of its size as a step. When the
web helper's GPU process keeps exiting until Chromium gives up on the GPU (the menus then never appear),
one line says so with the exit codes, followed by x86 `glxinfo -B` and `vulkaninfo --summary` through
FEX's thunks.
## DroidDeck direct for Linux games and runtimes
- Steam runs native Linux games in the Steam Linux Runtime container (pressure-vessel), which needs
bwrap and user namespaces. **DroidDeck direct** (`compatibilitytools.d/droiddeck-direct`, linux to
linux) runs them under FEX with no container. Scout titles run through Valve's scout library runtime
(`ubuntu12_32/steam-runtime/run.sh`). Titles Valve gives a newer runtime (sniper, soldier; listed in
`direct-sniper`) are built for newer libraries than scout's and run on the rootfs (Ubuntu 24.04) as
it is.
- **Mapping** (`x64_map_direct`): every game the client's appinfo cache shows with a Linux build (type
game, oslist with linux; runtime from `config/app_mappings`: SteamLinuxRuntime_sniper or _soldier,
none means scout), installed or not, so Steam downloads the Linux depots. A title on a tool chosen in
Properties keeps it, except `BL_X64_DIRECT_APPS` (Left 4 Dead 2, 550, by default), which is always
taken back. `direct-auto.json` lists what was mapped. One pass over the mapping: a 2000-title fixture
went from 16.5 s to 0.2 s with the same output.
- A game whose Windows build is installed (it ran on a Proton before) stays a Windows game on the
default Proton. Steam does not swap an installed build when the tool changes: DroidDeck direct was
handed `Launcher.exe` and the game ended at once (Borderlands 2), and getting the Linux build back
means downloading the game again.
- The client rewrites `config.vdf` from memory as it exits, so the mapping is written only while no
x86 client runs: by `--prepare`, and by the session after each exit, when the appinfo cache also
knows titles bought during the run. `--live` (the session's pass while the client runs) writes
nothing; installed Linux titles it would map go to the session's pending list.
- A new user's client has no `config.vdf` before its first start, so Steam Play's default and the
Linux titles would wait a session. `--prepare` writes the `CompatToolMapping` block alone, which the
client keeps.
- **Sniper check.** `tf.sh` reads `VERSION_CODENAME` from `/etc/os-release` and stops unless it is
`sniper`. For `direct-sniper` titles the run script runs such a script through `bash -c` with that
line replaced by `VERSION_CODENAME=sniper`.
- **HarfBuzz.** The rootfs's HarfBuzz needs a newer FreeType than a game may bring. Counter-Strike 2
ships an older `libfreetype.so.6` and no HarfBuzz: its menu text library failed to load, the game
flagged its files corrupt (a verify each start) and waited behind an error box that could not open.
Newer-runtime titles get Valve's runtime HarfBuzz (2.7) preloaded from `sniper-compat`, as they would
in its container.
- **GPU.** Games get FEX's host thunks and the session's GPU settings (Turnip, Zink), saved by the
launcher as `game-gpu.env` and rewritten whenever it runs; `game-gpu.local` is the user's, never
rewritten, and is read after it. `BL_X64_GAME_SOFTWARE=1` keeps a game in software, with
`mesa_glthread=false` (GL threading is no help with software GL under FEX).
- Source games (`hl2.sh`, `hl2_linux`, `srcds`) get `-condebug` for the VAC diagnostics.
- **Controllers.** `libfakeinput` is built for x86-64 and preloaded in the rootfs; Big Picture works
with it. The 32-bit copy crashed Left 4 Dead 2: its fake devices reach the game's own SDL2 through
udev with a missing attribute and SDL `strdup()`s the NULL (fault backtrace: strlen in libc <- `__strdup`
<- libSDL2 <- `udev_device_get_sysattr_value`). 32-bit programs get only the session shim. The
preload line names the x86_64 copy by full path, since with `$LIB` FEX falls through to the
session's own `/usr/local/lib/i386-linux-gnu` copy.
- Device: TF2 main menu at 358 fps on the GPU and about 84 fps in a match; Half-Life (32-bit) at
101 fps; Left 4 Dead 2 at 51 fps (about 11 in software).
- The arm64 client's route to x86 Linux titles, `droiddeck-fex`, is not wrapped for this client. Its
wrapper ran when the client probed its tools after logon; the arm64 client took that for an x86
program wanting FEX and installed Valve's FEX.
- `droiddeck-fex` itself (arm64 client): Valve's FEX-Emu keeps its server socket in
`$STEAM_COMPAT_DATA_PATH/fex-emu/Server` whatever `FEX_APP_DATA_LOCATION` or `FEX_SERVERSOCKETPATH`
say. Under the app's data folder that path passed the 108 bytes a socket path may have (Half-Life:
136), FEXServer never listened and the game ended at once. FEX's data folder in the runtime directory
is short enough; the one under HOME is not, so `/tmp/fex` stands in for it.
## Libraries, SD card and sharing between clients
- **Separate libraries.** With folders shared, each client could update, verify or switch the
other's games. A title with a Linux build runs as that in the x86 client and as the Windows build in
the arm64 one, so opening it in the other client switched the files and the first client downloaded
its version again (Left 4 Dead 2, 8.6 GB). Hard links do not help: switching platform makes Steam
download depot 551 anyway. A game wanted in both clients is installed in both. Folders an earlier
version shared are taken out of the x86 client's lists (written only while no x86 client runs); the
games stay in the arm64 library.
- **SD card.** The x86 client's card library is a folder inside the arm64 one (`/mnt/droiddeck-sd/x86-64`,
`GameStorage.X64_CARD_DIR`, passed as `BL_X64_STEAM_LIBRARIES`), so the install dialog offers Internal
Storage and SD card. compatdata, shadercache and runtime tools stay private, as in the arm64 card
library. FEX runs x86 programs from the noexec exFAT card. The folder appears with the client's first
session on the card and its `steamapps` with the first library write. Desktop sessions prepare it as
well: desktop mode offers the x86 client whichever is selected, and `droiddeck-steam-x64` drops
library folders it is not given. The arm64 card library may hold the arm64 copy of the same title,
so the session's installed check looks only in the x86 card folder.
- **Card downloads.** `storage.c` (no zero-filled reservation where `fallocate` is refused) and
`dircache.c` recognised only `steamrtarm64/steam`, and the x86 client wrote each download in full as
zeros first (Audiosurf: 415 MB written, 0 downloaded). After the fix: FlatOut, 1.19 GB, to the card
in 51 s.
- **Sign-in.** Before either client starts, the newer sign-in (`config/loginusers.vdf`, the login token
in `local.vdf`, `AutoLoginUser` and `RememberPassword` in `registry.vdf`) is copied to the other, so
signing in or switching accounts in one counts for both. Steam renews its token every few days and
refuses an old copy (a 3-day-old one asked for the password), so the newest is copied at every start,
never while either client runs, since each rewrites these files. A client that refused its token
rewrites `local.vdf` with an empty `ConnectCache`: newest, but copied over the other it would sign
that one out too (seen after the x86 client's "Invalid Password"), so it is never the source. The copy keeps
the time stamp, so both match until one client signs in again. The rest of `registry.vdf`
(language, skin) stays each client's.
- **App side.** The library, art picker, grid sync and boot movie follow the running client, else the
selected one; a changed client applies from the next session. Offline Mode is written to both
clients' `loginusers.vdf`; until the x86 client's first start its sign-in is the arm64 one. The log
redactor learns account names from both clients. The startup movie is staged into the x86 client's
config only once its first start has created it; `--prepare` links the arm64 `config/uioverrides`
and copies the `StartupMovie` choice.
- **Storage page.** It lists both clients' games, the x86 ones labelled; a game can be installed in
both and an id left over in several libraries. `steam://uninstall` reaches only the session's client,
so the other client's copy is uninstalled after switching clients. Tools in the x86 client's
libraries are shown as required (it runs its Linux games with Valve's runtimes). Proton tool files
live only in the arm64 root; the x86 client has wrappers. An x86 client without `config.vdf` has
never mapped a game.
## Proton for Windows titles in the x86 client
- Windows games run on the arm64 client's Protons; x86 Proton would need pressure-vessel. For each
arm64 client tool (droiddeck-proton-arm64, DroidDeck-Proton, GE-Proton, CachyOS) `--prepare` makes a
wrapper in the x86 client's `compatibilitytools.d` with the same folder and display name: Components
swaps and per-title mappings are keyed by them. In the tool name `arm64` and `aarch64` become `a64`,
because the x86 client ignores a tool named for arm64 ("for a different target platform linux
arm64"). The session runs `--prepare` again after it installs a new Proton.
- The wrapper is an x86 script under FEX. It starts `droiddeck-x64-arm-proton` through an arm64 binary
(a copy of `/usr/bin/env`), which FEX hands straight to the CPU, so Wine and the game run as from the
arm64 client: same Proton, launcher, Components swaps, per-game environment and Steam client library
(`~/.steam/sdk64`).
- `droiddeck-x64-arm-proton` drops FEX's settings and the x86 library paths and preloads, then restores
the arm64 session's environment that `droiddeck-steam-x64` saved before it built the x86 one
(`arm-session.env`: HOME, PATH, GPU, audio, input, fast path). Steam's per-game variables are not in
it. It keeps the game display Steam chose: the saved `DISPLAY=:0` hid the game window and made
DiRT Showdown exit after about 7 s. Proton takes its Steam client parts from the client's install
folder, and the x86 client's are x86, so `STEAM_COMPAT_CLIENT_INSTALL_PATH` points at the arm64 root
and `BL_X64_STEAM_ROOT` keeps the x86 one for install-script lookups.
- **Game overlay.** The x86 client preloads its own x86 overlay; the arm64 one takes its place. The
overlay marks the game's windows with the Steam app ID, and without it gamescope never shows them
(FlatOut ran and played sound behind Steam's launch screen, "steam app id: 0"). The agent also tags
untagged `steam_app_<id>` windows with `STEAM_GAME` on the game display when the x86 client runs
(DiRT 3, an added game, then reached its title screen at 144 fps).
- The x86 client's tools are not adopted as arm64 Protons: `adopt_extras` on its
`compatibilitytools.d` rewrote DroidDeck direct as a Windows tool, and Steam installed Half-Life's
Windows files. The arm64 Proton is adopted on demand at an x86 launch.
- **Default.** The newest DroidDeck-Proton, then the newest GE-Proton (aarch64), else
`droiddeck-proton-a64`. FlatOut from the card ran on DroidDeck-Proton 11-9.1 and exited on
GE-Proton11-7 (no log). With droiddeck-proton-arm64 (Proton Experimental) Xwayland never got its
window buffers back and FlatOut took the device's RAM with it (10.5 GB of dma-buf in 35 s);
GE-Proton, same game and client, stayed flat. The session asks for GE-Proton when none is installed.
`default-tool` records the default the script set; when it changes, the titles still on the old one
move along (device: 113 titles off GE-Proton11-7). A default or title the user set to another tool
keeps it. Before `default-tool` existed, the default the script set was an `-a64` wrapper at
priority 75.
- `steam-compatibility --x64` repoints entries the client aimed at a Proton it cannot start (Valve's
`proton_*` names, and `droiddeck-proton-arm64` left by an arm64 compat pass) to the default wrapper.
Tools in its own `compatibilitytools.d` are kept: the wrappers include `proton-cachyos-*-a64`, which
starts with "proton" like Valve's.
- **Added games** are written by the client the session runs and pointed at its default wrapper. The
shortcuts pass keeps the x86 client's own default and state (`droiddeck-compat` beside its Steam
folder), because the arm64 client's `state.json` lists which of that client's titles follow its
default.
- **Install scripts.** A game's own installers without a `HasRunKey` (Borderlands 2: VC++ 2005, 2008
and 2010, DirectX, .NET 4.0) are marked under `Software\Valve\Steam\Apps\<appid>` (and the
Wow6432Node view), so Steam skips them; this applies to both clients. Windows components install into
the x86 client's prefix (DiRT Showdown and DiRT 3 needed OpenAL).
## VAC findings
- x86-64 client with Linux builds: secure. Left 4 Dead 2 joined a live Quick Match game, the server
type that refuses the arm64 client; on the GPU an online server answered the A2S info query as
"Valve Left4Dead 2 US East Server", VAC=1. TF2 Casual matchmaking put it on a VAC-secured Valve
server. Half-Life's native build joined a VAC-secured server.
- x86-64 client with a Windows build (TF2 on DroidDeck-Proton): insecure. Proton reaches Steam through
the arm64 client's `steamclient.so`.
- arm64 client: Left 4 Dead 2 was blocked on both Protons; Half-Life (Windows build) joined a vac=1
GoldSrc server. Why the arm64 client fails is not established; it has no VAC module of its own to run.
- Headless VAC testing is not possible: current Steam starts the saved login from its UI.
- **VAC diagnostics** (Setup page). A Source game decides secure or insecure once, as it starts, and
says so only in its own console ("You are in insecure mode. You must restart before you can connect
to secure servers."). That console reaches a file only with `-condebug`: `<game>/<mod>/console.log`,
or `<game>/game/<mod>/console.log` for Source 2. At session start the app writes
`~/.droiddeck-vac-diagnostics` (start time, client, each console.log's length, since `-condebug`
appends). The launch wrapper (`bl_vacdiag`) then adds `-condebug` to Source titles (found by
`gameinfo.txt` or `gameinfo.gi`), flags `-insecure` in launch options and notes each launch. At the
end the session copies what each console.log gained into `vac/`, scrubbed, and writes `vac.txt`: the
game's secure/insecure lines, VAC lines in the client's logs, and which client files are present. A
stopped session keeps its own copy of the marker and launch list; a new session waits for earlier
collections, because it replaces the marker and Steam's logs.
- "Connecting to" is only an attempt. L4D2 logs it, retries, and then refuses a VAC server with a
dialog ("Connection Blocked: Please remove plug-ins before attempting to connect to a VAC secured
server") that never reaches the console. Only "Connected to" means the game got in.
- SteamLite's "INSECURE - VAC may kick" warning was our own parser reading the wrong log while the
client was secure all along. `vac.txt` therefore quotes the lines it found and says "inconclusive"
when the game never reached a server.
## Session integration, restarts and mapping
- With `BL_STEAM_CLIENT_ARCH=x86_64`, `droiddeck-session` updates the arm64 client's parts
(`droiddeck-steam-install`, then `--sync steamdeck_publicbeta`), runs `droiddeck-steam-x64 --prepare`
and starts the client with `droiddeck-steam-x64 --exec`. Steps only for the arm64 client (library
registration, the ARM64 Proton fetch, the live compat helper) are skipped; added games,
`steam-compatibility --x64`, UI scale and redistributables run for the x86 client.
- **Deck mode.** With `-steamos3` the client's SteamOS audio manager passed libpulse the NULL
operation of a failed request, and libpulse aborted the client right after start ("Assertion 'o'
failed at ../src/pulse/operation.c:136 pa_operation_get_state()", the runtime's PulseAudio 13).
`preload/pulseguard.c` (x86 builds only; the arm64 client does not hit it) answers a missing operation
as cancelled and ignores its unref and cancel. The session passes `-steamdeck -steamos3` unless
`BL_X64_DECK=0`. Switch to Desktop is expected to need no change (an x86 shell under FEX runs
`steamos-session-select`); the full switch has not been tested on a device.
- **Process names.** Under FEX a process's argv[0] is FEX and the program is its first argument. The
session's game detection, CPU pinning and the zRAM watcher read that argument. Steam pins itself and
its web helper to a few cores, so the session's CPU set is applied again every 5 s.
- **Live mapping and restarts.** While the client runs, the session's background loop runs
`steam-compatibility --x64 --live` and `droiddeck-steam-x64 --map-direct --live`, every 15 s and
only in the session's first hour (720 beats of 5 s). A title installed after that is mapped when the
client exits and takes effect from the next session. Steam reads a
mapping changed while it runs only at its next start, so changed titles go to `.bl-compat-pending`.
Once they have installed (`StateFlags` 4 in the client's root or its card folder) and no game runs,
the client is restarted once per title, at most 3 times a session.
- **After exit.** Added games, `steam-compatibility --x64` and `--map-direct` run again: the client
wrote its mapping from memory as it exited, over what the live passes set.
- The Lossless Scaling ownership probe and the Steam import watcher reach the x86 client through the
arm64 client's `steamclient.so` (`BL_STEAMCLIENT`); the watcher restarts the client with
`droiddeck-steam-x64 --exec -shutdown`.
- Steam logs and the Steam language come from the x86 client's home. On the desktop, `steam` and
`steamdeck-steam` start the selected client, and the desktop game entries list its libraries.
- The standalone launcher (desktop menu) shuts the arm64 client down first, starts the system bus and
the NetworkManager stand-in when missing, clears stale locks, pins CPUs and restarts the client on
rc 42.
## Known limits
- Experimental and opt-in; one client runs at a time.
- Windows titles in the x86 client are not VAC-secure: Proton reaches Steam through the arm64
`steamclient.so`.
- 32-bit games get no controller reader (the i386 `libfakeinput` is left out).
- 32-bit Linux titles through the arm64 client's `droiddeck-fex` still fail (not pursued).
- The x86 web helper's menus run at 60 fps without kopper.
- Counter-Strike 2 reaches its menu (and loaded Dust II at 78 fps), then Android's `lmkd` kills
DroidDeck. The game's GPU memory is pinned: KGSL `page_alloc` followed Shmem through the load, to
about 3.2 GB at the kill, with Unevictable just above it. This device's `lmkd` runs in minfree mode,
which does not count swap, while MemAvailable counted that memory as available. Paging the game out
with a root tool (`process_madvise(MADV_PAGEOUT)`) let it draw its menu, but `lmkd` still killed the
session. Not solved.
- Sharing libraries between the clients is parked.
+449
View File
@@ -7,6 +7,357 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-16/17 - `feat/x86-64-steam-client`: web helper on the GPU, org main, review rounds
- **x86-64 web helper on the GPU** (`f603a085`): the client's own steamwebhelper runs ANGLE's GL backend
through FEX's GL thunk to zink/Turnip (ANGLE-Vulkan was CPU-bound at 16 fps). No arm64 helper needed.
- **Quick Access Menu** (`26d80895`): the QAM opened blank until a d-pad press and the screen behind it went
black. Steam's compositor draws each browser window from its X pixmap; gamescope's WSI layer took the
helper's zink swapchains, so the pixmaps never got frames. The helper now runs with
`DISABLE_GAMESCOPE_WSI=1` (games keep the layer). Not FEX: a texture-from-pixmap test behaved the same
under FEX and native, so no fex-2610-r4.
- **Black flashes** (`1b52df77`): gamescope sets `vk_xwayland_wait_ready=false` for its children; without
its layer, unfinished frames reached the compositor (3 flashes in 10 QAM opens, screen-recorded and
counted frame by frame). The helper waits for frames and presents without kopper: 0 flashes in 60 opens.
The x86-64 menus run at 60 fps on that path (Xwayland's per-window timing for child windows); the user
chose no flashes over ~140 fps.
- **Org main**: rebased twice (522e755b, then e95a4226), linear, each final tree checked equal to a plain
merge. Main's art picker, grid sync and boot movie follow the selected client (`32bbf79a`).
- **CodeRabbit** on personal PRs #68 and #69 (full review of 76 files on #69). Fixed: one Steam channel for
the x86-64 client; VAC report reads only console logs this session wrote, and a stopped session keeps its
own marker; segvlog reads stack words without faulting and opens files with raw syscalls; the x86-64
shortcuts pass no longer writes the arm64 client's compat `state.json`; DroidDeck direct mapping in one
pass (2000-title fixture: same output, 16.5 s -> 0.2 s); session stop/start handoff under one lock;
desktop sessions prepare the x86-64 card library; Decky's x86-64 marker only for an installed client;
library follows the running client; download monitor restarts on the other client's log; compat
restart reads the x86-64 card folder; CPU pinning sees FEX processes; build-fex has a read-only build
job, a separate publish job, never republishes a tag, requires thunks and tags the built commit
(`8e2751f9`..`f9de849c`). Skipped with evidence: IPC home for the probes (device: ownership probe
succeeds in x86-64 sessions); shortened `STEAM_COMPAT_DATA_PATH` (read before the override).
- **Device** (5004cf4e): TF2, Half-Life, Borderlands 2 and DiRT Showdown launch and play on the x86-64
client; the arm64 client starts. BL2/Half-Life "downloads" were Steam shader pre-caching (6.1 GB Deck
`steam_pipeline_cache.foz` for BL2), not game updates. Half-Life is the Windows build in the arm64 client
and the Linux build in the x86-64 one, in separate folders.
- **Org readiness** (2026-10-17): org main's rules are a PR with the `Build APK` check (ruleset "Protect main")
and `AGENTS.md`'s one line on comments. Comments added by the branch went from about 440 lines to under
100, each a non-obvious external constraint; the device history and reasons moved to
`docs/development/x86-64-steam-client.md` (code unchanged, checked by stripping comments from both sides).
The Steam client setting now says the library is the client's own and the sign-in is shared. Rebased
onto 83eda433 (VITURE XR); squashed as personal PR #70, `feat:` title. FEX tags r1-r3 point at personal
main, not their build commits; build-fex now tags the built commit and needs thunks to publish.
- **PR #70 review**: added Linux-only titles in the x86-64 client went to `droiddeck-fex`, a tool that
client lacks (now DroidDeck direct, and `droiddeck-fex` is retaken); pulseguard finds a libpulse loaded
RTLD_LOCAL (Wine's winepulse.so); segvlog passes faults on to the program's own handlers after logging;
Lossless's DLL search includes the x86-64 card folder; the log scrubber keeps account names when a
`loginusers.vdf` cannot be read; build-diag runs with a read-only token.
- **Org main 97f976ef** (Epic Games Launcher for Linux, #928): rebased onto it. Its new session code
launched, restarted and recovered games through `steamrtarm64/steam`, and its added-game shortcut check
read the arm64 client's Steam folder; both now use the session's client. Added games marked native are
not given a Proton by either client's shortcuts pass.
- **FEX hosted by the org**: `fex-2610-r3` is mirrored into Droid-Deck/DroidDeck-Components by its
`mirror-release.yml`, checked against a new `fex.json` catalog there (Components PR #2), and
`droiddeck-steam-x64` downloads it from there instead of The412Banner/DroidDeck.
- **FEX fex-2610-r4** (PR #71 review): without openat2 (proot) FEX opened rootfs paths with absolute
symlinks resolved against the arm64 guest, and `libblsession.so` converted every openat2 the same way
before FEX saw ENOSYS. `preload/robust.c` now resolves `RESOLVE_IN_ROOT` paths component by component
inside the root (59 cases; 100,000-path differential fuzz; new `test_openat2_preload.py`), and patch
0001 does the same in FEX. Patch 0003 marks only the host mappings that overlap a failed MAP_32BIT
range (the low-2 GB case: about a million mmap tries before, one now). Built with thunks, tagged at the
built commit, mirrored into DroidDeck-Components (PR #3). The Steam client setting names the FEX build.
The bring-up crash logger (`tools/diag`) left the branch. Device: FEX r4 downloaded on first start,
client UI in 23 s (19-33 s before), TF2 played. The first session on the new build drew no SVG fills
in Steam's UI (icons blank, outlines and text fine); a restart drew them, cause not found.
- **Added games**: the scan treated every folder of the card library as a game folder except
`steamapps`, so the x86-64 client's `x86-64/` library showed up as a custom game named "x86-64"
with the exe of a Steam title installed there (FlatOut). `AddedGames.isSteamLibraryFolder` skips it
like `steamapps` in the scan, the exe-to-folder lookup and the folder list (`AddedGamesScanTest`).
- Rebased onto org main 45513c90; squashed again for a full automated review (personal PR #72,
replacing #71).
- **PR #72 review**: grid art, the content-log monitor, the repair marker, storage leftovers and the
VAC diagnostics files are kept per client (the x86-64 client's first art sync was deleting chosen
art as "removed in Steam"); the old games-folder migration skips the x86-64 library too.
- **Which client**: the Games header shows a plain "x86-64" chip while the x86-64 client's list is
shown (on its own line in a narrow pane); nothing changes for arm64. The setting reads "Applies next
session. FEX 2610 r4" with the choices ARM64 and x86-64 (`GamesClientTagTest`).
- **Stale list**: every refresh drops the Steam-client games when they belong to another client than
the active one (a setting change, the end of a session, a resume), keeping added and store games;
the cached list records its client in the same file (`library-cache.json`, saved under one lock). The next review of PR #72 had no
findings. Device: the header chip and the removed "x86-64" entry seen on 04568806.
- **x86-64 rootfs in DroidDeck-Components**: FEX-Emu's Ubuntu 24.04 image (2026-08-11, 525,615,104
bytes, unmodified) is now fetched from Droid-Deck/DroidDeck-Components release
`fex-rootfs-ubuntu-24.04-2026-08-11` instead of rootfs.fex-emu.gg. Components PR #4 added
`fex-rootfs.json` and `mirror-upstream.yml` (fetches a catalog's upstream link, refuses it unless
sha256 and size match, checks the digest GitHub stored); its run 39084452789 brought the file in.
The sha256 the app pins is unchanged, so an unpacked rootfs is kept.
- **One cache file**: the review of that change found the list and its client marker written as two
files, so concurrent saves could pair one client's list with the other's marker. `library-cache.json`
is now one object (`client`, `games`) written by one rename under a lock; a bare list from earlier
builds reads as arm64's. Device (73e2a80b): the first start after install read the old list,
rescanned and wrote `{"client":"x86_64","games":[...]}` with the same 23 games.
- A scan reads the client it tagged the cache with (`Library.launchableGames(arch = ...)`), so a switch
during a scan cannot pair one client's games with the other's tag; a failed rename drops the cache
instead of leaving the previous list behind (`LibraryArchTest`).
- The next review of PR #72 (a2552c58) had no findings; every review thread is resolved. Device: 43f9a8a8
installed.
- Device checks on 43f9a8a8: the setting reads "Applies next session. FEX 2610 r4" with the choices
ARM64 and x86-64; picking ARM64 drops the chip and the Games tab showed the arm64 list on first
look, and back on x86-64 the chip and the x86-64 list return (the cache records each client). Two
custom games' chosen background art is kept, with the same file in both clients' grid folders.
Team Fortress 2 launched from the Games tab with the x86-64 client selected: the session started
the client, handed it app 440 and the Linux build (`tf_linux64` under FEX) took focus about 60 s
after the tap.
- **Translations**: the branch's 11 strings (the Steam client setting and its values, VAC diagnostics,
the x86-64 loading title and the Storage labels) are in all 31 other locales, after
`stores_registering` as in the English file, reusing each locale's wording for "Steam client",
"Session logs" and "Uninstall" and its formal or informal address. Every file parses, every
translation keeps its English placeholders, and no locale has a key the English file lacks.
- The translations went to PR #72 (a43217c4) for review; org main is still 45513c90, so the branch
needs no rebase before the org PR. Device: 9afb9bd5 installed.
- **Org PR**: the draft #152 from 2026-10-02 (same branch name, 12 commits, conflicting with main) is
refreshed as one commit on org main 45513c90 and marked ready, once the review of the translations
is clean. Its old head is kept at `refs/backup/20261018/x86-64-steam-client-afa479ee`.
- Korean: the review of the translations found `을(를)` left in the two uninstall strings; both client
names end in a vowel when read, so they take `를`. The next review was clean.
- **Submitted**: #152 is one commit (a53fd117) on org main 45513c90, ready for review; Build APK passed.
Its `kwin` job (Build desktop package, KDE Plasma) failed for an unrelated reason: `build-kwin.sh`
requires Arch Linux ARM's `kwin-6.7.5`, which `extra` replaced with 6.8.0 on 2026-10-15; the job ran
only because refreshing the branch from its 2026-10-14 head spanned main's own `tools/desktop-kde`
changes. This branch changes nothing there.
- **Org review** (CodeRabbit on #152, 7 points): the library cache is saved only by the newest scan; a
session records the client it ran (`steam-client.txt`) so an abandoned session's Steam logs come
from that client; two console logs with the same game and mod get distinct names in the VAC report;
CPU pinning also unwraps `FEXInterpreter`; the VAC hint names the game in pl, ru, uk, cs and bg.
Pinning `actions/download-artifact` to a SHA was left out: no workflow here pins one. Follow-up
commit eb8634ea.
- Merged as ae94d9db.
- **FEX r5** (report: a Galaxy S25 without the KDE desktop): the x86-64 client exited 127 at its first
FEX call, `error while loading shared libraries: libxxhash.so.0`. FEX, FEXServer, FEXRootFSFetcher
and FEXOfflineCompiler link libxxhash; linuxfs-r9 does not ship it, and the test device had it only
from the KDE desktop package. r5 bundles it in `usr/local/lib` with RUNPATH `$ORIGIN/../lib` on
those programs (the runtime has no ld.so.cache), and `build-in-arch.sh` now fails when a program
needs a library that is neither bundled nor listed in `tools/fex/runtime-libs.txt` (the r9 libraries
FEX uses). With the device's libxxhash hidden, r4 failed with the reported error and r5 loaded.
Mirrored in DroidDeck-Components (#5); the app moves to fex-2610-r5 and an installed r4 is replaced
at the next x86-64 start.
- Merged as 7d21ba22.
- **FEX from the catalog**: FEX builds may need to follow user reports faster than app builds, so the
app now reads Components' `fex.json` (directly, kept between starts) and passes it to the session; a
build is installed only once its sha256 matches and its programs load, the installed one stays while it
loads, and the build pinned in the script remains the first-start default. **Session logs** for the
x86-64 client now name the FEX build and its source, the rootfs and the client's builds, report the
rootfs download in tenths instead of a progress bar (one report's log ended in hundreds of bar lines),
and, when the web helper's GPU process keeps exiting (a report from an Adreno 840: the menus never
appeared), say so in one line with x86 `glxinfo`/`vulkaninfo` through FEX. Settings shows the FEX
build installed. FEX is now built and published by DroidDeck-Components' `build-fex.yml` from this
repository's `tools/fex`, replacing the archive on its single `fex` release and recording the version
in `fex.json`; this repository's `build-fex.yml` only checks the build. Tests: `test_fex_select.py`,
`FexCatalogTest`.
Components' first build there published `2610-r6` (run 39104443888). Device (Pocket FIT, r4
installed): the app read `fex.json`, and the next x86-64 start logged `downloading FEX (fex-2610-r6)`
then `FEX ready (fex-2610-r6, catalog)` a second later, with the rootfs and client lines; the client
came up on it.
- Review of #1053: an install keeps the files it replaces aside until the installed copy loads and puts
them back otherwise (a failed copy could leave a partly replaced FEX marked as the new one;
`test_fex_install.py`), and the catalog is read on its own thread, not before the library scan, and
not in Offline Mode.
## 2026-10-15 - `feat/x86-64-steam-client`: SD card library, ARM parity, org main, Counter-Strike 2
- **SD card library** (`3fc74f3e`): the x86-64 client had internal storage as its only library, so Steam's
install dialog never offered a place. It now has `x86-64/` inside the card library
(`/mnt/droiddeck-sd/x86-64`; compatdata, shadercache and runtime tools private like the arm64 card
library). Before building, FEX was shown to run x86 programs from the noexec exFAT card. Device: the
dialog offers Internal Storage and SD card.
- **Card downloads** (`f77113fb`): `storage.c` (no zero-filled reservation where `fallocate` is refused)
and `dircache.c` recognised only `steamrtarm64/steam`; the x86-64 client wrote each download in full as
zeros first (Audiosurf: 415 MB written, 0 downloaded). Device: FlatOut, 1.19 GB, to the card in 51 s.
- **zRAM** (`877c27eb`): the watcher read argv[0], which under FEX is FEX; it now reads FEX's first argument.
- **ARM parity** (`b0923f05`): touch layouts read the active client's files; Offline Mode is written to
both clients' `loginusers.vdf`; the Lossless Scaling ownership check runs for x86-64; Repair Steam repairs
the x86-64 client too (and two unshown starts do, like arm64; the app clears its start count). Device: no
repair on the stale 31-start count, count cleared once the client was on screen. Switch to Desktop needs
no change (Deck mode: an x86 shell under FEX runs `steamos-session-select`).
- **DroidDeck-Proton default** (`2bd752aa`): newest DroidDeck-Proton before GE-Proton; `default-tool`
records the default this script set, and on a change the titles still on it move along (device: 113 off
GE-Proton11-7). A default the user chose is kept. FlatOut from the card: in game on DroidDeck-Proton
11-9.1, exits on GE-Proton11-7 (no log).
- **Org main** merged (`714198bf`, 36 commits; strings and `steam_started` conflicts kept both sides).
A stale line from the 10-07 sync ran the live compat helper in the foreground on the arm64 restart path:
black screen after a Steam import restart (`02e7c503` matches main again).
- **VAC findings:** x86-64 client + Linux builds secure (TF2, HL, L4D2). x86-64 client + Windows build
(TF2 on DroidDeck-Proton) insecure: Proton reaches Steam through the arm64 client's `steamclient.so`.
arm64 client + L4D2 blocked on both Protons; arm64 + Half-Life (Windows) joined a vac=1 GoldSrc server.
Why the arm64 client fails is not established. Sharing libraries between the clients stays parked.
- **arm64 `droiddeck-fex`** (`a53b49ef`): Valve's FEX-Emu keeps its server socket in
`$STEAM_COMPAT_DATA_PATH/fex-emu/Server` whatever `FEX_APP_DATA_LOCATION`/`FEX_SERVERSOCKETPATH` say;
under the app's folder that path passed 108 bytes (Half-Life: 136) and FEXServer never listened. A short
path is used now. 32-bit Linux titles through it still fail (not pursued).
- **Counter-Strike 2** (Linux, newer runtime) hung at 100% of one core before loading its engine. With
ptrace taken by proot, `perf_event_open` IP sampling put the loop in FEX's `MemAllocator32Bit`: CS2
reserves the low 2 GB with `MAP_32BIT` in shrinking sizes, FEX met `EEXIST`, stepped down one page per
retry and restarted from the top for ever. **`fex-2610-r3`** (patch `0003`, `b0d316dc`): such a range is
skipped whole and a second exhausted scan returns `-ENOMEM`, as the kernel does. Next, its menu text
library failed: CS2 ships an older `libfreetype.so.6` but no HarfBuzz, and the rootfs's needs a newer
FreeType; the game flagged its files corrupt (a verify each start) and waited behind an error box that
could not open. DroidDeck direct now preloads Valve's runtime HarfBuzz (2.7) for newer-runtime titles
(`c9eb7d30`). Device with both done by hand: engine and modules loaded (5 GB) with no error, then Android
killed DroidDeck for memory while it was in the background. Not yet seen at the menu.
- **CS2 on the r3 APK** (`c9eb7d30`, no launch options): no hang, the menu text library loads, the game
client and modules load and the window resizes. Every run then ended in Android's low-memory killer:
CS2 goes from 3.5 to 5.3 GB in about 15 s and `lmkd` killed DroidDeck (adj 200, in the background
while the device was in Termux) with zRAM on and still filling (1.5 to 3.2 GB of 6 GB). This device's
`lmkd` runs in minfree mode (`persist.device_config.lmkd_native.use_minfree_levels=true`; background
apps go below about 120 MB free), which does not count swap. Proposed, not applied: switch it to the
PSI mode (`use_minfree_levels=false`, `lmkd.reinit=1`; reverted with `true`). CS2 has not been seen
at its menu yet.
- **CS2 reached its menu (14:47, DroidDeck in the foreground):** still loading, 11 fps, 11.6 GiB used.
Five seconds later `lmkd` killed nearly every app, then DroidDeck itself at adj 0 (foreground), while
swap was about 22 of 23 GB free (zRAM 6 GB with 0.9 GB used, `/data/swapfile` 16 GB almost unused).
Steam and CS2 died with the app; neither crashed on its own.
- **Root test without changing Android (lmkd left as is):** DroidDeck's zRAM watcher only pages out
`steam` and `steamwebhelper`, never the game. A root tool (`/data/local/tmp/pageout cs2 3000 <log>`)
calls `process_madvise(MADV_PAGEOUT)` on CS2's private anonymous memory whenever MemAvailable drops
below 3 GB, so free RAM stays above the killer's levels. One call per mapping in 1 GB pieces: a single
call with many ranges is capped at about 2 GB in total, and FEX reserves huge ranges, so batched calls
silently skipped most of a FEX process. With that fixed, the x86 Steam client moved 223 of 256 MB to
swap. If CS2 survives to its menu, the app version is the zRAM watcher also paging out the running
game during its load (no root).
- **Page-out result (14:59):** the tool fired at 2.9 GB available and moved 4.8 GB of CS2 to swap
(resident 5.8 → 1.5 GB). CS2 drew its full menu (2–34 fps). 27 s later `lmkd` still killed down to
DroidDeck at adj 0. Its kill record: MemFree 111 MB, Cached 6.3 GB, but **Shmem 3.4 GB and
Unevictable 3.5 GB** (Mlocked only 120 MB), swap 17 GB free. In minfree mode the "file" figure is
Cached minus Shmem, Unevictable and SwapCached, so about 0, and it kills to the foreground. After the
session died Shmem fell to 0.3 GB, so the 3.4 GB of pinned shared memory belonged to the CS2 session.
MemAvailable counted it as available, which is why the tool saw 3.7 GB of headroom 2 s before the
kill. Page-out cannot move unevictable pages.
- **Next run (15:04):** a root logger (`/data/local/tmp/shmlog.sh` → `shm-cs2.log`, every 3 s) records
MemFree / Cached / Shmem / Unevictable / Mlocked / SwapFree and every process holding more than 50 MB
of shared (`RssShmem`) or locked (`VmLck`) memory, to find who pins the 3.4 GB (FEX, the X server,
Vulkan buffers, a ramfs). No SysV shared memory on the device. Then the trigger moves from
MemAvailable to MemFree plus droppable file pages.
- **Dust II (15:27):** CS2 loaded Dust II "Training Day" (bots) at 78 fps, then was killed at 15:28:05 the
same way. From 15:25:30 Shmem and Unevictable rose from 0.25 to 3.9 / 4.0 GB, yet the largest process
mapping shared memory (an `exe`) held only about 535 MB, so about 3.3 GB is not mapped by any process.
- **Open files ruled out (15:32-15:36):** a second logger (`fdlog.sh` → `fd-cs2.log`) listed every memfd,
ashmem, deleted or tmpfs file of 20 MB or more held by the app. Only 64 MB PulseAudio memfds showed up
while Shmem reached 2.7 GB. The run was killed at 15:36:28 (Shmem 3.4 GB, Unevictable 3.5 GB).
- **Lead: GPU memory.** `/sys/class/kgsl/kgsl/page_alloc_max` reads 6.07 GB (the peak since boot), and the
driver has `max_reclaim_limit` / `page_reclaim_per_call`, i.e. a KGSL build with process reclaim, where
GPU pages are shmem-backed and stay unevictable while the process is in the foreground. That fits
memory that no process maps, that has no file, and that is freed when the session dies. Not proven.
- **Low-settings test (15:42):** `cs2_video.txt` in the x86 userdata: `gpu_mem_level` 3 → 0,
`cpu_level` 3 → 0, resolution 1024x576 → 854x480 (everything else was already at its lowest; backup
`cs2_video.txt.before-low`). A third logger (`gpulog.sh` → `gpu-cs2.log`, every 3 s) records KGSL
`page_alloc`, MemFree, Shmem, Unevictable and each process with more than 200 MB of GPU memory. If
Shmem rises with `page_alloc`, the GPU memory is what is pinned. Background apps were force-stopped
and caches dropped first (about 11 GB free).
- **GPU memory confirmed (15:47):** with low settings CS2 reached its menu at 94 fps and was killed at
15:47:59 the same way. KGSL `page_alloc` followed Shmem within 20-120 MB through the whole load
(0.23 GB at 15:44:50, 0.93 GB at 15:46:47, then +2.2 GB in 3 s, about 3.2 GB at the kill), and
Unevictable stayed about 100 MB above it. One process (`exe`, pid 23906) held nearly all of it. So the
pinned 3.4 GB is the game's GPU memory, and the low settings did not make it smaller. (The negative
values in that log were the device shell's 32-bit arithmetic; the logger now divides in awk.)
- **New page-out trigger (16:23):** the tool now acts on what lmkd's minfree mode looks at, MemFree plus
`Cached + Buffers - Shmem - Unevictable`, instead of MemAvailable (which counted the GPU memory as
free). Threshold 2500 MB, checked every 0.5 s, page-out at most once a second. The idea: push CS2's
swappable memory out early so the 3.2 GB of GPU memory fits beside it. The open-file logger was stopped.
- **Still to test:** CS2 without the kill; touch layouts, Offline Mode, Switch to Desktop, a Linux game from
the card. TF2 on the device is the Windows build after the VAC test.
## 2026-10-14 (evening) - `feat/x86-64-steam-client`: Team Fortress 2 runs (sniper check, FEX thunks)
- **Sniper check:** `tf.sh` reads `VERSION_CODENAME` from `/etc/os-release` and stops unless it is
`sniper`. DroidDeck direct cannot use Valve's sniper container (no user namespaces under Android), so
for titles in `direct-sniper` it runs such a script through `bash -c` with that line replaced by
`VERSION_CODENAME=sniper` (`92d65e3d`). Device: TF2 gets past the check and loads on the rootfs
libraries; sniper itself is not needed.
- **Then a crash at the first frames**, with both launch options (Vulkan and Legacy OpenGL). Strict FEX
memory ordering and turning off the Gamescope WSI layer changed nothing. With FEX thunks off (lavapipe,
software) TF2 reached its main menu and loaded a match at 17 fps, so the thunks were the cause; with
only the Vulkan thunk on it still crashed.
- **Cause (from a SIGSEGV report taken inside the game):** the main thread was executing Turnip's
`vk_common_CmdBindDescriptorSets` as x86 code, an address FEX never linked. FEX-2609's guest
`vkGetDeviceProcAddr` passes a lookup of `vkGetDeviceProcAddr` itself to the host and hands back the
raw host function; every pointer fetched through it is unlinked. TF2's native DXVK (2.3) makes that
lookup; the Windows games through Proton do not. FEX-2610 answers self-lookups in the guest
(FEX-Emu/FEX 3397eb9b).
- **FEX 2610** (`af49e545`): `tools/fex/build-in-arch.sh` builds FEX-2610 with the same openat2 patch and
host thunks as `fex-2609-r3`. The container's newer clang has no `FileManager::clearStatCache()`,
which the thunk generator called after each run; patch `0002` drops that call (`31d66e67`). Published
as `fex-2610-r1` on the personal repo.
- **2610 alone did not fix it.** An ARM-side signal hook in FEX (wrapping its `syscall(rt_sigaction)`)
logged no fault in Turnip, and the guest thunk's disassembly showed why: Arch's clang builds the x86
guest thunks with stack protection, and the canary load in `CallHostFunction`'s prologue
(`mov %fs:0x28, %r11`) overwrote r11, which carries the host function's address, before the thunk
stored it. FEX called the canary. 9 of 521 Vulkan and 66 of 736 GL host calls (those with 7+
arguments) were affected, `vkCmdBindDescriptorSets` among them. Windows games never reach these
thunks (they run on the arm64 Proton) and 32-bit games pass the host address in mm0.
- **`fex-2610-r2`** (`56e5a712`): the toolchain files add `-fno-stack-protector` for the guest thunks;
no guest thunk library has a canary load. The x86 client fetches it (`9cc0ebcb`). Device, stock
settings: TF2 main menu at 358 fps on the GPU, Casual matchmaking onto a VAC-secured Valve server,
in game at about 84 fps; Half-Life (32-bit, direct) runs on it. Windows games are not on this FEX:
in x86 client mode they still run on the arm64 Proton (Borderlands 2: GE-Proton11-7-aarch64), whose
own FEX translates the game.
- **Which FEX runs what in x86 client mode:** the client (`steamrt64/steam`) and every Linux game on
DroidDeck direct run on the runtime's FEX (`fex-2610-r2`); Windows games go to the arm64 Proton and
its own FEX. At each start every library game with a Linux build, installed or not, is mapped to
DroidDeck direct, so Steam downloads the Linux depots; Windows-only games stay on the default
Proton, and a game already installed as its Windows build stays there (Borderlands 2 here:
`Binaries/Win32`, depot 49521). A game new to the library mid-session is mapped at the next start.
- APK run 38670180921 (`9cc0ebcb`), signed and staged as `DroidDeck-x64-fex2610r2-9cc0ebcb.apk`.
## 2026-10-14 - `feat/x86-64-steam-client`: brought up to main; added games run through the x86-64 client
Work lives on The412Banner/DroidDeck only until it runs correctly; the org branch and draft PR #152
stay as they were (`afa479ee`).
- **Merged main (94 commits).** Main dropped the Steam channel and Deck-mode settings (#546): only the
client choice (`steamClientArch`), `activeSteamRoot` and VAC diagnostics remain. Main's web-helper
arguments (#566, `bl_webhelper_argv`) are composed with the x86 client's arm64 web-helper redirect in
`preload/net.c`. Main's single background loop (#575) now carries the x86 hooks: the client's own
compat pass (`--x64 --live`), `steamrt64` in the pinning and stale-tmp patterns, Lossless probe off for
x86, and the once-per-title restart after a new mapping. The text-strings merge had dropped three of
main's strings; main's files were taken and the six x86 strings added back.
- **Builds on the personal repo** need the pinned Debian image in a release of their own:
`ci-debian-bullseye-r1` was copied (same sha256) as a pre-release. The bullseye compilers come from
`deb.debian.org` first; `archive.debian.org` no longer serves `libmpfr6_4.1.0-3`.
- **Client update before start** (`x64_sync_client`): Valve serves one build to both clients; the x86
client is brought to it (changed parts only, 9 of 28 on device) so it can use the arm64 web helper on
the GPU from the first frame (device: 144 fps menus).
- **Web helper loop:** with main's in-process GPU arguments the x86 web helper's GPU crash took the
whole helper down (102 restarts). The x86 helper now starts with stock arguments.
- **Loading screen:** "Starting Steam x86-64", and the x86 setup and update steps reach it.
- **Added games:** written first, then pointed at the x86 client's Proton (they were mapped to the
arm64 client's tool name, which this client ignores). Windows components install into the x86
prefix (DiRT Showdown and DiRT 3 needed OpenAL).
- **Hidden game window, two causes.** The arm64 launcher loaded the saved arm session's `DISPLAY=:0`
over the game display Steam chose; it now keeps Steam's. And the game window had no `STEAM_GAME`
tag, so gamescope never listed it as focusable; the agent's focus watcher now tags untagged
`steam_app_<id>` windows on the game display when the x86 client runs. Device: DiRT 3 (added game)
at its title screen at 144 fps.
- **Parity with the arm64 client:** Steam UI scale and the Steam import watcher run for the x86 client
too (the import is untested). An SD-card library stays arm64-only: FEX cannot run Linux games from it.
- **Later the same day:**
- DiRT Showdown's ~7 s exit was the same `DISPLAY=:0` problem; it runs now.
- **Tools adopted as arm64 Protons:** main's `droiddeck-steam-shortcuts` calls `adopt_extras` on the
client's `compatibilitytools.d`; in the x86 client that rewrote DroidDeck direct as a Windows tool,
so Steam installed Half-Life's Windows files. The x86 client no longer adopts its tools, and its
preparation removes what an adoption left. The arm64 Proton is adopted on demand at an x86 launch.
- **Scout runtime:** shipped as `steam-runtime.tar.xz`; `steam.sh` unpacks it, and the x86 client
is started without it. Its preparation unpacks it now. Device: Half-Life's native Linux build at
101 fps on the GPU under FEX (32-bit), and a VAC-secured multiplayer server.
- **Install scripts:** the evaluator script is looked up in the x86 client's folder. A game's own
installers without a `HasRunKey` (Borderlands 2: VC++ 2005/2008/2010, DirectX, .NET 4.0) are now
marked under `Software\Valve\Steam\Apps\<appid>` (and the Wow6432Node view); Steam skips them.
This applies to the arm64 client too. Device: Borderlands 2 started past its install script, and
on the build that marks them itself it went straight into the game.
- **Windows components** detection follows the chosen client (in-session install watch and
`appinfo.vdf`).
- **Next:** Counter-Strike: Source, the first game on the newer (sniper) runtime path, where DroidDeck
direct runs it on the rootfs instead of the scout runtime.
- **Open:** newer-runtime Linux games (sniper) untested; the arm64 client untested on this build; the
Steam import watcher untested; an SD-card library for the x86 client deferred.
## 2026-10-10 - `feat/wincomp-auto-sources`: Windows components picked by the game's source
- **Per source** (`WinCompSources`): Steam - Steamworks Shared depots from the appmanifest or the
@@ -87,6 +438,104 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
idempotent, store and steamapps skipped, libraries empty, same-name folders), `GameStorageTest`
updated. Not yet on a device.
## 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
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/bash
# Runs inside an Arch Linux ARM container (menci/archlinuxarm:base-devel) on an arm64 runner. Arch Linux ARM ships
# no FEX package and FEX publishes no Linux binaries.
set -euxo pipefail
VERSION=${FEX_VERSION:-FEX-2610}
WORK=/work
cd "$WORK"
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 patchelf xxhash
# The x86 sysroot is Arch x86_64 and multilib i686 packages unpacked by pacman, never run: no scriptlets or 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
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; here the guest thunks need the x86 sysroot too.
# Arch's clang adds stack protection; its canary load in a guest thunk's prologue clobbered r11 (the host function).
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"
printf '\nset(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fno-stack-protector")\nset(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fno-stack-protector")\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
# The Linux runtime has no xxhash and no ld.so.cache: the library goes beside libFEXCore and the programs
# that need it find it through RUNPATH.
install -Dm644 "$(readlink -f /usr/lib/libxxhash.so.0)" out/usr/local/lib/libxxhash.so.0
bundled=$(cd out/usr/local/lib && ls -- *.so* | sort -u)
for f in out/usr/local/bin/*; do
[ -L "$f" ] && continue
file "$f" | grep -q ELF || continue
readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }' | grep -qxF "$bundled" \
&& patchelf --set-rpath '$ORIGIN/../lib' "$f"
done
# Every NEEDED soname must resolve in a stock guest: bundled here or provided by the runtime (runtime-libs.txt).
for f in out/usr/local/bin/* out/usr/local/lib/*.so* out/usr/local/lib/fex-emu/HostThunks*/*.so; do
[ -f "$f" ] && file "$f" | grep -q ELF || continue
readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }'
done | sort -u | tee fex-needed.txt
missing=$(printf '%s\n' "$bundled" | cat - tools/fex/runtime-libs.txt | sort -u | comm -23 fex-needed.txt -)
[ -z "$missing" ] || { echo "not in the Linux runtime or this package:" $missing >&2; exit 1; }
for f in out/usr/local/bin/*; do
[ -L "$f" ] || ! file "$f" | grep -q ELF && continue
if readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }' | grep -qxF "$bundled"; then
readelf -d "$f" | grep -q 'RUNPATH.*\$ORIGIN/\.\./lib' || { echo "$f needs a bundled library but has no RUNPATH" >&2; exit 1; }
fi
done
(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,203 @@
diff --git a/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp b/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp
index 7172460..2c80dca 100644
--- a/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp
+++ b/Source/Tools/LinuxEmulation/LinuxSyscalls/FileManagement.cpp
@@ -29,6 +29,7 @@ $end_info$
#include <algorithm>
#include <errno.h>
#include <cstring>
+#include <linux/magic.h>
#include <linux/openat2.h>
#include <fcntl.h>
#include <filesystem>
@@ -632,6 +633,163 @@ static bool ShouldSkipOpenInEmu(int flags) {
return false;
}
+static bool IsOnProcFS(int DirFD, const char* Pathname) {
+ int FD = ::openat(DirFD, Pathname, O_PATH | O_NOFOLLOW | O_CLOEXEC);
+ if (FD == -1) {
+ return false;
+ }
+
+ struct statfs Buffer {};
+ const bool Result = fstatfs(FD, &Buffer) == 0 && Buffer.f_type == PROC_SUPER_MAGIC;
+ close(FD);
+ return Result;
+}
+
+// openat2() with RESOLVE_IN_ROOT, walked one component at a time: PRoot has no openat2 and fails it with ENOSYS.
+// DirFD is AT_FDCWD only for thunk overlays, which never resolve in the root.
+static int OpenatWithoutOpenat2(int DirFD, const char* Pathname, int Flags, uint32_t Mode) {
+ if (DirFD == AT_FDCWD) {
+ return ::syscall(SYSCALL_DEF(openat), DirFD, Pathname, Flags, Mode);
+ }
+
+ // The kernel's MAXSYMLINKS.
+ constexpr uint32_t MaxSymlinks = 40;
+ uint32_t Symlinks {};
+ std::optional<dev_t> RootDev {};
+
+ // O_NOFOLLOW and O_CREAT | O_EXCL leave a final symlink alone, unless a trailing slash follows it.
+ const bool NoFollow = (Flags & O_NOFOLLOW) || (Flags & (O_CREAT | O_EXCL)) == (O_CREAT | O_EXCL);
+
+ char Resolved[PATH_MAX];
+ size_t ResolvedLen {};
+
+ std::array<char[PATH_MAX], 2> Pending;
+ uint32_t PendingIndex {};
+ const char* Remaining = Pathname;
+
+ for (;;) {
+ while (*Remaining == '/') {
+ ++Remaining;
+ }
+
+ if (*Remaining == 0) {
+ break;
+ }
+
+ const char* Component = Remaining;
+ const char* ComponentEnd = Component;
+ while (*ComponentEnd != 0 && *ComponentEnd != '/') {
+ ++ComponentEnd;
+ }
+ const size_t ComponentLen = ComponentEnd - Component;
+
+ Remaining = ComponentEnd;
+ while (*Remaining == '/') {
+ ++Remaining;
+ }
+ const bool Last = *Remaining == 0;
+ const bool TrailingSlash = Last && Remaining != ComponentEnd;
+
+ if (ComponentLen == 1 && Component[0] == '.') {
+ continue;
+ }
+
+ if (ComponentLen == 2 && Component[0] == '.' && Component[1] == '.') {
+ while (ResolvedLen > 0 && Resolved[ResolvedLen - 1] != '/') {
+ --ResolvedLen;
+ }
+ if (ResolvedLen > 0) {
+ --ResolvedLen;
+ }
+ continue;
+ }
+
+ const size_t Separator = ResolvedLen > 0 ? 1 : 0;
+ const size_t CandidateLen = ResolvedLen + Separator + ComponentLen;
+ if (CandidateLen + 1 >= PATH_MAX) {
+ errno = ENAMETOOLONG;
+ return -1;
+ }
+ if (Separator) {
+ Resolved[ResolvedLen] = '/';
+ }
+ memcpy(&Resolved[ResolvedLen + Separator], Component, ComponentLen);
+ Resolved[CandidateLen] = 0;
+
+ struct stat Buffer {};
+ const bool Follow = !Last || TrailingSlash || !NoFollow;
+ const int StatResult = Follow ? fstatat(DirFD, Resolved, &Buffer, AT_SYMLINK_NOFOLLOW) : -1;
+
+ if (StatResult == 0 && S_ISLNK(Buffer.st_mode)) {
+ if (++Symlinks > MaxSymlinks) {
+ errno = ELOOP;
+ return -1;
+ }
+
+ if (!RootDev) {
+ struct stat Root {};
+ if (fstat(DirFD, &Root) != 0) {
+ return -1;
+ }
+ RootDev = Root.st_dev;
+ }
+
+ if (Buffer.st_dev != *RootDev && IsOnProcFS(DirFD, Resolved)) {
+ // Only procfs has magic links. RESOLVE_IN_ROOT fails them with EXDEV and the callers then open without it.
+ return ::syscall(SYSCALL_DEF(openat), DirFD, Pathname, Flags, Mode);
+ }
+
+ char* Target = Pending[PendingIndex];
+ const ssize_t TargetLen = readlinkat(DirFD, Resolved, Target, PATH_MAX);
+ if (TargetLen <= 0) {
+ if (TargetLen == 0) {
+ errno = ENOENT;
+ }
+ return -1;
+ }
+
+ const size_t RestLen = strlen(ComponentEnd);
+ if (static_cast<size_t>(TargetLen) + RestLen >= PATH_MAX) {
+ errno = ENAMETOOLONG;
+ return -1;
+ }
+ memcpy(&Target[TargetLen], ComponentEnd, RestLen + 1);
+
+ if (Target[0] == '/') {
+ ResolvedLen = 0;
+ }
+ Remaining = Target;
+ PendingIndex ^= 1;
+ continue;
+ }
+
+ if (Last) {
+ // openat reports on the final component, which O_CREAT may create.
+ ResolvedLen = CandidateLen;
+ if (TrailingSlash) {
+ Resolved[ResolvedLen++] = '/';
+ }
+ break;
+ }
+
+ if (StatResult != 0) {
+ return -1;
+ }
+
+ if (!S_ISDIR(Buffer.st_mode)) {
+ errno = ENOTDIR;
+ return -1;
+ }
+ ResolvedLen = CandidateLen;
+ }
+
+ if (ResolvedLen == 0) {
+ Resolved[ResolvedLen++] = '.';
+ }
+ Resolved[ResolvedLen] = 0;
+ return ::syscall(SYSCALL_DEF(openat), DirFD, Resolved, Flags, Mode);
+}
+
bool FileManager::ReplaceEmuFd(int fd, int flags, uint32_t mode) {
char Tmp[PATH_MAX + 1];
@@ -678,6 +836,8 @@ uint64_t FileManager::Open(const char* pathname, int flags, uint32_t mode) {
// 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(openat), Path.FD, Path.Path, flags, mode);
+ } else if (fd == -1 && errno == ENOSYS) {
+ fd = OpenatWithoutOpenat2(Path.FD, Path.Path, flags, mode);
}
}
@@ -931,6 +1091,8 @@ uint64_t FileManager::Openat([[maybe_unused]] int dirfs, const char* pathname, i
// 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(openat), Path.FD, Path.Path, flags, mode);
+ } else if (fd == -1 && errno == ENOSYS) {
+ fd = OpenatWithoutOpenat2(Path.FD, Path.Path, flags, mode);
}
}
@@ -967,6 +1129,8 @@ 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) {
+ fd = OpenatWithoutOpenat2(Path.FD, Path.Path, how->flags, how->mode);
}
}
@@ -0,0 +1,9 @@
--- a/ThunkLibs/Generator/gen.cpp
+++ b/ThunkLibs/Generator/gen.cpp
@@ -840,6 +840,5 @@
const bool Success = Compiler.ExecuteAction(Action);
- Files->clearStatCache();
return Success;
}
@@ -0,0 +1,103 @@
diff --git a/Source/Tools/LinuxEmulation/LinuxSyscalls/LinuxAllocator.cpp b/Source/Tools/LinuxEmulation/LinuxSyscalls/LinuxAllocator.cpp
index 60b0ef1..4a94009 100644
--- a/Source/Tools/LinuxEmulation/LinuxSyscalls/LinuxAllocator.cpp
+++ b/Source/Tools/LinuxEmulation/LinuxSyscalls/LinuxAllocator.cpp
@@ -2,6 +2,7 @@
#include "LinuxSyscalls/LinuxAllocator.h"
#include "LinuxSyscalls/Syscalls.h"
+#include <FEXCore/Utils/Allocator.h>
#include <FEXCore/Utils/MathUtils.h>
#include <FEXCore/Utils/TypeDefines.h>
#include <FEXHeaderUtils/Syscalls.h>
@@ -9,6 +10,7 @@
#include <FEXCore/fextl/memory.h>
#include <bitset>
+#include <fcntl.h>
#include <linux/mman.h>
#include <unistd.h>
#include <sys/user.h>
@@ -82,6 +84,7 @@ private:
std::mutex AllocMutex {};
uint64_t FindPageRange(uint64_t Start, size_t Pages) const;
uint64_t FindPageRange_TopDown(uint64_t Start, size_t Pages) const;
+ bool SetHostMappedPages(uint64_t PageAddr, size_t PagesLength);
using FindHandler = uint64_t (MemAllocator32Bit::*)(uint64_t Start, size_t Pages) const;
FindHandler FindPageRangePtr {};
};
@@ -132,6 +135,37 @@ uint64_t MemAllocator32Bit::FindPageRange_TopDown(uint64_t Start, size_t Pages)
return 0;
}
+// Marks each run of host mappings that overlaps the range as used, in full. Returns false if none overlaps.
+bool MemAllocator32Bit::SetHostMappedPages(uint64_t PageAddr, size_t PagesLength) {
+ const int MapsFD = ::open("/proc/self/maps", O_RDONLY | O_CLOEXEC);
+ if (MapsFD == -1) {
+ return false;
+ }
+
+ const auto Gaps =
+ FEXCore::Allocator::CollectMemoryGaps(BASE_KEY << FEXCore::Utils::FEX_PAGE_SHIFT, TOP_KEY << FEXCore::Utils::FEX_PAGE_SHIFT, MapsFD);
+ close(MapsFD);
+
+ const uint64_t EndPage = PageAddr + PagesLength;
+ uint64_t MappedBegin = BASE_KEY;
+ bool Marked = false;
+ auto MarkIfOverlapping = [&](uint64_t MappedEnd) {
+ if (MappedBegin < MappedEnd && MappedBegin < EndPage && PageAddr < MappedEnd) {
+ SetUsedPages(MappedBegin, MappedEnd - MappedBegin);
+ Marked = true;
+ }
+ };
+
+ for (const auto& Gap : Gaps) {
+ const uintptr_t GapBegin = reinterpret_cast<uintptr_t>(Gap.Ptr);
+ MarkIfOverlapping(GapBegin >> FEXCore::Utils::FEX_PAGE_SHIFT);
+ MappedBegin = (GapBegin + Gap.Size) >> FEXCore::Utils::FEX_PAGE_SHIFT;
+ }
+ MarkIfOverlapping(TOP_KEY);
+
+ return Marked;
+}
+
void* MemAllocator32Bit::Mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset) {
std::scoped_lock<std::mutex> lk {AllocMutex};
size_t PagesLength = FEXCore::AlignUp(length, FEXCore::Utils::FEX_PAGE_SIZE) >> FEXCore::Utils::FEX_PAGE_SHIFT;
@@ -175,11 +209,17 @@ void* MemAllocator32Bit::Mmap(void* addr, size_t length, int prot, int flags, in
auto AllocateNoHint = [&]() -> void* {
bool Wrapped = false;
+ bool Restarted = false;
uint64_t BottomPage = Map32Bit && (LastScanLocation >= LastKeyLocation32Bit) ? LastKeyLocation32Bit : LastScanLocation;
restart: {
// Linear range scan
uint64_t LowerPage = (this->*FindPageRangePtr)(BottomPage, PagesLength);
if (LowerPage == 0) {
+ // A second scan from the start found nothing either: the range is full, as the kernel would say.
+ if (Restarted) {
+ return reinterpret_cast<void*>(-ENOMEM);
+ }
+ Restarted = true;
// Try again but this time from the start
BottomPage = Map32Bit ? LastKeyLocation32Bit : LastKeyLocation;
LowerPage = (this->*FindPageRangePtr)(BottomPage, PagesLength);
@@ -206,7 +246,17 @@ restart: {
return (void*)(uintptr_t)-errno;
} else {
// Try again
- if (SearchDown) {
+ if (Map32Bit) {
+ // MAP_32BIT from a 64-bit guest, whose other mappings bypass this allocator: once Counter-Strike 2 had reserved the
+ // low 2GB, stepping one page at a time scanned for ever. Record what the host holds there and rescan the range, or
+ // move past it if that mapping is gone.
+ const bool Marked = SetHostMappedPages(LowerPage, PagesLength);
+ if (SearchDown) {
+ BottomPage = Marked ? UpperPage - 1 : LowerPage - 1;
+ } else {
+ BottomPage = Marked ? LowerPage : UpperPage;
+ }
+ } else if (SearchDown) {
--BottomPage;
} else {
++BottomPage;
+12
View File
@@ -0,0 +1,12 @@
ld-linux-aarch64.so.1
libGLX.so.0
libOpenGL.so.0
libc.so.6
libgcc_s.so.1
liblz4.so.1
liblzma.so.5
liblzo2.so.2
libm.so.6
libstdc++.so.6
libz.so.1
libzstd.so.1
+9
View File
@@ -27,6 +27,15 @@ 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] /'
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
python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 | sed 's/^/[pads] /'
( while sleep 30; do python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 | sed 's/^/[pads] /'; done ) &
python3 /usr/local/bin/droiddeck-desktop-gpu 2>&1 | sed 's/^/[gpu] /'
@@ -21,6 +21,7 @@ Requests: ping, exec, focus, cdp, quit. Each answer is {"ok": true, ...} or
import base64
import json
import math
import re
import os
import selectors
import signal
@@ -254,6 +255,31 @@ def focus_snapshot():
return snap
STEAM_APP_CLASS = re.compile(r"steam_app_(\d+)$")
def tag_game_windows(snap):
if os.environ.get("BL_STEAM_CLIENT_ARCH") != "x86_64":
return False
tagged = False
for display, wins in snap.get("displays", {}).items():
if display == os.environ.get("DISPLAY"):
continue
for win in wins:
match = STEAM_APP_CLASS.match(win.get("class") or "")
if not match or win.get("appId") or win.get("map") != "viewable" or win.get("overrideRedirect"):
continue
try:
done = subprocess.run(["xprop", "-display", display, "-id", str(win["window"]), "-f", "STEAM_GAME", "32c",
"-set", "STEAM_GAME", match.group(1)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5).returncode == 0
except (OSError, subprocess.TimeoutExpired):
done = False
log("game window %s on %s tagged STEAM_GAME=%s: %s" % (win["window"], display, match.group(1), "ok" if done else "failed"))
tagged = tagged or done
return tagged
def vr_scene_app(path=Path('/run/droiddeck/droiddeck-steamvr/scene-focus.json'), proc=Path('/proc')):
"""Only a live SteamVR scene with its dashboard hidden may claim game focus."""
try:
@@ -326,6 +352,9 @@ class FocusWatcher(threading.Thread):
log("focus snapshot failed: %s" % e)
time.sleep(2)
continue
if tag_game_windows(snap):
self.changed.set()
continue
body = {k: v for k, v in snap.items() if k != "t"}
if body == self.last:
continue
@@ -4,8 +4,10 @@ import re
import sys
HOME = os.environ.get("HOME", "/root")
STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HOME, ".local/share/Steam")
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd", "SD Games"))
X64 = os.environ.get("BL_STEAM_CLIENT_ARCH") == "x86_64"
CLIENT_HOME = (os.environ.get("BL_X64_HOME") or os.path.join(HOME, ".droiddeck-x64")) if X64 else HOME
STEAM_ROOT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(CLIENT_HOME, ".local/share/Steam")
LIBRARIES = ((STEAM_ROOT, "Steam Games"), ("/mnt/droiddeck-sd/x86-64" if X64 else "/mnt/droiddeck-sd", "SD Games"))
NOT_GAMES = {228980, 1070560, 1391110, 1493710, 1628350, 3127680, 4183110, 4185400, 4427310, 4628740}
APPS_DIR = os.path.join(HOME, ".local/share/applications")
PREFIX = "droiddeck-game-"
@@ -422,6 +422,12 @@ def guest_environment(fex, env):
result["FEX_APP_CONFIG_LOCATION"] = CONFIG_DIR + "/"
result["FEX_APP_DATA_LOCATION"] = data + "/"
result["FEX_APP_CACHE_LOCATION"] = CACHE_DIR + "/"
# Valve's FEX-Emu binds its server socket under $STEAM_COMPAT_DATA_PATH/fex-emu whatever FEX_APP_DATA_LOCATION or
# FEX_SERVERSOCKETPATH say, and sun_path is 108 bytes: compatdata under the app's data folder is too long.
if result.get("STEAM_COMPAT_DATA_PATH"):
short = data if data != DATA_DIR else "/tmp/fex"
os.makedirs(short, exist_ok=True)
result["STEAM_COMPAT_DATA_PATH"] = short
result.setdefault("FEX_SILENTLOG", "0")
result.setdefault("FEX_OUTPUTLOG", "stderr")
if fex["portable"]:
@@ -261,7 +261,7 @@ def mark_install_scripts(command, env):
ones stop with an error."""
if not any(arg.lower().replace("\\", "/").endswith("iscriptevaluator.exe") for arg in command):
return
root = env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam")
root = env.get("BL_X64_STEAM_ROOT") or env.get("STEAM_COMPAT_CLIENT_INSTALL_PATH") or str(Path.home() / ".local/share/Steam")
scripts = [str(Path(root) / arg.replace("\\", "/")) for arg in command if EVALUATOR_SCRIPT.search(arg)]
scripts = [path for path in scripts if os.path.isfile(path)]
if not scripts:
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more