diff --git a/.github/workflows/build-fex.yml b/.github/workflows/build-fex.yml new file mode 100644 index 000000000..85ced9cfd --- /dev/null +++ b/.github/workflows/build-fex.yml @@ -0,0 +1,77 @@ +name: Build FEX (x86-64 Steam client experiment) + +on: + push: + paths: + - 'tools/fex/**' + - '.github/workflows/build-fex.yml' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish as (e.g. fex-2609-r1); empty = artifact only' + required: false + default: '' + thunks: + description: 'Build the host thunks (Vulkan/GL/X11/... to the runtime arm64 libraries)' + required: false + default: 'false' + cxxflags: + description: 'Extra CXXFLAGS for a diagnostic build (e.g. -DDEBUG_STRACE); never published with a tag' + required: false + default: '' + +permissions: + contents: 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 + + # Only this job can write: the build above runs third-party images and toolchains with a read-only token. + publish: + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.tag != '' && inputs.cxxflags == '' && inputs.thunks == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + name: fex + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + # droiddeck-steam-x64 pins each tag's sha256 and the build is not reproducible: never rebuild over a tag. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::release $TAG already exists; publish under a new tag" + exit 1 + fi + gh release create "$TAG" --target "$GITHUB_SHA" --latest=false --prerelease=false --title "$TAG" --notes "FEX for the runtime (tools/fex/build-in-arch.sh); fetched by droiddeck-steam-x64. sha256 $(cut -d' ' -f1 fex.tzst.sha256)" fex.tzst fex.tzst.sha256 + # Fetched by tag by the app, never installed by users: never the repo's Latest release nor a pre-release. + gh release edit "$TAG" --latest=false --prerelease=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98e50e792..5b1af9a2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 31a570494..4c41ea269 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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(null) private var steamGames by mutableStateOf>(emptyList()) + private var steamGamesX64 by mutableStateOf(false) @Volatile private var componentSweepDone = false @Volatile private var componentSweepSession: String? = null private var emulatorList by mutableStateOf>(emptyList()) private var runningLabel by mutableStateOf(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) @@ -1902,9 +1922,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 +1936,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 diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 180b9f5fa..cc1bc77a7 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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() { diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt index 969442631..d2128476a 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -51,25 +51,29 @@ object LogRedactor { @Volatile private var accounts: List = emptyList() - fun learnAccounts(loginUsers: java.io.File) { - val found = ArrayList() - 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)(?() + 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)(?]*?\\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 { val out = ArrayList() 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()) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt index 7fc51558f..c70b2a895 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGamesPublisher.kt @@ -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") } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GameFileSync.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GameFileSync.kt index 0196797cf..e31b86dd0 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GameFileSync.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GameFileSync.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt b/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt index faf79c153..2c544b010 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/GamesFolderMigration.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 24c2379e4..8fbb19b7c 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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> { - val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List> { + 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 { - val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam") + fun steamGames(context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context)): List { + 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() for ((library, label) in libraries) { @@ -134,19 +137,24 @@ object Library { } fun steamLibraryArt(context: Context): List> { - 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? = null): List { - 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? = null, + arch: String = SessionPrefs.activeSteamClientArch(context), + ): List { + 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 } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt index c8e1cbabf..3d03b2307 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/LibraryCache.kt @@ -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 = 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, 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) { + fun load(context: Context): List = 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, 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}") } } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SessionInstalls.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SessionInstalls.kt index 3c32bcb06..1f07da832 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SessionInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SessionInstalls.kt @@ -25,9 +25,16 @@ object SessionInstalls { STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!) }.getOrNull() - internal fun steamappsDirs(context: Context): List = listOfNotNull( - File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"), - GameStorage.effective(context)?.let { File(it.path, "steamapps") }, + internal fun steamappsDirs(context: Context): List = ( + 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) { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt index e847d8060..d43c4bafb 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamAppInfo.kt @@ -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) - 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? { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt index 4b0d951f7..cb4f49f0f 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGameArt.kt @@ -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 { - 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 { diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt index 615595deb..47b679031 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGrid.kt @@ -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 = @@ -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 = 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) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt index 375a60a57..f78cb629e 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/Lossless.kt @@ -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() } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt index 8f9d7a844..4025db934 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SteamTouchConfig.kt @@ -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") diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index 8a391f7ae..4bf70d9e4 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 096f8e588..90feec16e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -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 { diff --git a/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt b/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt index c276da390..5632bd27e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OfflineMode.kt @@ -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 { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt index 8a4927a90..e0e855742 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionArtifacts.kt @@ -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() + + /** 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)) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index bfdbd88e0..32e149aeb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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")) + } } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt index 4ff28ac02..80c00cc99 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionLogShare.kt @@ -80,7 +80,7 @@ object SessionLogShare { private fun liveSteamLogs(context: Context, folder: File): List { 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() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index d54632130..49aa2e9ba 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 3a0231746..1d8db3d53 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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,11 @@ 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 (SessionState.mode == MODE_STEAM) { guest.add("BL_GAMESCOPE_STRETCH_16X9=" + (if (SessionPrefs.stretch16x9(this)) "1" else "0")) } @@ -823,6 +835,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 +1402,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 +1430,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 +1634,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() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 8b407fc56..ab32b9059 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt index 1744dc28e..2558bfe94 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt @@ -12,12 +12,13 @@ internal class SteamDownloadMonitor { private var previous = emptyMap() private var lastProgressAt = 0L private var contentLogOffset = 0L + private var contentLog: File? = null private val activeContentUpdates = mutableSetOf() 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 { - 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() + 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""") } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt index 38fc9c235..9cd25d685 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SteamRepair.kt @@ -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() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt new file mode 100644 index 000000000..568188209 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/VacDiagnostics.kt @@ -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 + * "lengthcanonical 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 = 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 //console.log, Source 2 /game//console.log. */ + private fun consoleLogs(context: Context, arch: String): List { + val found = LinkedHashMap() + 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 { + 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): List { + val used = HashSet() + 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): Boolean = + log.lastModified() >= since && log.length() != armedSizes[canonical(log)] + + internal fun tail(file: File, from: Long = 0L): List = 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 { + 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()) +} diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt index c0f74ab18..e707fd59f 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt @@ -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, val otherIds: Set, val complete: Boolean) { val ids: Set 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, val installed: Set, 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()) } diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt index f4c6ca999..71eb79198 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageScan.kt @@ -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? 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() val games = ArrayList() @@ -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() 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 + diff --git a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt index 8be0a3e85..0724dd8dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt +++ b/app/src/main/java/com/droiddeck/launcher/storage/StorageState.kt @@ -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 }) diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt index 80a05b01d..9b3f7136d 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstalls.kt @@ -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}") diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt index f12a712f1..679348878 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt @@ -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, count: Int, current: Library.SteamGame, + games: List, 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() } 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, Int>?>(null) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index d96620da5..543778b34 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -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, + val steamGamesX64: Boolean = false, val emulators: List, 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 = {}, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 994aad935..6ac40e831 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 89267e8ca..2cd1c63b8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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,21 @@ 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 { "${it.groupValues[1]} r${it.groupValues[2]}" } + +@Composable +private fun x64FexVersion(context: android.content.Context): String? = remember { + 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 = 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 +452,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( diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt index f212fbbb5..7c1e45ffa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoragePage.kt @@ -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 }, ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index aada7eac0..b4c9938ba 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -2436,6 +2436,17 @@ تعذّر الاتصال بـ GOG تعذّر قراءة مكتبتك على GOG جارٍ التسجيل في Steam… + عميل Steam + FEX %1$s + ARM64 + x86-64 + تشخيص VAC + تسجّل ألعاب Source وحدة التحكم الخاصة بها (-condebug)؛ ويذكر vac.txt في سجلات الجلسة إن كانت آمنة أو غير آمنة + جارٍ تشغيل Steam x86-64 + %1$s · x86-64 + يستخدمه عميل Steam x86-64 + هذه النسخة موجودة في عميل Steam x86-64. اختر x86-64 في عميل Steam، ثم ألغِ تثبيتها هنا. + هذه النسخة موجودة في عميل Steam ARM64. اختر ARM64 في عميل Steam، ثم ألغِ تثبيتها هنا. النظارة نظارة VITURE التحكم في النظارة diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index cfec247e1..d4fcefe80 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -2384,6 +2384,17 @@ No se pudo conectar con GOG No se pudo leer tu biblioteca de GOG Registrando con Steam… + Cliente de Steam + FEX %1$s + ARM64 + x86-64 + Diagnóstico de VAC + Los juegos Source registran su consola (-condebug); vac.txt en los registros de sesión dice si es seguro o no + Iniciando Steam x86-64 + %1$s · x86-64 + Lo usa el cliente de Steam x86-64 + Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí. + Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí. Gafas Gafas VITURE Control de las gafas diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index c9f0fb974..9f3afeff5 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -2336,6 +2336,17 @@ Неуспешно свързване с GOG Библиотеката ви в GOG не можа да се прочете Регистриране в Steam… + Клиент на Steam + FEX %1$s + ARM64 + x86-64 + Диагностика на VAC + Игрите на Source записват конзолата си (-condebug); vac.txt в дневниците на сесията показва дали играта е защитена от VAC + Стартиране на Steam x86-64 + %1$s · x86-64 + Използва се от клиента на Steam x86-64 + Това копие е в клиента на Steam x86-64. Изберете x86-64 в „Клиент на Steam“, след което го деинсталирайте тук. + Това копие е в клиента на Steam ARM64. Изберете ARM64 в „Клиент на Steam“, след което го деинсталирайте тук. Очила Очила VITURE Управление на очилата diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index dae08a4d9..67fa3ad17 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2386,6 +2386,17 @@ Nepodařilo se spojit s GOG Vaši knihovnu GOG se nepodařilo načíst Registrace ve Steamu… + Klient Steam + FEX %1$s + ARM64 + x86-64 + Diagnostika VAC + Hry na Source zaznamenávají svou konzoli (-condebug); vac.txt v protokolech relace uvádí, zda je hra chráněna VAC + Spouštění Steamu x86-64 + %1$s · x86-64 + Používá klient Steam x86-64 + Tato kopie je v klientovi Steam x86-64. V nastavení Klient Steam vyberte x86-64 a pak ji odinstalujte tady. + Tato kopie je v klientovi Steam ARM64. V nastavení Klient Steam vyberte ARM64 a pak ji odinstalujte tady. Brýle Brýle VITURE Ovládání brýlí diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index b0609b8d3..91503e0ee 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -2336,6 +2336,17 @@ Kunne ikke oprette forbindelse til GOG Dit GOG-bibliotek kunne ikke læses Registrerer hos Steam… + Steam-klient + FEX %1$s + ARM64 + x86-64 + VAC-diagnostik + Source-spil logger deres konsol (-condebug); vac.txt i sessionslogs viser, om det er sikret eller ej + Starter Steam x86-64 + %1$s · x86-64 + Bruges af x86-64-Steam-klienten + Denne kopi ligger i x86-64-Steam-klienten. Vælg x86-64 under Steam-klient, og afinstaller den så her. + Denne kopi ligger i ARM64-Steam-klienten. Vælg ARM64 under Steam-klient, og afinstaller den så her. Brille VITURE-brille Brillestyring diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 052a5931c..7552cce41 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2322,6 +2322,17 @@ GOG konnte nicht erreicht werden Deine GOG-Bibliothek konnte nicht gelesen werden Wird bei Steam registriert… + Steam-Client + FEX %1$s + ARM64 + x86-64 + VAC-Diagnose + Source-Spiele protokollieren ihre Konsole (-condebug); vac.txt in den Sitzungslogs zeigt gesichert oder ungesichert + Steam x86-64 wird gestartet + %1$s · x86-64 + Wird vom x86-64-Steam-Client verwendet + Diese Kopie liegt im x86-64-Steam-Client. Wähle x86-64 unter Steam-Client und deinstalliere sie dann hier. + Diese Kopie liegt im ARM64-Steam-Client. Wähle ARM64 unter Steam-Client und deinstalliere sie dann hier. Brille VITURE-Brille Brillensteuerung diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 1da7c29cd..01a89d686 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2336,6 +2336,17 @@ Δεν ήταν δυνατή η σύνδεση με το GOG Δεν ήταν δυνατή η ανάγνωση της βιβλιοθήκης σας στο GOG Εγγραφή στο Steam… + Πελάτης Steam + FEX %1$s + ARM64 + x86-64 + Διαγνωστικά VAC + Τα παιχνίδια Source καταγράφουν την κονσόλα τους (-condebug)· το vac.txt στα αρχεία καταγραφής συνεδρίας δείχνει αν είναι ασφαλές ή όχι + Εκκίνηση του Steam x86-64 + %1$s · x86-64 + Χρησιμοποιείται από τον πελάτη Steam x86-64 + Αυτό το αντίγραφο βρίσκεται στον πελάτη Steam x86-64. Επιλέξτε x86-64 στο «Πελάτης Steam» και, στη συνέχεια, απεγκαταστήστε το εδώ. + Αυτό το αντίγραφο βρίσκεται στον πελάτη Steam ARM64. Επιλέξτε ARM64 στο «Πελάτης Steam» και, στη συνέχεια, απεγκαταστήστε το εδώ. Γυαλιά Γυαλιά VITURE Έλεγχος γυαλιών diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 45a7926b5..94710b72a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -2497,6 +2497,17 @@ Descargar componentes Las descargas se comparten con la página Componentes. Elige arriba una versión descargada para usarla al iniciar el próximo juego. Elige una versión para descargar + Cliente de Steam + FEX %1$s + ARM64 + x86-64 + Diagnóstico de VAC + Los juegos Source registran su consola (-condebug); vac.txt en los registros dice si es seguro o no + Iniciando Steam x86-64 + %1$s · x86-64 + Lo usa el cliente de Steam x86-64 + Esta copia está en el cliente de Steam x86-64. Elige x86-64 en Cliente de Steam y desinstálala aquí. + Esta copia está en el cliente de Steam ARM64. Elige ARM64 en Cliente de Steam y desinstálala aquí. Compresión zRAM 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. No disponible: este dispositivo no tiene swap activo, o su kernel es anterior a la 5.4. diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index f08e3bcdd..e3e927657 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -2336,6 +2336,17 @@ GOG:iin ei saatu yhteyttä GOG-kirjastoasi ei voitu lukea Rekisteröidytään Steamiin… + Steam-asiakasohjelma + FEX %1$s + ARM64 + x86-64 + VAC-diagnostiikka + Source-pelit kirjaavat konsolinsa (-condebug); istunnon lokien vac.txt kertoo, onko suojattu vai ei + Käynnistetään Steam x86-64 + %1$s · x86-64 + x86-64-Steam-asiakasohjelman käytössä + Tämä kopio on x86-64-Steam-asiakasohjelmassa. Valitse x86-64 kohdassa Steam-asiakasohjelma ja poista sen asennus sitten täältä. + Tämä kopio on ARM64-Steam-asiakasohjelmassa. Valitse ARM64 kohdassa Steam-asiakasohjelma ja poista sen asennus sitten täältä. Lasit VITURE-lasit Lasien hallinta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index fec2f05a1..791475a18 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -2355,6 +2355,17 @@ Impossible de contacter GOG Impossible de lire votre bibliothèque GOG Enregistrement auprès de Steam… + Client Steam + FEX %1$s + ARM64 + x86-64 + Diagnostic VAC + Les jeux Source journalisent leur console (-condebug) ; vac.txt dans les journaux de session indique sécurisé ou non sécurisé + Démarrage de Steam x86-64 + %1$s · x86-64 + Utilisé par le client Steam x86-64 + Cette copie se trouve dans le client Steam x86-64. Choisissez x86-64 sous Client Steam, puis désinstallez-la ici. + Cette copie se trouve dans le client Steam ARM64. Choisissez ARM64 sous Client Steam, puis désinstallez-la ici. Lunettes Lunettes VITURE Contrôle des lunettes diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 2872d9245..a5ded04c1 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -2336,6 +2336,17 @@ GOG से कनेक्ट नहीं हो सका आपकी GOG लाइब्रेरी पढ़ी नहीं जा सकी Steam में रजिस्टर हो रहा है… + Steam क्लाइंट + FEX %1$s + ARM64 + x86-64 + VAC डायग्नोस्टिक्स + Source गेम अपना कंसोल लॉग करते हैं (-condebug); सेशन लॉग में vac.txt बताता है कि सुरक्षित है या नहीं + Steam x86-64 शुरू हो रहा है + %1$s · x86-64 + x86-64 Steam क्लाइंट इसका उपयोग करता है + यह कॉपी x86-64 Steam क्लाइंट में है। Steam क्लाइंट में x86-64 चुनें, फिर इसे यहाँ अनइंस्टॉल करें। + यह कॉपी ARM64 Steam क्लाइंट में है। Steam क्लाइंट में ARM64 चुनें, फिर इसे यहाँ अनइंस्टॉल करें। चश्मा VITURE चश्मा चश्मे का कंट्रोल diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 12bd8128c..65b2417b4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2336,6 +2336,17 @@ Nem sikerült csatlakozni a GOG-hoz A GOG-könyvtáradat nem sikerült beolvasni Regisztráció a Steamnél… + Steam-kliens + FEX %1$s + ARM64 + x86-64 + VAC-diagnosztika + A Source-játékok naplózzák a konzoljukat (-condebug); a munkamenet-naplókban lévő vac.txt mutatja, hogy védett-e + Steam x86-64 indítása + %1$s · x86-64 + Az x86-64-es Steam-kliens használja + 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. + 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. Szemüveg VITURE szemüveg Szemüveg vezérlése diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9e362ff9c..1d40fa193 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -2311,6 +2311,17 @@ Tidak dapat menghubungi GOG Pustaka GOG kamu tidak dapat dibaca Mendaftar ke Steam… + Klien Steam + FEX %1$s + ARM64 + x86-64 + Diagnostik VAC + Game Source mencatat konsolnya (-condebug); vac.txt di log sesi menunjukkan aman atau tidak + Memulai Steam x86-64 + %1$s · x86-64 + Digunakan oleh klien Steam x86-64 + Salinan ini ada di klien Steam x86-64. Pilih x86-64 di Klien Steam, lalu hapus instalasinya di sini. + Salinan ini ada di klien Steam ARM64. Pilih ARM64 di Klien Steam, lalu hapus instalasinya di sini. Kacamata Kacamata VITURE Kontrol kacamata diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a6a1c9988..e46e4c31a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -2347,6 +2347,17 @@ Impossibile contattare GOG Impossibile leggere la tua libreria GOG Registrazione su Steam… + Client Steam + FEX %1$s + ARM64 + x86-64 + Diagnostica VAC + I giochi Source registrano la console (-condebug); vac.txt nei log della sessione indica se è protetto o no + Avvio di Steam x86-64 + %1$s · x86-64 + Usato dal client Steam x86-64 + Questa copia è nel client Steam x86-64. Scegli x86-64 in Client Steam, poi disinstallala qui. + Questa copia è nel client Steam ARM64. Scegli ARM64 in Client Steam, poi disinstallala qui. Occhiali Occhiali VITURE Controllo degli occhiali diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 35da191bb..9590f476d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -2311,6 +2311,17 @@ GOGに接続できませんでした GOGのライブラリを読み込めませんでした Steamに登録中… + Steamクライアント + FEX %1$s + ARM64 + x86-64 + VAC診断 + Sourceゲームはコンソールを記録します(-condebug)。セッションログのvac.txtで保護の有無がわかります + Steam x86-64を起動中 + %1$s · x86-64 + x86-64 Steamクライアントが使用 + このコピーはx86-64のSteamクライアントにあります。Steamクライアントでx86-64を選んでから、ここでアンインストールしてください。 + このコピーはARM64のSteamクライアントにあります。SteamクライアントでARM64を選んでから、ここでアンインストールしてください。 グラス VITURE グラス グラスの制御 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d613477bb..e9ad566d9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -2311,6 +2311,17 @@ GOG에 연결할 수 없습니다 GOG 라이브러리를 읽을 수 없습니다 Steam에 등록 중… + Steam 클라이언트 + FEX %1$s + ARM64 + x86-64 + VAC 진단 + Source 게임은 콘솔을 기록합니다(-condebug). 세션 로그의 vac.txt에서 보안 여부를 확인할 수 있습니다 + Steam x86-64 시작 중 + %1$s · x86-64 + x86-64 Steam 클라이언트에서 사용 + 이 사본은 x86-64 Steam 클라이언트에 있습니다. Steam 클라이언트에서 x86-64를 선택한 다음 여기에서 제거하세요. + 이 사본은 ARM64 Steam 클라이언트에 있습니다. Steam 클라이언트에서 ARM64를 선택한 다음 여기에서 제거하세요. 안경 VITURE 안경 안경 제어 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 82059ffd9..f402bab38 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -2311,6 +2311,17 @@ Tidak dapat menyambung ke GOG Pustaka GOG anda tidak dapat dibaca Mendaftar dengan Steam… + Klien Steam + FEX %1$s + ARM64 + x86-64 + Diagnostik VAC + Permainan Source merekodkan konsolnya (-condebug); vac.txt dalam log sesi menunjukkan selamat atau tidak + Memulakan Steam x86-64 + %1$s · x86-64 + Digunakan oleh klien Steam x86-64 + Salinan ini berada dalam klien Steam x86-64. Pilih x86-64 di bawah Klien Steam, kemudian nyahpasangnya di sini. + Salinan ini berada dalam klien Steam ARM64. Pilih ARM64 di bawah Klien Steam, kemudian nyahpasangnya di sini. Cermin mata Cermin mata VITURE Kawalan cermin mata diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index aa1f93e41..af17dbf03 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -2336,6 +2336,17 @@ Kunne ikke nå GOG Kunne ikke lese GOG-biblioteket ditt Registrerer hos Steam… + Steam-klient + FEX %1$s + ARM64 + x86-64 + VAC-diagnostikk + Source-spill logger konsollen sin (-condebug); vac.txt i sesjonsloggene viser om det er sikret eller ikke + Starter Steam x86-64 + %1$s · x86-64 + Brukes av x86-64-Steam-klienten + Denne kopien ligger i x86-64-Steam-klienten. Velg x86-64 under Steam-klient, og avinstaller den så her. + Denne kopien ligger i ARM64-Steam-klienten. Velg ARM64 under Steam-klient, og avinstaller den så her. Briller VITURE-briller Brillestyring diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 475b4d51f..533f9ce33 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -2336,6 +2336,17 @@ Kan GOG niet bereiken Je GOG-bibliotheek kon niet worden gelezen Registreren bij Steam… + Steam-client + FEX %1$s + ARM64 + x86-64 + VAC-diagnose + Source-games leggen hun console vast (-condebug); vac.txt in de sessielogs vermeldt beveiligd of onbeveiligd + Steam x86-64 wordt gestart + %1$s · x86-64 + Gebruikt door de x86-64-Steam-client + Deze kopie staat in de x86-64-Steam-client. Kies x86-64 bij Steam-client en verwijder hem daarna hier. + Deze kopie staat in de ARM64-Steam-client. Kies ARM64 bij Steam-client en verwijder hem daarna hier. Bril VITURE-bril Brilbediening diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e612170fa..61c698987 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -2386,6 +2386,17 @@ Nie udało się połączyć z GOG Nie udało się wczytać Twojej biblioteki GOG Rejestrowanie w Steam… + Klient Steam + FEX %1$s + ARM64 + x86-64 + Diagnostyka VAC + Gry na Source zapisują swoją konsolę (-condebug); vac.txt w logach sesji pokazuje, czy gra jest chroniona przez VAC + Uruchamianie Steam x86-64 + %1$s · x86-64 + Używane przez klienta Steam x86-64 + Ta kopia jest w kliencie Steam x86-64. Wybierz x86-64 w ustawieniu Klient Steam, a potem odinstaluj ją tutaj. + Ta kopia jest w kliencie Steam ARM64. Wybierz ARM64 w ustawieniu Klient Steam, a potem odinstaluj ją tutaj. Okulary Okulary VITURE Sterowanie okularami diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8565d8a01..771109e64 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -2347,6 +2347,17 @@ Não foi possível contactar a GOG Não foi possível ler a tua biblioteca da GOG A registar na Steam… + Cliente Steam + FEX %1$s + ARM64 + x86-64 + Diagnóstico do VAC + Os jogos Source registam a consola (-condebug); o vac.txt nos registos da sessão indica se está seguro ou não + A iniciar o Steam x86-64 + %1$s · x86-64 + Usado pelo cliente Steam x86-64 + Esta cópia está no cliente Steam x86-64. Escolha x86-64 em Cliente Steam e depois desinstale-a aqui. + Esta cópia está no cliente Steam ARM64. Escolha ARM64 em Cliente Steam e depois desinstale-a aqui. Óculos Óculos VITURE Controlo dos óculos diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 049f6c9f3..a7463002c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -2347,6 +2347,17 @@ Não foi possível conectar à GOG Não foi possível ler sua biblioteca da GOG Registrando na Steam… + Cliente Steam + FEX %1$s + ARM64 + x86-64 + Diagnóstico do VAC + Jogos Source registram o console (-condebug); o vac.txt nos logs da sessão informa se está seguro ou não + Iniciando o Steam x86-64 + %1$s · x86-64 + Usado pelo cliente Steam x86-64 + Esta cópia está no cliente Steam x86-64. Escolha x86-64 em Cliente Steam e depois desinstale-a aqui. + Esta cópia está no cliente Steam ARM64. Escolha ARM64 em Cliente Steam e depois desinstale-a aqui. Óculos Óculos VITURE Controle dos óculos diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c60ac8495..4bfcb9fa9 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -2361,6 +2361,17 @@ Nu s-a putut contacta GOG Biblioteca ta GOG nu a putut fi citită Se înregistrează la Steam… + Client Steam + FEX %1$s + ARM64 + x86-64 + Diagnosticare VAC + Jocurile Source își înregistrează consola (-condebug); vac.txt din jurnalele sesiunii arată dacă e securizat sau nu + Se pornește Steam x86-64 + %1$s · x86-64 + Folosit de clientul Steam x86-64 + Această copie este în clientul Steam x86-64. Alege x86-64 la Client Steam, apoi dezinstaleaz-o aici. + Această copie este în clientul Steam ARM64. Alege ARM64 la Client Steam, apoi dezinstaleaz-o aici. Ochelari Ochelari VITURE Controlul ochelarilor diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 86600fb34..901ffbc8d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2380,6 +2380,17 @@ Не удалось связаться с GOG Не удалось прочитать вашу библиотеку GOG Регистрация в Steam… + Клиент Steam + FEX %1$s + ARM64 + x86-64 + Диагностика VAC + Игры на Source записывают консоль (-condebug); vac.txt в журналах сессии показывает, защищена ли игра VAC + Запуск Steam x86-64 + %1$s · x86-64 + Используется клиентом Steam x86-64 + Эта копия находится в клиенте Steam x86-64. Выберите x86-64 в пункте «Клиент Steam», затем удалите её здесь. + Эта копия находится в клиенте Steam ARM64. Выберите ARM64 в пункте «Клиент Steam», затем удалите её здесь. Очки Очки VITURE Управление очками diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 415d25de3..45eb0e035 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -2336,6 +2336,17 @@ Det gick inte att nå GOG Det gick inte att läsa ditt GOG-bibliotek Registrerar hos Steam… + Steam-klient + FEX %1$s + ARM64 + x86-64 + VAC-diagnostik + Source-spel loggar sin konsol (-condebug); vac.txt i sessionsloggarna visar om det är säkrat eller inte + Startar Steam x86-64 + %1$s · x86-64 + Används av x86-64-Steam-klienten + Den här kopian finns i x86-64-Steam-klienten. Välj x86-64 under Steam-klient och avinstallera den sedan här. + Den här kopian finns i ARM64-Steam-klienten. Välj ARM64 under Steam-klient och avinstallera den sedan här. Glasögon VITURE-glasögon Glasögonstyrning diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 420b5002a..9f4e60ea0 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -2311,6 +2311,17 @@ เชื่อมต่อกับ GOG ไม่ได้ ไม่สามารถอ่านคลังเกม GOG ของคุณได้ กำลังลงทะเบียนกับ Steam… + ไคลเอนต์ Steam + FEX %1$s + ARM64 + x86-64 + การวินิจฉัย VAC + เกม Source บันทึกคอนโซลของเกม (-condebug); vac.txt ในล็อกเซสชันบอกว่าปลอดภัยหรือไม่ + กำลังเริ่ม Steam x86-64 + %1$s · x86-64 + ใช้โดยไคลเอนต์ Steam x86-64 + สำเนานี้อยู่ในไคลเอนต์ Steam x86-64 เลือก x86-64 ในไคลเอนต์ Steam แล้วถอนการติดตั้งที่นี่ + สำเนานี้อยู่ในไคลเอนต์ Steam ARM64 เลือก ARM64 ในไคลเอนต์ Steam แล้วถอนการติดตั้งที่นี่ แว่นตา แว่นตา VITURE การควบคุมแว่นตา diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 172966b7b..4273df1ff 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -2322,6 +2322,17 @@ GOG\'a bağlanılamadı GOG kitaplığın okunamadı Steam\'e kaydediliyor… + Steam istemcisi + FEX %1$s + ARM64 + x86-64 + VAC tanılaması + Source oyunları konsollarını kaydeder (-condebug); oturum günlüklerindeki vac.txt güvenli olup olmadığını gösterir + Steam x86-64 başlatılıyor + %1$s · x86-64 + x86-64 Steam istemcisi tarafından kullanılıyor + Bu kopya x86-64 Steam istemcisinde. Steam istemcisi altında x86-64 seçin, ardından burada kaldırın. + Bu kopya ARM64 Steam istemcisinde. Steam istemcisi altında ARM64 seçin, ardından burada kaldırın. Gözlük VITURE gözlüğü Gözlük kontrolü diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 621246030..6e305d204 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -2386,6 +2386,17 @@ Не вдалося з\'єднатися з GOG Не вдалося прочитати вашу бібліотеку GOG Реєстрація в Steam… + Клієнт Steam + FEX %1$s + ARM64 + x86-64 + Діагностика VAC + Ігри на Source записують свою консоль (-condebug); vac.txt у журналах сеансу показує, чи захищена гра VAC + Запуск Steam x86-64 + %1$s · x86-64 + Використовується клієнтом Steam x86-64 + Ця копія в клієнті Steam x86-64. Виберіть x86-64 у пункті «Клієнт Steam», потім видаліть її тут. + Ця копія в клієнті Steam ARM64. Виберіть ARM64 у пункті «Клієнт Steam», потім видаліть її тут. Окуляри Окуляри VITURE Керування окулярами diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index d28bdedb9..6551b2fff 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -2311,6 +2311,17 @@ Không thể kết nối với GOG Không thể đọc thư viện GOG của bạn Đang đăng ký với Steam… + Client Steam + FEX %1$s + ARM64 + x86-64 + Chẩn đoán VAC + Game Source ghi lại console (-condebug); vac.txt trong log phiên cho biết có an toàn hay không + Đang khởi động Steam x86-64 + %1$s · x86-64 + Được client Steam x86-64 sử dụng + 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. + 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. Kính Kính VITURE Điều khiển kính diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 22b2a1ddb..6a0b5b8ee 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2311,6 +2311,17 @@ 无法连接到 GOG 无法读取你的 GOG 游戏库 正在向 Steam 注册… + Steam 客户端 + FEX %1$s + ARM64 + x86-64 + VAC 诊断 + Source 游戏会记录控制台(-condebug);会话日志中的 vac.txt 会显示是否安全 + 正在启动 Steam x86-64 + %1$s · x86-64 + 由 x86-64 Steam 客户端使用 + 此副本位于 x86-64 Steam 客户端中。请在“Steam 客户端”中选择 x86-64,然后在此处卸载。 + 此副本位于 ARM64 Steam 客户端中。请在“Steam 客户端”中选择 ARM64,然后在此处卸载。 眼镜 VITURE 眼镜 眼镜控制 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 1dc636dfb..15a059f10 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -2311,6 +2311,17 @@ 無法連線至 GOG 無法讀取你的 GOG 遊戲庫 正在向 Steam 註冊… + Steam 用戶端 + FEX %1$s + ARM64 + x86-64 + VAC 診斷 + Source 遊戲會記錄主控台(-condebug);會話記錄中的 vac.txt 會顯示是否安全 + 正在啟動 Steam x86-64 + %1$s · x86-64 + 由 x86-64 Steam 用戶端使用 + 此副本位於 x86-64 Steam 用戶端中。請在「Steam 用戶端」中選擇 x86-64,然後在此解除安裝。 + 此副本位於 ARM64 Steam 用戶端中。請在「Steam 用戶端」中選擇 ARM64,然後在此解除安裝。 眼鏡 VITURE 眼鏡 眼鏡控制 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 67bf5274b..b66b3948a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -2311,6 +2311,17 @@ 無法連線到 GOG 無法讀取你的 GOG 遊戲庫 正在向 Steam 註冊… + Steam 用戶端 + FEX %1$s + ARM64 + x86-64 + VAC 診斷 + Source 遊戲會記錄主控台(-condebug);工作階段日誌中的 vac.txt 會顯示是否安全 + 正在啟動 Steam x86-64 + %1$s · x86-64 + 由 x86-64 Steam 用戶端使用 + 此副本位於 x86-64 Steam 用戶端中。請在「Steam 用戶端」中選擇 x86-64,然後在此解除安裝。 + 此副本位於 ARM64 Steam 用戶端中。請在「Steam 用戶端」中選擇 ARM64,然後在此解除安裝。 眼鏡 VITURE 眼鏡 眼鏡控制 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 109f9f55c..3c8f63385 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2386,6 +2386,17 @@ Couldn\'t reach GOG Could not read your GOG library Registering with Steam… + Steam client + FEX %1$s + ARM64 + x86-64 + VAC diagnostics + Source games log their console (-condebug); vac.txt in the session logs says secure or insecure + Starting Steam x86-64 + %1$s · x86-64 + Used by the x86-64 Steam client + This copy is in the x86-64 Steam client. Choose x86-64 under Steam client, then uninstall it here. + This copy is in the ARM64 Steam client. Choose ARM64 under Steam client, then uninstall it here. Glasses VITURE glasses Glasses control diff --git a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt index 41f6f9d4e..ddeb3ac1f 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/LogRedactorTest.kt @@ -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 and ", 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(" ", LogRedactor.redact("armplayer x64player")) + } finally { + LogRedactor.learnAccounts() + root.deleteRecursively() + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt index 37a4ae776..26ebf6700 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGamesScanTest.kt @@ -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") diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt new file mode 100644 index 000000000..d3a230a81 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryArchTest.kt @@ -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(), Library.launchableGames(app, added = emptyList(), arch = "arm64").map { it.name }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryCacheClientTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryCacheClientTest.kt new file mode 100644 index 000000000..40570d9e4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryCacheClientTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/LibraryFromSteamClientTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryFromSteamClientTest.kt new file mode 100644 index 000000000..dacd004f0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/LibraryFromSteamClientTest.kt @@ -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 }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt index d3539155f..cc243e743 100644 --- a/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/frontend/SteamGridTest.kt @@ -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) diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionClientRecordTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionClientRecordTest.kt new file mode 100644 index 000000000..60c37d0c2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionClientRecordTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt index 28d5a7720..c39210737 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadMonitorTest.kt @@ -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 { diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamRepairTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamRepairTest.kt new file mode 100644 index 000000000..edffce8fa --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamRepairTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/VacDiagnosticsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/VacDiagnosticsTest.kt new file mode 100644 index 000000000..614a7dbae --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/VacDiagnosticsTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt index 91c862ae9..794e01d44 100644 --- a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt @@ -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")) diff --git a/app/src/test/java/com/droiddeck/launcher/storage/StorageScanTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/StorageScanTest.kt new file mode 100644 index 000000000..6425820af --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/storage/StorageScanTest.kt @@ -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 }) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/FexVersionTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/FexVersionTest.kt new file mode 100644 index 000000000..8fb201636 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/FexVersionTest.kt @@ -0,0 +1,22 @@ +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 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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt new file mode 100644 index 000000000..9f89330c1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GamesClientTagTest.kt @@ -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, 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) + } +} diff --git a/docs/development/proot-performance.md b/docs/development/proot-performance.md index 3679f43ea..217650b53 100644 --- a/docs/development/proot-performance.md +++ b/docs/development/proot-performance.md @@ -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` | diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md new file mode 100644 index 000000000..1c3161448 --- /dev/null +++ b/docs/development/x86-64-steam-client.md @@ -0,0 +1,433 @@ +# 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`). +- **`build-fex.yml`.** The build job runs third-party images and toolchains with a read-only token, + and inputs reach the shell as environment variables. A separate publish job (contents: write) runs + only on a dispatch with a tag, host thunks on and no extra `cxxflags` (diagnostic builds such as + `-DDEBUG_STRACE` stay artifacts). `droiddeck-steam-x64` pins each tag's sha256 and the archive is not + reproducible, so a tag is never rebuilt over. The release is never the repo's Latest nor a + pre-release: it is a component fetched by tag, not something users install. +- Releases so far: `fex-2609-r2` (no thunks), `fex-2609-r3` (thunks), `fex-2610-r1`, `fex-2610-r2`, + `fex-2610-r3`, `fex-2610-r4` (current: in-root resolution when openat2 is missing, exact MAP_32BIT + marking). 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. +- **Where the app gets it.** The app downloads FEX from Droid-Deck/DroidDeck-Components, where nothing is + uploaded by hand: a new tag is published by `build-fex.yml`, recorded in that repository's `fex.json` + (sha256 and size), and brought in by its `mirror-release.yml`, which refuses an archive that does not + match. `FEX_TAG` and `FEX_SHA256` in `droiddeck-steam-x64` then move to the new tag. 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. + +## 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_` 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\` (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`: `//console.log`, + or `/game//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. diff --git a/docs/progress-log.md b/docs/progress-log.md index 32642465c..8164052aa 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -7,6 +7,313 @@ 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`. + +## 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 `) + 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_` 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\` (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 +394,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 diff --git a/tools/fex/build-in-arch.sh b/tools/fex/build-in-arch.sh new file mode 100755 index 000000000..9226e6dee --- /dev/null +++ b/tools/fex/build-in-arch.sh @@ -0,0 +1,70 @@ +#!/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 + +# 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 +# Every NEEDED soname must resolve in a stock guest. +for f in out/usr/local/bin/*; do + file "$f" | grep -q ELF || continue + readelf -d "$f" | awk '/NEEDED/ { gsub(/[\[\]]/, "", $5); print $5 }' +done | sort -u | tee fex-needed.txt +(cd out && find . -type f -o -type l | sort) | tee fex-files.txt +tar -C out -c . | zstd -19 -T0 -o fex.tzst +sha256sum fex.tzst | tee fex.tzst.sha256 diff --git a/tools/fex/patches/0001-openat2-enosys-fallback.patch b/tools/fex/patches/0001-openat2-enosys-fallback.patch new file mode 100644 index 000000000..d7914133e --- /dev/null +++ b/tools/fex/patches/0001-openat2-enosys-fallback.patch @@ -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 + #include + #include ++#include + #include + #include + #include +@@ -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 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 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(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); + } + } + diff --git a/tools/fex/patches/0002-thunkgen-newer-clang.patch b/tools/fex/patches/0002-thunkgen-newer-clang.patch new file mode 100644 index 000000000..a6a87c27e --- /dev/null +++ b/tools/fex/patches/0002-thunkgen-newer-clang.patch @@ -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; + } diff --git a/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch b/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch new file mode 100644 index 000000000..923d68789 --- /dev/null +++ b/tools/fex/patches/0003-allocator32-map32bit-exhaustion.patch @@ -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 + #include + #include + #include +@@ -9,6 +10,7 @@ + #include + + #include ++#include + #include + #include + #include +@@ -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(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 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(-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; diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 15771a35b..5078fbd9b 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -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] /' diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 2ae3016a8..81d2ad5c0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games index 7b59fb468..d86d10b6c 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games @@ -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-" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex index 513a966c4..c05ab928b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -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"]: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 5f937bfee..30c8a2555 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -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: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index 5b314d71b..a9eaea65c 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -6,9 +6,11 @@ LIBRARIES=${DROIDDECK_LIBRARIES-/mnt/droiddeck /mnt/droiddeck-sd} redist="$steam_root/steamapps/common/Steamworks Shared/_CommonRedist" read_scripts() { xargs -0 -r awk -v want="${1:-redists}" ' - FNR == 1 { depth = 0; pending = "" } - /^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; vulkan[depth] = 0; pending = ""; next } + FNR == 1 { depth = 0; pending = ""; appid = "" } + /^[[:space:]]*\{/ { depth++; name[depth] = pending; key[depth] = ""; vulkan[depth] = 0; proc[depth] = 0; pending = ""; next } /^[[:space:]]*\}/ { + if (depth > 0 && key[depth] == "" && proc[depth] && appid != "" && name[depth] != "" && vulkan[depth] == (want == "vulkan")) + printf "HKEY_LOCAL_MACHINE\\\\Software\\\\Valve\\\\Steam\\\\Apps\\\\%s\t%s\n", appid, name[depth] if (depth > 0 && key[depth] != "" && name[depth] != "" && vulkan[depth] == (want == "vulkan")) printf "%s\t%s\n", key[depth], name[depth] if (depth > 0) depth-- @@ -16,8 +18,10 @@ read_scripts() { xargs -0 -r awk -v want="${1:-redists}" ' } { n = split($0, part, "\"") + if (n >= 5 && tolower(part[2]) == "appid" && part[4] ~ /^[0-9]+$/) { appid = part[4]; next } if (n >= 5 && tolower(part[2]) == "hasrunkey") { key[depth] = part[4]; next } if (n >= 5 && tolower(part[2]) ~ /^process [0-9]+$/) { + proc[depth] = 1 program = tolower(part[4]) sub(/.*[\\\/]/, "", program) if (program ~ /vulkan/) vulkan[depth] = 1 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index fbc49e790..4fd738b89 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -33,9 +33,9 @@ prepare_locale() { clear_stale_tmp() { local running names=() n - running=$(pgrep -af 'steamwebhelper|steamrtarm64/steam|--type=|droiddeck-appimage-run|dbus-run-session|dbus-daemon.*--session|dbus-launch|kwin|plasmashell' 2>/dev/null) + running=$(pgrep -af 'steamwebhelper|steamrtarm64/steam|steamrt64/steam|--type=|droiddeck-appimage-run|dbus-run-session|dbus-daemon.*--session|dbus-launch|kwin|plasmashell' 2>/dev/null) case $running in - *steamwebhelper* | *steamrtarm64/steam*) ;; + *steamwebhelper* | *steamrtarm64/steam* | *steamrt64/steam*) ;; *) names+=(-o -name 'mangohud.????????' -o -name '.com.valvesoftware.Steam.??????' -o -name 'steam_chrome_shmem_uid*' -o \( -type d -empty -name 'steam??????' \)) ;; esac @@ -79,10 +79,11 @@ run_gamescope() ( collect_steam_logs() { [ -n "${BL_DEBUG_DIR:-}" ] || return 0 local steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam + [ "${steam_x64:-0}" = 1 ] && steam_root=${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam mkdir -p "$BL_DEBUG_DIR/steam" 2>/dev/null || return 0 [ -d "$steam_root/logs" ] && find "$steam_root/logs" -maxdepth 1 -type f -size -8192k \ -exec cp {} "$BL_DEBUG_DIR/steam/" \; 2>/dev/null - [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null + [ "${steam_x64:-0}" = 0 ] && [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null local sync_store=$HOME/.local/share/droiddeck-esync sync_file if [ -d "$sync_store" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-esync" 2>/dev/null; then for sync_file in launches.log tools.tsv wanted.tsv prefixes.json; do @@ -344,17 +345,52 @@ case $mode in esac ;; steam) - step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)" - droiddeck-steam-install || exit $? - step "Steam client ready" - steam_root=$XDG_DATA_HOME/Steam + steam_x64=0 + [ "${BL_STEAM_CLIENT_ARCH:-}" = x86_64 ] && steam_x64=1 + steam_cef_gpu=1 + if [ "$steam_x64" = 1 ]; then + step "checking the Steam client's arm64 parts (first run downloads them, ~1-2 min)" + # droiddeck-steam-install removes the repair request: read it first so Repair Steam covers both clients. + _bl_x64_repair=0 + [ -e "$HOME/.bl-steam-repair" ] && _bl_x64_repair=1 + droiddeck-steam-install || exit $? + droiddeck-steam-install --sync steamdeck_publicbeta || step "could not update the arm64 client's parts; the x86-64 client keeps its own web helper" + step "checking the x86-64 Steam client (FEX, experimental; the first run downloads ~2 GB)" + BL_X64_REPAIR=$_bl_x64_repair droiddeck-steam-x64 --prepare || exit $? + step "x86-64 Steam client ready" + steam_root=${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam + steam_client=(droiddeck-steam-x64 --exec) + if [ "${BL_X64_X86_WEBHELPER:-0}" = 1 ] && + ! droiddeck-steam-x64 --webhelper-gpu; then + steam_cef_gpu=0 + fi + else + step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)" + droiddeck-steam-install || exit $? + step "Steam client ready" + steam_root=$XDG_DATA_HOME/Steam + if [ -f "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam/local.vdf" ]; then + droiddeck-steam-x64 --sync-login || true + fi + steam_client=("$steam_root/steamrtarm64/steam") + fi + _bl_proton_root=$steam_root + if [ "$steam_x64" = 1 ]; then + _bl_proton_root=$XDG_DATA_HOME/Steam + if ! ls -d "$_bl_proton_root"/compatibilitytools.d/GE-Proton*-aarch64 >/dev/null 2>&1 && + ! grep -q '^ge' "$HOME/.bl-proton-extra" 2>/dev/null; then + echo ge >> "$HOME/.bl-proton-extra" + fi + fi if [ -s "$HOME/.bl-proton-extra" ]; then _bl_left=$HOME/.bl-proton-extra.left : > "$_bl_left" + _bl_got_proton=0 while read -r _bl_extra || [ -n "$_bl_extra" ]; do case ${_bl_extra# } in ""|"#"*) continue ;; esac step "installing Proton '$_bl_extra' (large download, several minutes)" - if droiddeck-proton-extra "$steam_root" $_bl_extra; then + if droiddeck-proton-extra "$_bl_proton_root" $_bl_extra; then + _bl_got_proton=1 step "installed Proton '$_bl_extra'" else step "Proton '$_bl_extra' failed; will retry next session" @@ -362,6 +398,9 @@ case $mode in fi done < "$HOME/.bl-proton-extra" mv -f "$_bl_left" "$HOME/.bl-proton-extra" + if [ "$steam_x64" = 1 ] && [ "$_bl_got_proton" = 1 ]; then + droiddeck-steam-x64 --prepare || true + fi fi if [ -s "$HOME/.bl-steam-urls" ]; then while read -r _bl_url || [ -n "$_bl_url" ]; do @@ -381,7 +420,9 @@ case $mode in bl_zram_scan() { _bl_game=0 _bl_pids=() for _d in /proc/[0-9]*; do - IFS= read -r -d '' _c 2>/dev/null < "$_d/cmdline" || continue + # Under FEX the program is FEX's first argument. + { IFS= read -r -d '' _c && case ${_c##*/} in FEX|FEXInterpreter) IFS= read -r -d '' _c ;; esac; } \ + 2>/dev/null < "$_d/cmdline" || continue case ${_c##*/} in reaper) _bl_game=1 ;; steam|steamwebhelper) _bl_pids+=("${_d#/proc/}") ;; @@ -430,6 +471,7 @@ case $mode in /usr/local/bin/droiddeck-launcher-cores "$BL_LAUNCH_DIR/agent/focus.json" 2>&1 & fi + if [ "$steam_x64" = 0 ]; then step "registering the app's game library with Steam" droiddeck-steam-library "$steam_root" || true step "setting up the ARM64 Proton compatibility tool" @@ -442,7 +484,22 @@ case $mode in droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true fi droiddeck-seed-redists "$steam_root" || true - if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then + else + _bl_x64_tool=$(droiddeck-steam-x64 --default-tool) + if [ -n "$_bl_x64_tool" ]; then + if [ -n "${BL_ADDED_GAMES:-}" ]; then + step "adding the app's own games to the x86-64 client's library" + BL_COMPAT_TOOL=$_bl_x64_tool droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + fi + step "pointing the x86-64 client's Windows titles at $_bl_x64_tool" + BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 || true + fi + if [ -z "${BL_DESKTOP:-}" ] && [ "${BL_STEAM_UI:-}" != desktop ]; then + droiddeck-steam-ui-scale "$steam_root" "${BL_WIDTH:-1280}" "${BL_HEIGHT:-720}" || true + fi + droiddeck-seed-redists "$steam_root" || true + fi + if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then step "asking the client to fetch the ARM64 Proton" set -- "$@" steam://install/4427310 ( @@ -454,7 +511,7 @@ case $mode in steam-compatibility "$steam_root" >/dev/null 2>&1 || true step "compatibility layer installed: restarting the Steam client once" : > "$HOME/.bl-restart-once" - "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true + "${steam_client[@]}" -shutdown >/dev/null 2>&1 || true exit 0 fi done @@ -478,7 +535,7 @@ case $mode in sleep 10 [ -f "$_bl_acf" ] && exit 0 step "signed in: asking the client again for the ARM64 Proton (try $_bl_try of 3)" - "$steam_root/steamrtarm64/steam" steam://install/4427310 >/dev/null 2>&1 || true + "${steam_client[@]}" steam://install/4427310 >/dev/null 2>&1 || true sleep 50 done exit 0 @@ -712,23 +769,26 @@ BUSCONF echo "== steam: devtools port $BL_CDP_PORT, guest processes only" fi [ -n "${BL_LAUNCH_DIR:-}" ] && mkdir -p "$BL_LAUNCH_DIR/agent" && echo "$BL_CDP_PORT" > "$BL_LAUNCH_DIR/agent/cdp-port" - "${_bl_compat_env[@]}" droiddeck-steam-compat "$steam_root" 2>&1 & + [ "$steam_x64" = 1 ] || "${_bl_compat_env[@]}" droiddeck-steam-compat "$steam_root" 2>&1 & _bl_ui=(-gamepadui) [ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=() _bl_cef=(-cef-force-gpu -cef-use-gl=angle -cef-use-angle=vulkan) + [ "${steam_cef_gpu:-1}" = 1 ] || _bl_cef=(-cef-disable-gpu) if [ "${BL_DESKTOP:-}" = 1 ] && [ "${BL_STEAM_UI:-}" = desktop ]; then _bl_cef=(-cef-disable-gpu) _bl_ui=(-nobigpicture) fi if [ "${BL_STEAM_UI:-}" != desktop ]; then - _bl_ui+=(-steamdeck -steamos3) - export STEAM_GAMESCOPE_NIS_SUPPORTED=1 - export STEAM_GAMESCOPE_FANCY_SCALING_SUPPORT=1 - echo "== steam: Deck mode on (-steamdeck -steamos3)" + if [ "$steam_x64" = 0 ] || [ "${BL_X64_DECK:-1}" != 0 ]; then + _bl_ui+=(-steamdeck -steamos3) + export STEAM_GAMESCOPE_NIS_SUPPORTED=1 + export STEAM_GAMESCOPE_FANCY_SCALING_SUPPORT=1 + echo "== steam: Deck mode on (-steamdeck -steamos3)" + fi fi export STEAMOS_STEAM_SHUTDOWN_SENTINEL="$HOME/.bl-steam-shutdown" export STEAMOS_STEAM_REBOOT_SENTINEL="$HOME/.bl-steam-reboot" - if ! pgrep -f 'steamwebhelper|steamrtarm64/steam' >/dev/null 2>&1; then + if ! pgrep -f 'steamwebhelper|steamrtarm64/steam|steamrt64/steam' >/dev/null 2>&1; then _stale="" for _f in "$steam_root/.crash" "$steam_root/steam.pid" \ "$steam_root/config/htmlcache/SingletonLock" "$steam_root/config/htmlcache/SingletonCookie" \ @@ -743,10 +803,16 @@ BUSCONF [ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE") steam_beta=$steam_root/package/beta if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then - droiddeck-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & + if [ "$steam_x64" = 0 ]; then + droiddeck-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & + else + BL_STEAMCLIENT=$XDG_DATA_HOME/Steam/steamrtarm64/steamclient.so \ + BL_STEAM_SHUTDOWN="droiddeck-steam-x64 --exec -shutdown" \ + droiddeck-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & + fi games_watcher=$! fi - echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-ozone-platform=x11 ${_bl_cef[*]} $*" + echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} ${_bl_lang[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-ozone-platform=x11 ${_bl_cef[*]} $*$([ "$steam_x64" = 1 ] && echo ' (x86-64 client under FEX)')" steam_updates=0 rm -f "$HOME/.bl-stop-requested" "$STEAMOS_STEAM_SHUTDOWN_SENTINEL" "$STEAMOS_STEAM_REBOOT_SENTINEL" steam_other_game_running() { @@ -768,7 +834,7 @@ BUSCONF printf '%s\n' "$1" > "$HOME/.bl-restarted-for" echo "== steam: $2; restarting Steam to launch it" printf '%s\n' "$1" > "$HOME/.bl-restart-game" - "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 + "${steam_client[@]:-$steam_root/steamrtarm64/steam}" -shutdown >/dev/null 2>&1 } steam_launch_game() { local state=0 tries @@ -781,7 +847,7 @@ BUSCONF return fi fi - "$steam_root/steamrtarm64/steam" "steam://rungameid/$1" >/dev/null 2>&1 + "${steam_client[@]:-$steam_root/steamrtarm64/steam}" "steam://rungameid/$1" >/dev/null 2>&1 [ "$state" -eq 3 ] && return for tries in 1 2 3 4 5 6 7 8; do nap 1 @@ -822,7 +888,7 @@ BUSCONF pgrep -f 'SteamLaunch AppId=[0-9]' >/dev/null 2>&1 && return 0 echo "== steam: the client dropped shortcut(s)$lost when they exited; restarting Steam so it lists them again" : > "$HOME/.bl-restart-lost" - "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 + "${steam_client[@]:-$steam_root/steamrtarm64/steam}" -shutdown >/dev/null 2>&1 } steam_bring_forward() { local end=$((SECONDS + 600)) behind=0 line name value focused other n @@ -895,13 +961,34 @@ BUSCONF done return 1 } + compat_pending=$HOME/.bl-compat-pending + compat_restarted=$HOME/.bl-compat-restarted + rm -f "$compat_pending" + : > "$compat_restarted" + compat_restarts=0 + compat_settled() { + local app m flags r card=/mnt/droiddeck-sd + # The arm64 card library may hold that client's copy of the same title. + [ "$steam_x64" = 1 ] && card=/mnt/droiddeck-sd/x86-64 + while read -r app; do + [ -n "$app" ] || continue + m= + for r in "$steam_root" "$card"; do + [ -f "$r/steamapps/appmanifest_$app.acf" ] && { m=$r/steamapps/appmanifest_$app.acf; break; } + done + [ -n "$m" ] || return 1 + flags=$(sed -n 's/^[[:space:]]*"StateFlags"[[:space:]]*"\([0-9]*\)".*/\1/p' "$m" | head -n 1) + [ "$flags" = 4 ] || return 1 + done < "$compat_pending" + ! pgrep -f 'reaper SteamLaunch' >/dev/null 2>&1 + } while :; do if [ -n "$requests" ]; then if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then rm -f "$BL_LAUNCH_DIR/steam-stop" : > "$HOME/.bl-stop-requested" echo "== steam: the app asked for a clean exit" - "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true + "${steam_client[@]}" -shutdown >/dev/null 2>&1 || true exit 0 fi if [ -f "$BL_LAUNCH_DIR/steam-uninstall" ]; then @@ -911,7 +998,7 @@ BUSCONF ''|0*|*[!0-9]*|???????????*) echo "== steam: ignored invalid uninstall request" ;; *) echo "== steam: opening the uninstall dialog for $uninstall_id" - "$steam_root/steamrtarm64/steam" "steam://uninstall/$uninstall_id" >/dev/null 2>&1 & + "${steam_client[@]}" "steam://uninstall/$uninstall_id" >/dev/null 2>&1 & ;; esac fi @@ -944,8 +1031,9 @@ BUSCONF steam_check_exits if [ "$beat" -le 720 ] && [ -n "${BL_CLIENT_CPUS:-}" ]; then for _d in /proc/[0-9]*; do - IFS= read -r -d '' _c < "$_d/cmdline" 2>/dev/null || continue - case $_c in *steamwebhelper*|*steamrtarm64/steam|*gamescope*) ;; *) continue ;; esac + { IFS= read -r -d '' _c && { IFS= read -r -d '' _c1 || _c1=; }; } < "$_d/cmdline" 2>/dev/null || continue + case $_c in FEX|*/FEX|FEXInterpreter|*/FEXInterpreter) _c=$_c1 ;; esac + case $_c in *steamwebhelper*|*steamrtarm64/steam|*steamrt64/steam|*gamescope*) ;; *) continue ;; esac _t=("$_d"/task/*) _p=${_d#/proc/} [ "${_bl_pinned[$_p]:-}" = "${#_t[@]}" ] && client_single_core_matches "$BL_CLIENT_CPUS" "${_t[@]}" && continue @@ -963,17 +1051,42 @@ BUSCONF [ -n "$compat" ] && : > "$compat_stamp" ( [ -z "$seed" ] || droiddeck-seed-redists "$steam_root" >/dev/null 2>&1 || true - [ -z "$compat" ] || steam-compatibility "$steam_root" >/dev/null 2>&1 || true + if [ -n "$compat" ]; then + if [ "$steam_x64" = 1 ]; then + [ -z "${_bl_x64_tool:-}" ] || + BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 --live >/dev/null 2>&1 || true + droiddeck-steam-x64 --map-direct --live >/dev/null 2>&1 || true + else + steam-compatibility "$steam_root" >/dev/null 2>&1 || true + fi + fi ) & library_job=$! fi fi + if [ "$((beat % 3))" -eq 0 ] && [ -s "$compat_pending" ] && [ "$compat_restarts" -lt 3 ] && compat_settled; then + _bl_new=$(sort -u "$compat_pending" | grep -vxF -f "$compat_restarted" || true) + rm -f "$compat_pending" + if [ -n "$_bl_new" ]; then + compat_restarts=$((compat_restarts + 1)) + printf '%s\n' $_bl_new >> "$compat_restarted" + if [ "$steam_x64" = 1 ]; then + step "newly installed title(s) $(echo $_bl_new) set to an ARM64 Proton (Windows builds) or DroidDeck direct (Linux builds): restarting the Steam client once so it takes effect" + else + step "newly installed title(s) $(echo $_bl_new) set to the ARM64 Proton: restarting the Steam client once so it takes effect" + fi + : > "$HOME/.bl-restart-once" + "${steam_client[@]}" -shutdown >/dev/null 2>&1 || true + fi + fi if [ "$lossless_left" -gt 0 ] && [ "$((beat % 6))" -eq 0 ] && { [ -z "$lossless_job" ] || ! kill -0 "$lossless_job" 2>/dev/null; }; then lossless_left=$((lossless_left - 1)) if [ -e "$lossless_owned" ] || [ -e "$lossless_asked" ]; then lossless_left=0 elif ! pgrep -x wineserver >/dev/null 2>&1; then ( + # The probe reaches the x86-64 client through the arm64 client's steamclient.so, as the import watcher does. + if [ "$steam_x64" = 1 ]; then export BL_STEAMCLIENT=$XDG_DATA_HOME/Steam/steamrtarm64/steamclient.so; fi answer=$(droiddeck-steam-games probe "$steam_root" 993090 2>/dev/null) || exit 0 case $answer in *'"993090": true'*) mkdir -p "${lossless_owned%/*}" && : > "$lossless_owned" && echo "== Lossless Scaling: owned" ;; @@ -999,7 +1112,11 @@ BUSCONF _bl_counted=1 fi if [ -n "${BL_STEAM_LANGUAGE:-}" ]; then - droiddeck-steam-language "$HOME" "$BL_STEAM_LANGUAGE" || true + if [ "$steam_x64" = 1 ]; then + droiddeck-steam-language "${BL_X64_HOME:-$HOME/.droiddeck-x64}" "$BL_STEAM_LANGUAGE" || true + else + droiddeck-steam-language "$HOME" "$BL_STEAM_LANGUAGE" || true + fi fi step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}" desktop_ui_pid= @@ -1008,7 +1125,7 @@ BUSCONF desktop_ui_pid=$! fi steam_started=$SECONDS - "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -clientbeta "$steam_channel" \ + "${steam_client[@]}" "${_bl_ui[@]}" "${_bl_cdp[@]}" "${_bl_lang[@]}" -clientbeta "$steam_channel" \ -no-cef-sandbox -cef-ozone-platform=x11 "${_bl_cef[@]}" "$@" rc=$? steam_ran=$((SECONDS - steam_started)) @@ -1017,13 +1134,23 @@ BUSCONF wait "$desktop_ui_pid" 2>/dev/null || true fi echo "== steam exited rc=$rc" - if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then - droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true - fi collect_steam_logs if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then /usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true fi + if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then + if [ "$steam_x64" = 0 ]; then + droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + elif [ -n "${_bl_x64_tool:-}" ]; then + BL_COMPAT_TOOL=$_bl_x64_tool droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + fi + fi + # The x86-64 client rewrote config.vdf from memory on exit, over the live passes' mapping. + if [ "$steam_x64" = 1 ]; then + [ -z "${_bl_x64_tool:-}" ] || + BL_COMPAT_TOOL=$_bl_x64_tool steam-compatibility "$steam_root" --x64 >/dev/null 2>&1 || true + droiddeck-steam-x64 --map-direct || true + fi if [ -f "$HOME/.bl-stop-requested" ]; then rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once" break diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index 58022a267..4bfbb87ed 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -5,6 +5,7 @@ import json import os from pathlib import Path import re +import shlex import subprocess import sys import time @@ -119,7 +120,7 @@ def method(obj, slot, result, *args): def probe(steam_root, ids): - library = C.CDLL(str(Path(steam_root) / "steamrtarm64/steamclient.so")) + library = C.CDLL(os.environ.get("BL_STEAMCLIENT") or str(Path(steam_root) / "steamrtarm64/steamclient.so")) library.CreateInterface.argtypes = [C.c_char_p, C.c_void_p] library.CreateInterface.restype = C.c_void_p client = library.CreateInterface(b"SteamClient020", None) @@ -173,7 +174,8 @@ def request_restart(steam_root): """The client reads manifests and shortcuts only at start, so an import needs one restart; the session's loop runs the importer between the exit and the next start.""" Path(os.environ.get("HOME", "/root"), ".bl-restart-once").touch() - subprocess.run([str(Path(steam_root) / "steamrtarm64/steam"), "-shutdown"], + command = shlex.split(os.environ.get("BL_STEAM_SHUTDOWN", "")) or [str(Path(steam_root) / "steamrtarm64/steam"), "-shutdown"] + subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30) @@ -213,9 +215,10 @@ def watch(steam_root, listing): elif candidates and (key != probed or now >= next_probe): probed, next_probe = key, now + 300 try: + probe_env = dict(os.environ, HOME=os.environ["BL_STEAM_IPC_HOME"]) if os.environ.get("BL_STEAM_IPC_HOME") else None result = subprocess.run([sys.executable, __file__, "probe", steam_root, *map(str, sorted(set(candidates.values())))], - capture_output=True, text=True, timeout=15) + capture_output=True, text=True, timeout=15, env=probe_env) if result.returncode != 0: status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode)) failures += 1 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index 6d580dbe6..7e9b4e073 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -33,7 +33,12 @@ if [ -n "$repair" ]; then [ "$(sha1sum "$part" | cut -d' ' -f1)" = "$sum" ] || rm -f "$part" done fi -[ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0 +sync= +if [ "${1:-}" = --sync ]; then + case "${2:-}" in publicbeta|steamdeck_publicbeta) sync=$2 ;; *) sync=$steam_channel ;; esac + manifest=steam_client_${sync}_linuxarm64 +fi +[ -z "$sync" ] && [ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0 work=$(mktemp -d) trap 'rm -rf "$work"' EXIT @@ -51,6 +56,14 @@ for candidate in $cdn_list; do [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate") done version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") +if [ -n "$sync" ]; then + installed=$(awk -F'"' '$2 == "version" { print $4; exit }' "$steam_root/package/$manifest.manifest" 2>/dev/null || true) + if [ -x "$steam_root/steamrtarm64/steam" ] && [ "$installed" = "$version" ]; then + chmod -R a+x "$steam_root/steamrtarm64" "$steam_root/linuxarm64" 2>/dev/null || true + exit 0 + fi + echo "== STEP $(date -u +%H:%M:%S) updating the arm64 Steam client to $version (was ${installed:-none}) for the x86-64 client" +fi awk -F'"' ' /^\t"/ && NF >= 3 && $4 == "" { block = $2 } $2 == "file" { file[block] = $4 } @@ -86,7 +99,12 @@ find "$steam_root" -depth -name '*\\*' | while read -r odd; do mv -f "$odd" "$fixed" fi done -echo "$steam_channel" > "$steam_root/package/beta" +chmod -R a+x "$steam_root/steamrtarm64" "$steam_root/linuxarm64" 2>/dev/null || true +if [ -n "$sync" ]; then + cp -f "$work/manifest" "$steam_root/package/$manifest.manifest" +else + echo "$steam_channel" > "$steam_root/package/beta" +fi ln -sfn "$steam_root/steamrtarm64" "$steam_root/steamrtarm32" chmod +x "$steam_root/steamrtarm64/steam" 2>/dev/null || true mkdir -p "$HOME/.steam" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch index 71da571dd..ec5453f5b 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch @@ -12,15 +12,22 @@ case $target in esac if [ "$ui" = desktop ] && [ "${BL_DESKTOP:-}" = 1 ]; then - steam_root=${XDG_DATA_HOME:-${HOME:-/root}/.local/share}/Steam - export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + if [ "${BL_STEAM_CLIENT_ARCH:-}" = x86_64 ]; then + client_home=${BL_X64_HOME:-${HOME:-/root}/.droiddeck-x64} + client=(/usr/local/bin/droiddeck-steam-x64 --exec) + else + steam_root=${XDG_DATA_HOME:-${HOME:-/root}/.local/share}/Steam + export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} + client_home=$HOME + client=("$steam_root/steamrtarm64/steam") + fi mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}/droiddeck" exec 9>"${XDG_CACHE_HOME:-$HOME/.cache}/droiddeck/steam-desktop.lock" if ! flock -n 9; then for ((attempt = 0; attempt < 180; attempt++)); do - pid=$(cat "$HOME/.steam/steam.pid" 2>/dev/null || true) - if [ -p "$HOME/.steam/steam.pipe" ] && [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then - exec "$steam_root/steamrtarm64/steam" -nobigpicture -no-cef-sandbox \ + pid=$(cat "$client_home/.steam/steam.pid" 2>/dev/null || true) + if [ -p "$client_home/.steam/steam.pipe" ] && [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then + exec "${client[@]}" -nobigpicture -no-cef-sandbox \ -cef-ozone-platform=x11 -cef-disable-gpu "${url:-steam://open/library}" fi sleep 0.5 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim index 3ecbfceae..3e319a460 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim @@ -11,4 +11,5 @@ if [ "${BL_DESKTOP:-}" = 1 ]; then done exec /usr/local/bin/droiddeck-steam-launch "$ui" ${url:+"$url"} fi +[ "${BL_STEAM_CLIENT_ARCH:-}" = x86_64 ] && exec /usr/local/bin/droiddeck-steam-x64 --exec "$@" exec "${HOME:-/root}/.local/share/Steam/steamrtarm64/steam" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts index 223edc01f..3764e4a92 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts @@ -344,9 +344,21 @@ def main(): spec.loader.exec_module(compat) cfg = os.path.join(steam_root, "config", "config.vdf") if os.path.isfile(cfg) and (ours or routes): - compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe") and not g.get("native")] + [str(appid) for appid in set(routes.values())], - protect=compat.adopt_extras(os.path.join(steam_root, "compatibilitytools.d"))) - print("droiddeck-steam-shortcuts: mapped them to %s" % compat.TOOL) + tools = os.path.join(steam_root, "compatibilitytools.d") + x64_tool = os.environ.get("BL_COMPAT_TOOL") + own = {} + if x64_tool: + protect = {compat.first_key(os.path.join(tools, e, "compatibilitytool.vdf"), ["compatibilitytools", "compat_tools"]) or e: e + for e in (os.listdir(tools) if os.path.isdir(tools) else [])} + # Its own state: the arm64 client's state.json lists which of that client's titles follow its default. + own = {"default": x64_tool, + "directory": os.path.join(os.path.dirname(os.path.abspath(steam_root)), "droiddeck-compat"), + "linux_tool": "droiddeck-direct"} + else: + protect = compat.adopt_extras(tools) + used = compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe") and not g.get("native")] + [str(appid) for appid in set(routes.values())], + protect=protect, **own) + print("droiddeck-steam-shortcuts: mapped them to %s" % used) except Exception as e: print("droiddeck-steam-shortcuts: compat mapping skipped (%s)" % e) return 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 new file mode 100755 index 000000000..f4b71d0da --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 @@ -0,0 +1,969 @@ +#!/bin/bash +set -eu +FEX_TAG=fex-2610-r4 +FEX_SHA256=0b8b55b663390e35a85871bbd32985bf5ee4f9fbf3a70906255d914ad0257cdf +FEX_URL=https://github.com/Droid-Deck/DroidDeck-Components/releases/download/$FEX_TAG/fex.tzst +ROOTFS_URL=https://github.com/Droid-Deck/DroidDeck-Components/releases/download/fex-rootfs-ubuntu-24.04-2026-08-11/Ubuntu_24_04.sqsh +ROOTFS_SHA256=2854b06d3ff1b8f6e526135bfb6dd5b7b30ab3ab73e79ae933a3d9fed959a178 +CDN=https://client-update.fastly.steamstatic.com +# Must match the -clientbeta droiddeck-session starts this client with (steam_channel). +X64_CHANNEL=steamdeck_publicbeta +MANIFEST=steam_client_${X64_CHANNEL}_ubuntu12 + +REAL_HOME=${HOME:-/root} +X64_HOME=${BL_X64_HOME:-$REAL_HOME/.droiddeck-x64} +ROOTFS=$X64_HOME/rootfs +STEAM_X64=$X64_HOME/.local/share/Steam +ARM_STEAM=${XDG_DATA_HOME:-$REAL_HOME/.local/share}/Steam +LOG=$X64_HOME/steam-x64.log +mkdir -p "$X64_HOME" +step() { echo "== STEP $(date -u +%H:%M:%S) $*" | tee -a "$LOG"; } + +x64_default_dir() { + local d + while IFS= read -r d; do + [ -f "$d/.droiddeck-arm64-proton" ] && { echo "$d"; return; } + done < <(ls -d "$STEAM_X64"/compatibilitytools.d/DroidDeck-Proton* 2>/dev/null | sort -V -r + ls -d "$STEAM_X64"/compatibilitytools.d/GE-Proton*-aarch64 2>/dev/null | sort -V -r) + echo "$STEAM_X64/compatibilitytools.d/droiddeck-proton-arm64" +} +x64_default_tool() { + sed -n 's/^[[:space:]]*"\([^"]*\)"[[:space:]]*$/\1/p' "$(x64_default_dir)/compatibilitytool.vdf" 2>/dev/null | + grep -v -x -e compatibilitytools -e compat_tools | head -n 1 +} + +x64_client_parts() { + awk -F'"' ' + /^\t"/ && NF >= 3 && $4 == "" { block = $2 } + $2 == "file" { file[block] = $4 } + $2 == "sha2" { sha[block] = $4 } + END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] } + ' "$1" | sort +} + +x64_sync_client() { + local chan cur version work total i block file sum old + chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo "$X64_CHANNEL") + old=$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest + cur=$(awk -F'"' '$2 == "version" { print $4; exit }' "$old" 2>/dev/null || true) + work=$(mktemp -d) + if ! curl -fsSL "$CDN/steam_client_${chan}_ubuntu12" -o "$work/manifest"; then + rm -rf "$work"; return 0 + fi + version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + if [ -z "$version" ] || [ "$version" = "$cur" ]; then + rm -rf "$work"; return 0 + fi + x64_client_parts "$work/manifest" > "$work/parts" + if [ -f "$old" ]; then + x64_client_parts "$old" | awk '{ print $3 }' > "$work/have" + grep -vwF -f "$work/have" "$work/parts" > "$work/todo" || true + else + cp "$work/parts" "$work/todo" + fi + total=$(grep -c . "$work/todo" || true) + step "updating the x86-64 Steam client to $version (was ${cur:-none}, $total part(s))" + i=0 + while read -r block file sum; do + i=$((i + 1)) + step "downloading Steam: $block ($i/$total)" + if ! curl -fsSL "$CDN/$file" -o "$work/$file" || ! echo "$sum $work/$file" | sha256sum -c --quiet; then + step "could not update the x86-64 client; it updates itself" + rm -rf "$work"; return 0 + fi + unzip -qo "$work/$file" -d "$STEAM_X64" + rm -f "$work/$file" + done < "$work/todo" + cp -f "$work/manifest" "$old" + echo "$version" > "$STEAM_X64/package/droiddeck-installed" + chmod +x "$STEAM_X64/steam.sh" "$STEAM_X64/steamrt64/steam" "$STEAM_X64/ubuntu12_32/steam" 2>/dev/null || true + rm -rf "$work" +} + +# The client rewrites config.vdf from memory as it exits: the mapping is written only while no x86 client runs. +x64_map_direct() { + local mode=write + [ "${1:-}" = --live ] && mode=live + [ -f "$STEAM_X64/config/config.vdf" ] || return 0 + if [ "$mode" = write ] && pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then return 0; fi + python3 - "$mode" "$STEAM_X64/config/config.vdf" "$STEAM_X64/appcache/appinfo.vdf" "$X64_HOME/direct-auto.json" \ + "$X64_HOME/direct-sniper" "$STEAM_X64/steamapps:/mnt/droiddeck-sd/x86-64/steamapps" "$(x64_default_tool)" \ + "$REAL_HOME/.bl-compat-pending" ${BL_X64_DIRECT_APPS:-550} \ + <<'PY' || { [ "$mode" = live ] || step "could not map titles to DroidDeck direct"; } +# Steam does not swap an installed build when the tool changes: a title installed for Windows stays on Proton. +import json, os, re, struct, sys +mode, path, appinfo, auto_file, sniper_file, steamapps, proton, pending = sys.argv[1:9] +extra = sys.argv[9:] +WINDOWS_DEPOTS = {} +KNOWN = set() + + +def read_kv(buf, pos, strings): + out = {} + while True: + t = buf[pos] + pos += 1 + if t == 0x08: + return out, pos + if strings is not None: + key = strings[struct.unpack_from("/dev/null)" != "$FEX_TAG" ]; then + step "downloading FEX ($FEX_TAG)" + tmp=$(mktemp -d) + curl -fL --progress-bar "$FEX_URL" -o "$tmp/fex.tzst" + if [ "$FEX_SHA256" != PENDING ]; then echo "$FEX_SHA256 $tmp/fex.tzst" | sha256sum -c --quiet; fi + zstd -dc "$tmp/fex.tzst" | tar -C / -x + rm -rf "$tmp" + mkdir -p /usr/local/share/fex-emu && echo "$FEX_TAG" > /usr/local/share/fex-emu/.droiddeck-tag +fi +step "FEX ready ($FEX_TAG)" +# The client's opt-in to its SteamRT3 build; without it, it re-executes through steam.sh, whose checks fail under proot. +mkdir -p "$STEAM_X64" && : > "$STEAM_X64/.steam-enable-steamrt64-client" + +if [ ! -f "$ROOTFS/.droiddeck-complete" ]; then + step "downloading FEX's Ubuntu 24.04 x86 rootfs" + # Resumes a part-done download; a finished one answers 416, which is not a failure here. + curl -fL --progress-bar -C - "$ROOTFS_URL" -o "$X64_HOME/rootfs.sqsh" || { + rc=$? + [ "$rc" = 22 ] && [ -s "$X64_HOME/rootfs.sqsh" ] || { step "rootfs download failed (curl $rc)"; exit 1; } + } + if ! echo "$ROOTFS_SHA256 $X64_HOME/rootfs.sqsh" | sha256sum -c --quiet >/dev/null 2>&1; then + rm -f "$X64_HOME/rootfs.sqsh"; step "rootfs download did not match its checksum; try again"; exit 1 + fi + step "unpacking the rootfs" + rm -rf "$ROOTFS" + /usr/local/bin/unsquashfs -no-xattrs -no-progress -ignore-errors -d "$ROOTFS" "$X64_HOME/rootfs.sqsh" >>"$LOG" 2>&1 || true + [ -x "$ROOTFS/usr/bin/bash" ] || { step "rootfs unpack failed (no usr/bin/bash); see $LOG"; exit 1; } + rm -f "$X64_HOME/rootfs.sqsh" + touch "$ROOTFS/.droiddeck-complete" +fi +# FEX falls back to the guest's own /etc/ld.so.preload (arm64 libraries) when the rootfs has none. The x86 +# session shim provides System V IPC, which Android kernels lack and the client's tier0 needs to start. +: > "$ROOTFS/etc/ld.so.preload" +for lib in libblsession libfakeinput; do + have=0 + # 32-bit libfakeinput crashes SDL2 games (a NULL udev attribute reaches strdup), so only the x86_64 copy + # is preloaded, by full path: with $LIB, FEX falls through to the session's own i386 copy. + arches="x86_64 i386"; [ "$lib" = libfakeinput ] && arches=x86_64 + rm -f "$ROOTFS/usr/local/lib/i386-linux-gnu/libfakeinput.so" + for arch in $arches; do + [ -f "/usr/local/lib/droiddeck-fex/$arch/$lib.so" ] || continue + mkdir -p "$ROOTFS/usr/local/lib/$arch-linux-gnu" + cp -f "/usr/local/lib/droiddeck-fex/$arch/$lib.so" "$ROOTFS/usr/local/lib/$arch-linux-gnu/$lib.so" + have=1 + done + if [ "$have" = 1 ] && [ "$lib" = libfakeinput ]; then + echo "/usr/local/lib/x86_64-linux-gnu/$lib.so" >> "$ROOTFS/etc/ld.so.preload" + elif [ "$have" = 1 ]; then echo "/usr/local/\$LIB/$lib.so" >> "$ROOTFS/etc/ld.so.preload" + else step "warning: no x86 build of $lib (/usr/local/lib/droiddeck-fex/x86_64/$lib.so)"; fi +done +rm -f "$ROOTFS/usr/local/lib/libblsession-x86_64.so" "$ROOTFS/usr/local/lib/libfakeinput-x86_64.so" +unconfirmed=$STEAM_X64/package/droiddeck-unconfirmed +if [ -d "${unconfirmed%/*}" ] && ! grep -qx tracked "$unconfirmed" 2>/dev/null; then echo tracked > "$unconfirmed"; fi +x64_repair= +if [ "${BL_X64_REPAIR:-0}" = 1 ]; then + x64_repair=requested +elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" && [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then + x64_repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything" +fi +if [ -n "$x64_repair" ]; then + step "repairing the x86-64 Steam client ($x64_repair)" + rm -f "$STEAM_X64/package/droiddeck-installed" "$STEAM_X64/.crash" "$STEAM_X64/steam.pid" \ + "$STEAM_X64"/package/steam_client_*.installed +fi +if [ ! -x "$STEAM_X64/steamrt64/steam" ] || [ ! -f "$STEAM_X64/package/droiddeck-installed" ]; then + step "downloading the x86-64 Steam client ($MANIFEST)" + work=$(mktemp -d) + curl -fsSL "$CDN/$MANIFEST" -o "$work/manifest" + version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest") + awk -F'"' ' + /^\t"/ && NF >= 3 && $4 == "" { block = $2 } + $2 == "file" { file[block] = $4 } + $2 == "sha2" { sha[block] = $4 } + END { for (b in file) if (b ~ /_all$/ || b ~ /_ubuntu12$/) print b, file[b], sha[b] } + ' "$work/manifest" | sort > "$work/parts" + [ -s "$work/parts" ] || { step "no client components in $MANIFEST"; exit 1; } + mkdir -p "$STEAM_X64/package" + total=$(grep -c . "$work/parts") + i=0 + while read -r block file sum; do + i=$((i + 1)) + step "client: $block ($i/$total)" + curl -fsSL "$CDN/$file" -o "$work/$file" + echo "$sum $work/$file" | sha256sum -c --quiet + unzip -qo "$work/$file" -d "$STEAM_X64" + rm -f "$work/$file" + done < "$work/parts" + cp -f "$work/manifest" "$STEAM_X64/package/$MANIFEST.manifest" + rm -rf "$work" + echo "$X64_CHANNEL" > "$STEAM_X64/package/beta" + echo "$version" > "$STEAM_X64/package/droiddeck-installed" + chmod +x "$STEAM_X64/steam.sh" "$STEAM_X64/steamrt64/steam" "$STEAM_X64/ubuntu12_32/steam" 2>/dev/null || true +fi +[ -n "$x64_repair" ] && printf 'tracked\nrepaired\n' > "$unconfirmed" +x64_sync_client +scout=$STEAM_X64/ubuntu12_32 +if [ -f "$scout/steam-runtime.tar.xz" ] && + { [ ! -x "$scout/steam-runtime/run.sh" ] || + ! cmp -s "$scout/steam-runtime.tar.xz.checksum" "$scout/steam-runtime/checksum"; }; then + step "unpacking Valve's scout runtime for native Linux games" + rm -rf "$scout/steam-runtime.new" + mkdir -p "$scout/steam-runtime.new" + if tar -xJf "$scout/steam-runtime.tar.xz" -C "$scout/steam-runtime.new" && [ -x "$scout/steam-runtime.new/steam-runtime/run.sh" ]; then + rm -rf "$scout/steam-runtime" + mv "$scout/steam-runtime.new/steam-runtime" "$scout/steam-runtime" + cp -f "$scout/steam-runtime.tar.xz.checksum" "$scout/steam-runtime/checksum" 2>/dev/null || true + else + step "could not unpack the scout runtime; native Linux games will not start" + fi + rm -rf "$scout/steam-runtime.new" +fi +[ -L "$STEAM_X64/steamapps" ] && rm -f "$STEAM_X64/steamapps" +mkdir -p "$STEAM_X64/steamapps" +# FEX looks in the rootfs first: an empty dev/dri there hides the session's KGSL node, which x86 programs spin on. +mkdir -p "$ROOTFS/dev/dri" +mkdir -p "$X64_HOME/.steam" +ln -sfn "$STEAM_X64" "$X64_HOME/.steam/root" +ln -sfn "$STEAM_X64" "$X64_HOME/.steam/steam" +ln -sfn "$STEAM_X64/ubuntu12_32" "$X64_HOME/.steam/bin32" +ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64" +ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32" +ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64" +# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has. +srt_real=$STEAM_X64/steamrt64/pv-runtime/steam-runtime-steamrt +srt_shim=$X64_HOME/steamrt-shim +if [ -d "$srt_real" ]; then + rm -rf "$srt_shim" + mkdir -p "$srt_shim/bin" + for e in "$srt_real"/* "$srt_real"/.[!.]*; do + [ -e "$e" ] || continue + [ "$(basename "$e")" = bin ] || ln -s "$e" "$srt_shim/$(basename "$e")" + done + for e in "$srt_real"/bin/*; do ln -s "$e" "$srt_shim/bin/$(basename "$e")"; done + rm -f "$srt_shim/bin/steam-runtime-check-requirements" + printf '#!/bin/sh +exit 0 +' > "$srt_shim/bin/steam-runtime-check-requirements" + chmod 755 "$srt_shim/bin/steam-runtime-check-requirements" + rm -f "$srt_shim/bin/steam-runtime-launch-client" + cat > "$srt_shim/bin/steam-runtime-launch-client" <<'LAUNCH' +#!/bin/sh +while [ $# -gt 0 ]; do + case $1 in + --) shift; break ;; + --directory=*) cd "${1#--directory=}" || exit 127 ;; + --env=*) export "${1#--env=}" ;; + --unset-env=*) unset "${1#--unset-env=}" ;; + esac + shift +done +[ $# -gt 0 ] || exit 0 +exec "$@" +LAUNCH + chmod 755 "$srt_shim/bin/steam-runtime-launch-client" +fi +# Valve's platform libraries ship without soname links (pressure-vessel makes them); ldconfig adds them here. +srt_libs=$X64_HOME/steamrt-libs +rm -rf "$srt_libs" +mkdir -p "$srt_libs" +for d in "$STEAM_X64"/steamrt64/pv-runtime/steam-runtime-steamrt/steamrt3c_platform_*/files/lib/x86_64-linux-gnu \ + "$STEAM_X64"/steamrt64/pv-runtime/steam-runtime-steamrt/steamrt3c_platform_*/files/usr/lib/x86_64-linux-gnu; do + [ -d "$d" ] || continue + for f in "$d"/*.so*; do [ -e "$srt_libs/$(basename "$f")" ] || ln -s "$f" "$srt_libs/"; done +done +FEX_ROOTFS=$ROOTFS /usr/local/bin/FEX "$ROOTFS/usr/sbin/ldconfig.real" -n "$srt_libs" >>"$LOG" 2>&1 || step "ldconfig on $srt_libs failed" +# The rootfs's HarfBuzz needs a newer FreeType than some games bundle (Counter-Strike 2): use Valve's runtime one. +sniper_compat=$X64_HOME/sniper-compat +rm -rf "$sniper_compat" +mkdir -p "$sniper_compat" +hb=$(ls "$srt_libs"/libharfbuzz.so.0.* 2>/dev/null | head -n 1) +if [ -n "$hb" ]; then ln -s "$hb" "$sniper_compat/libharfbuzz.so.0"; else step "warning: no HarfBuzz in Valve's runtime for the newer-runtime titles"; fi +# LD_LIBRARY_PATH beats the system folders whatever its order: Valve's older glibc must not shadow the rootfs's. +for f in "$srt_libs"/*; do + n=$(basename "$f") + if [ -e "$ROOTFS/usr/lib/x86_64-linux-gnu/$n" ] || [ -e "$ROOTFS/lib/x86_64-linux-gnu/$n" ]; then rm -f "$f"; fi +done + tool=$STEAM_X64/compatibilitytools.d/droiddeck-direct + mkdir -p "$tool" + rm -f "$tool/droiddeck-proton-wrap" "$tool/toolmanifest.vdf.droiddeck-orig" + cat > "$tool/compatibilitytool.vdf" <<'VDF' +"compatibilitytools" +{ + "compat_tools" + { + "droiddeck-direct" + { + "install_path" "." + "display_name" "DroidDeck direct (no container)" + "from_oslist" "linux" + "to_oslist" "linux" + } + } +} +VDF + cat > "$tool/toolmanifest.vdf" <<'VDF' +"manifest" +{ + "version" "2" + "commandline" "/run %verb%" +} +VDF + printf '%s\n' "$REAL_HOME/.droiddeck-vac-diagnostics" > "$tool/vac-marker" + cat > "$tool/run" <<'RUN' +#!/bin/sh +verb=$1; shift +rt=$HOME/.local/share/Steam/ubuntu12_32/steam-runtime.old +[ -x "$rt/run.sh" ] || rt=$HOME/.local/share/Steam/ubuntu12_32/steam-runtime +# -condebug only while VAC diagnostics are on (the marker is in the real home, not this client's). +marker=$(cat "${0%/*}/vac-marker" 2>/dev/null) +case "$*" in *hl2.sh*|*hl2_linux*|*srcds*) [ -n "$marker" ] && [ -f "$marker" ] && set -- "$@" -condebug ;; esac +# game-gpu.env is rewritten whenever the launcher runs; game-gpu.local is the user's and is read after it. +gpu=$HOME/game-gpu.env +if [ -f "$gpu" ] && [ "${BL_X64_GAME_SOFTWARE:-0}" != 1 ]; then + . "$gpu" + [ -f "$HOME/game-gpu.local" ] && . "$HOME/game-gpu.local" +else + export mesa_glthread=false +fi +case $verb in + run|waitforexitandrun) + # Titles Valve gives a newer runtime (sniper, soldier) need newer libraries than scout's: the rootfs's. + if grep -qx "${STEAM_COMPAT_APP_ID:-${SteamAppId:-none}}" "$HOME/direct-sniper" 2>/dev/null; then + [ -e "$HOME/sniper-compat/libharfbuzz.so.0" ] && + export LD_PRELOAD="$HOME/sniper-compat/libharfbuzz.so.0${LD_PRELOAD:+:$LD_PRELOAD}" + case $1 in + *.sh) + if grep -q '^[[:space:]]*\. /etc/os-release' "$1" 2>/dev/null; then + script=$1; shift + exec bash -c "$(sed 's|^\([[:space:]]*\)\. /etc/os-release|\1VERSION_CODENAME=sniper|' "$script")" "$script" "$@" + fi ;; + esac + exec "$@" + fi + exec "$rt/run.sh" "$@" ;; + *) exec "$@" ;; +esac +RUN + chmod 755 "$tool/run" + if [ ! -f "$STEAM_X64/config/config.vdf" ] && ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then + mkdir -p "$STEAM_X64/config" + printf '"InstallConfigStore"\n{\n\t"Software"\n\t{\n\t\t"Valve"\n\t\t{\n\t\t\t"Steam"\n\t\t\t{\n\t\t\t\t"CompatToolMapping"\n\t\t\t\t{\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n' \ + > "$STEAM_X64/config/config.vdf" + fi + x64_map_direct + # Each wrapper execs droiddeck-x64-arm-proton through an arm64 binary, which FEX hands straight to the CPU. + arm_tools=$ARM_STEAM/compatibilitytools.d + mkdir -p "$X64_HOME/arm64" + cp -f /usr/bin/env "$X64_HOME/arm64/env" + for w in "$STEAM_X64"/compatibilitytools.d/*/.droiddeck-arm64-proton; do + [ -e "$w" ] || continue + d=${w%/.droiddeck-arm64-proton} + [ -d "$arm_tools/${d##*/}" ] && [ "${d##*/}" != droiddeck-fex ] || rm -rf "$d" + done + for src in "$arm_tools"/*/; do + src=${src%/}; name=${src##*/} + [ -f "$src/compatibilitytool.vdf" ] && [ -f "$src/toolmanifest.vdf" ] || continue + # A droiddeck-fex wrapper made the arm64 client install Valve's FEX when this client probed its tools after logon. + case $name in droiddeck-direct|droiddeck-fex) continue ;; esac + dst=$STEAM_X64/compatibilitytools.d/$name + mkdir -p "$dst" + rm -f "$dst/droiddeck-proton-wrap" "$dst/toolmanifest.vdf.droiddeck-orig" + : > "$dst/.droiddeck-arm64-proton" + python3 - "$src/compatibilitytool.vdf" "$dst/compatibilitytool.vdf" <<'PY' || { rm -rf "$dst"; continue; } +import re, sys +s = open(sys.argv[1], encoding="utf-8", errors="replace").read() +m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', s) +if not m: + sys.exit(1) +# The x86-64 client ignores a tool named arm64 or aarch64 ("for a different target platform linux arm64"). +key = re.sub(r"arm64|aarch64", "a64", m.group(1)) +d = re.search(r'"display_name"\s*"([^"]*)"', s) +name = d.group(1) if d else key +open(sys.argv[2], "w").write( + '"compatibilitytools"\n{\n "compat_tools"\n {\n "%s"\n {\n "install_path" "."\n' + ' "display_name" "%s"\n "from_oslist" "windows"\n "to_oslist" "linux"\n }\n }\n}\n' + % (key, name)) +PY + cat > "$dst/toolmanifest.vdf" <<'VDF' +"manifest" +{ + "version" "2" + "commandline" "/run %verb%" +} +VDF + printf '#!/bin/sh\n# The arm64 client'"'"'s %s, run natively (droiddeck-x64-arm-proton).\nexec "%s" /usr/local/bin/droiddeck-x64-arm-proton "%s" "$@"\n' \ + "$name" "$X64_HOME/arm64/env" "$name" > "$dst/run" + chmod 755 "$dst/run" + done + x64_sync_login + # Each client keeps its own libraries; written only while no x86 client runs, as it rewrites these on exit. + if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then + listing_ok=0 + if python3 - "$STEAM_X64" "${BL_X64_STEAM_LIBRARIES:-}" "$STEAM_X64/config/libraryfolders.vdf" "$STEAM_X64/steamapps/libraryfolders.vdf" <<'PY'; then +import json, os, re, sys +keep = {os.path.realpath(sys.argv[1])} +# Without the session's list nothing is known about the card: leave the folders as they are. +if not sys.argv[2]: + sys.exit(3) +try: + keep |= {os.path.realpath(str(e["path"])) for e in json.load(open(sys.argv[2], encoding="utf-8"))} +except (OSError, ValueError, KeyError, TypeError): + sys.exit(3) +for path in sys.argv[3:]: + if not os.path.isfile(path): + continue + s = open(path, encoding="utf-8", errors="surrogateescape").read() + out, removed, i = [], [], 0 + for m in re.finditer(r'^\t"(\d+)"\s*\n\t\{', s, re.M): + if m.start() < i: + continue + depth, k = 1, m.end() + while depth and k < len(s): + depth += s[k] == "{" + depth -= s[k] == "}" + k += 1 + block = s[m.start():k] + p = re.search(r'"path"\s*"([^"]*)"', block) + if p and os.path.realpath(p.group(1)) not in keep: + out.append(s[i:m.start()]) + i = k + (1 if s[k:k + 1] == "\n" else 0) + removed.append(p.group(1)) + if not removed: + continue + out.append(s[i:]) + open(path, "w", encoding="utf-8", errors="surrogateescape").write("".join(out)) + print("droiddeck-steam-x64: no longer shared with the arm64 client: %s" % ", ".join(removed)) +PY + listing_ok=1 + elif [ $? = 3 ]; then + step "no library list from the session: the x86-64 client's library folders are left as they are" + else + step "could not check the x86-64 client's library folders" + fi + if [ "$listing_ok" = 1 ]; then + BL_STEAM_LIBRARIES=$BL_X64_STEAM_LIBRARIES droiddeck-steam-library "$STEAM_X64" || + step "could not add the card to the x86-64 client's library folders" + fi + fi + # default-tool is the default this script set: titles still on it move along; a user's choice is kept. + default=$(x64_default_dir) + if [ -f "$default/compatibilitytool.vdf" ] && [ -f "$STEAM_X64/config/config.vdf" ] && + ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then + python3 - "$STEAM_X64/config/config.vdf" "$default/compatibilitytool.vdf" "$X64_HOME/default-tool" <<'PY' || step "could not make ARM64 Proton the default" +import os, re, sys +path, vdf, record = sys.argv[1:4] +tool = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', open(vdf).read()).group(1) +s = open(path, encoding="utf-8", errors="surrogateescape").read() +m = re.search(r'"CompatToolMapping"\s*\{', s) +if not m: + sys.exit(0) +depth, k = 1, m.end() +while depth: + depth += s[k] == "{" + depth -= s[k] == "}" + k += 1 +block = s[m.end():k] +try: + previous = open(record).read().strip() or None +except OSError: + previous = None +z = re.search(r'^\s*"0"\s*\{([^{}]*)\}', block, re.M) +if z: + name = re.search(r'"name"\s*"([^"]*)"', z.group(1)) + name = name.group(1) if name else "" + set_here = re.search(r'"priority"\s*"75"', z.group(1)) is not None + if previous is None and set_here and name.endswith("-a64"): + previous = name + if "arm64" not in z.group(1) and name != previous: + sys.exit(0) + if name == tool and previous == tool: + sys.exit(0) + block = block[:z.start()] + block[z.end():] +moved = 0 +if previous and previous != tool: + block, moved = re.subn(r'("name"\s*")%s(")' % re.escape(previous), lambda x: x.group(1) + tool + x.group(2), block) +entry = ('\n\t\t\t\t\t"0"\n\t\t\t\t\t{\n\t\t\t\t\t\t"name"\t\t"%s"\n' % tool + + '\t\t\t\t\t\t"config"\t\t""\n\t\t\t\t\t\t"priority"\t\t"75"\n\t\t\t\t\t}') +s = s[:m.end()] + entry + block + s[k:] +open(path, "w", encoding="utf-8", errors="surrogateescape").write(s) +open(record, "w").write(tool + "\n") +if moved: + print("droiddeck-steam-x64: default %s -> %s, with the %d titles on it" % (previous, tool, moved)) +PY + fi + if [ -d "$ARM_STEAM/config/uioverrides" ] && [ ! -e "$STEAM_X64/config/uioverrides" ]; then + ln -s "$ARM_STEAM/config/uioverrides" "$STEAM_X64/config/uioverrides" + fi + if [ -f "$ARM_STEAM/.cef-enable-remote-debugging" ]; then + : > "$STEAM_X64/.cef-enable-remote-debugging" + else + rm -f "$STEAM_X64/.cef-enable-remote-debugging" + fi + if [ -f "$ARM_STEAM/config/config.vdf" ] && [ -f "$STEAM_X64/config/config.vdf" ] && + ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then + python3 - "$ARM_STEAM/config/config.vdf" "$STEAM_X64/config/config.vdf" <<'PY' || step "could not copy the startup movie choice" +import re, sys +movie = re.search(r'\n([ \t]*)"StartupMovie"\s*\{[^{}]*\}', open(sys.argv[1], encoding="utf-8", errors="replace").read()) +if not movie: + sys.exit(0) +path = sys.argv[2] +s = open(path, encoding="utf-8", errors="surrogateescape").read() +old = re.search(r'\n[ \t]*"StartupMovie"\s*\{[^{}]*\}', s) +if old: + if old.group(0).split(None, 1)[1] == movie.group(0).split(None, 1)[1]: + sys.exit(0) + s = s[:old.start()] + s[old.end():] +steam = re.search(r'"Valve"\s*\{\s*"Steam"\s*\{', s) +if not steam: + sys.exit(0) +s = s[:steam.end()] + movie.group(0) + s[steam.end():] +open(path, "w", encoding="utf-8", errors="surrogateescape").write(s) +PY + fi + # FEX takes its rootfs from FEXServer, and one started without FEX_ROOTFS serves none: stop it while no x86 client runs. + if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then + FEX_ROOTFS=$ROOTFS /usr/local/bin/FEXServer -k >/dev/null 2>&1 || true + fi +} + +x64_gpu() { + [ "${BL_X64_SOFTWARE:-0}" != 1 ] && [ -d /usr/local/share/fex-emu/GuestThunks ] && [ -d /usr/local/lib/fex-emu/HostThunks ] +} + +x64_webhelper_gpu() { + x64_gpu && [ "${BL_X64_CLIENT_SOFTWARE:-0}" != 1 ] && [ "${BL_X64_CEF_GPU:-1}" != 0 ] +} + +x64_env() { +# droiddeck-x64-arm-proton restores this arm64 environment before Proton runs. +export -p | grep -v -E '^declare -x (PWD|OLDPWD|SHLVL|_)=' > "$X64_HOME/arm-session.env.new" || true +mv -f "$X64_HOME/arm-session.env.new" "$X64_HOME/arm-session.env" +unset LD_LIBRARY_PATH LD_PRELOAD STEAM_RUNTIME STEAM_RUNTIME_PREFER_HOST_LIBRARIES +gpu_vars="GALLIUM_DRIVER MESA_LOADER_DRIVER_OVERRIDE VK_ICD_FILENAMES VK_DRIVER_FILES LIBGL_KOPPER_DRI2 ZINK_DEBUG ZINK_DESCRIPTORS TU_DEBUG MESA_NO_ERROR mesa_glthread" +rm -f "$X64_HOME/game-gpu.env" +if x64_gpu; then + printf '{"ThunksDB": {"GL": 1, "Vulkan": 1}}\n' > "$X64_HOME/thunks.json" + { + echo "export FEX_THUNKCONFIG='$X64_HOME/thunks.json'" + echo "unset LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE GALLIUM_DRIVER" + for v in $gpu_vars; do + eval "val=\${$v-}"; [ -n "$val" ] && printf "export %s='%s'\n" "$v" "$val" + done + } > "$X64_HOME/game-gpu.env" +fi +if x64_gpu && [ "${BL_X64_CLIENT_SOFTWARE:-0}" != 1 ]; then + export FEX_THUNKCONFIG=$X64_HOME/thunks.json + unset LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE +else +unset FEX_THUNKCONFIG $gpu_vars +export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe +# Over DRI3 the X server hands out the GPU node, and Mesa's software path loops on KMS dumb buffers on it. +export LIBGL_DRI3_DISABLE=1 +fi +[ "${BL_X64_FP_OFF:-0}" = 1 ] && export PROOT_FP_OFF=1 +export FEX_TSOENABLED=${FEX_TSOENABLED:-1} FEX_VECTORTSOENABLED=${FEX_VECTORTSOENABLED:-0} \ + FEX_MEMCPYSETTSOENABLED=${FEX_MEMCPYSETTSOENABLED:-0} FEX_HALFBARRIERTSOENABLED=${FEX_HALFBARRIERTSOENABLED:-0} \ + FEX_X87REDUCEDPRECISION=${FEX_X87REDUCEDPRECISION:-1} FEX_MULTIBLOCK=${FEX_MULTIBLOCK:-1} +export HOME=$X64_HOME +export XDG_DATA_HOME=$X64_HOME/.local/share XDG_CONFIG_HOME=$X64_HOME/.config XDG_CACHE_HOME=$X64_HOME/.cache +export FEX_ROOTFS=$ROOTFS +export STEAM_EXTRA_COMPAT_TOOLS_PATHS= +export STEAM_RUNTIME=1 +[ -d "$X64_HOME/steamrt-shim" ] && export STEAM_RUNTIME_STEAMRT=$X64_HOME/steamrt-shim +# The client starts commands via steam-runtime-launch-client; Valve's needs a session-bus service, the shim's runs them. +[ -d "$X64_HOME/steamrt-shim/bin" ] && export PATH=$X64_HOME/steamrt-shim/bin:$PATH +export LD_LIBRARY_PATH=$STEAM_X64/steamrt64:$STEAM_X64/steamrt64/panorama:$X64_HOME/steamrt-libs +# After an update the client re-executes itself through pressure-vessel-wrap (bwrap) unless this guard is set. +export STEAM_STEAMRT_RECURSION_GUARD=1 +# proot is a tracer: tier0 takes it for a debugger and breaks into it with int3 on its first failed assertion. +export BL_WEBHELPER_HIDE_TRACER=1 +} + +x64_exec() { + x64_env + cd "$STEAM_X64" + # The client checksums its files at start and reinstalls over edited ones, so the session shim (preload/net.c) + # swaps the web helper at exec. The arm64 helper only comes up against a client of the same build. + if [ "${BL_X64_X86_WEBHELPER:-0}" != 1 ] && [ -x /usr/local/bin/droiddeck-x64-arm-webhelper ] && + [ -x "$ARM_STEAM/steamrtarm64/steamwebhelper" ]; then + local chan x86_build arm_build + chan=$(cat "$STEAM_X64/package/beta" 2>/dev/null || echo "$X64_CHANNEL") + x86_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$STEAM_X64/package/steam_client_${chan}_ubuntu12.manifest" 2>/dev/null || true) + arm_build=$(awk -F'"' '$2 == "version" { print $4; exit }' "$ARM_STEAM/package/steam_client_${chan}_linuxarm64.manifest" 2>/dev/null || true) + if [ -n "$x86_build" ] && [ "$x86_build" = "$arm_build" ]; then + export BL_X64_ARM_WEBHELPER=/usr/local/bin/droiddeck-x64-arm-webhelper + else + echo "== x86-64 client build ${x86_build:-?}, arm64 web helper's ${arm_build:-?}: keeping the x86 web helper" >&2 + fi + fi + if [ -z "${BL_X64_ARM_WEBHELPER:-}" ] && x64_webhelper_gpu; then + # Not --in-process-gpu (the preload's default): a GPU crash there takes the whole helper down. + export BL_WEBHELPER_ARGS="--ignore-gpu-blocklist --use-gl=angle --use-angle=gl --ozone-platform=x11" + [ -x /usr/local/bin/droiddeck-x64-webhelper ] && export BL_X64_ARM_WEBHELPER=/usr/local/bin/droiddeck-x64-webhelper + elif [ -z "${BL_X64_ARM_WEBHELPER:-}" ]; then + export BL_WEBHELPER_ARGS=stock + local a args=() soft=0 + for a in "$@"; do + case $a in + -cef-force-gpu|-cef-ozone-platform=*|-cef-use-gl=*|-cef-use-angle=*) soft=1 ;; + *) args+=("$a") ;; + esac + done + [ "$soft" = 1 ] && set -- "${args[@]}" -cef-disable-gpu + fi + mkdir -p "$STEAM_X64/logs" + touch "$STEAM_X64/logs/bootstrap_log.txt" + ( tail -n0 -F --pid=$$ "$STEAM_X64/logs/bootstrap_log.txt" 2>/dev/null | + grep --line-buffered -oE 'Downloading update \([0-9,]+ of [0-9,]+ KB\)' | sed -u 's/,//g' >&2 ) & + exec "${BL_FEX:-/usr/local/bin/FEX}" "$STEAM_X64/steamrt64/steam" "$@" +} + +# Steam renews its token every few days and refuses old copies: copy the newest every start, never while a client runs. +x64_sync_login() { + pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1 && return 0 + pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 && return 0 + python3 - "$ARM_STEAM" "$STEAM_X64" "$REAL_HOME/.steam/registry.vdf" "$X64_HOME/.steam/registry.vdf" <<'PY' || step "could not share the sign-in between the clients" +import os, re, shutil, sys +arm, x64, arm_reg, x64_reg = sys.argv[1:5] +FILES = ("config/loginusers.vdf", "local.vdf") + + +def stamp(root): + if not all(os.path.isfile(os.path.join(root, f)) for f in FILES): + return None + # A client that refused its token leaves an empty ConnectCache: newest, but copying it signs the other out. + text = open(os.path.join(root, "local.vdf"), encoding="utf-8", errors="surrogateescape").read() + if not re.search(r'"ConnectCache"\s*\{\s*"[^"]+"\s*"[^"]+"', text): + return None + return os.path.getmtime(os.path.join(root, "local.vdf")) + + +a, x = stamp(arm), stamp(x64) +if a is None and x is None: + sys.exit(0) +if a is not None and x is not None and abs(a - x) < 1: + sys.exit(0) +if x is None or (a is not None and a > x): + src, dst, sreg, dreg, who = arm, x64, arm_reg, x64_reg, "arm64" +else: + src, dst, sreg, dreg, who = x64, arm, x64_reg, arm_reg, "x86-64" +for f in FILES: + os.makedirs(os.path.dirname(os.path.join(dst, f)), exist_ok=True) + # copy2 keeps the time stamp, so both match until one client signs in again. + shutil.copy2(os.path.join(src, f), os.path.join(dst, f)) +if os.path.isfile(sreg): + st = open(sreg, encoding="utf-8", errors="surrogateescape").read() + if not os.path.isfile(dreg): + os.makedirs(os.path.dirname(dreg), exist_ok=True) + shutil.copy2(sreg, dreg) + else: + dt = open(dreg, encoding="utf-8", errors="surrogateescape").read() + for key in ("AutoLoginUser", "RememberPassword"): + v = re.search(r'"%s"\s*"([^"]*)"' % key, st) + if v and re.search(r'"%s"\s*"' % key, dt): + dt = re.sub(r'("%s"\s*")[^"]*(")' % key, lambda m: m.group(1) + v.group(1) + m.group(2), dt, count=1) + open(dreg, "w", encoding="utf-8", errors="surrogateescape").write(dt) +print("droiddeck-steam-x64: sign-in shared from the %s client (the newer one)" % who) +PY +} + +case "${1:-}" in + --sync-login) x64_sync_login; exit 0 ;; + --prepare) x64_prepare; exit 0 ;; + --exec) shift; x64_exec "$@" ;; + --gpu) x64_gpu; exit $? ;; + --webhelper-gpu) x64_webhelper_gpu; exit $? ;; + --default-tool) x64_default_tool; exit 0 ;; + --map-direct) shift; x64_map_direct "$@"; exit 0 ;; +esac + +if pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1; then + step "shutting down the arm64 client first" + "$ARM_STEAM/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true + for _ in $(seq 1 30); do pgrep -f 'steamrtarm64/steam( |$)' >/dev/null 2>&1 || break; sleep 1; done +fi +x64_prepare +step "FEX, rootfs and client ready" +if [ -n "${BL_FEXSERVER_LOG:-}" ]; then + FEX_ROOTFS=$ROOTFS "$(dirname "${BL_FEX:-/usr/local/bin/FEX}")/FEXServer" -f -p 600 >"$BL_FEXSERVER_LOG" 2>&1 & + sleep 1 +fi +# The client's connection status comes from NetworkManager on the system bus; a desktop has no system bus. +x64_bus= x64_nm= x64_pin= +x64_cleanup() { + [ -n "$x64_pin" ] && kill "$x64_pin" 2>/dev/null + [ -n "$x64_nm" ] && kill "$x64_nm" 2>/dev/null + [ -n "$x64_bus" ] && kill "$x64_bus" 2>/dev/null + return 0 +} +trap x64_cleanup EXIT +if ! pgrep -f 'dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf' >/dev/null 2>&1 \ + && command -v dbus-daemon >/dev/null 2>&1 && command -v droiddeck-netmanager >/dev/null 2>&1; then + mkdir -p /run/dbus /usr/local/share/droiddeck + cat > /usr/local/share/droiddeck/system-bus.conf <<'BUSCONF' + + + system + unix:path=/run/dbus/system_bus_socket + EXTERNAL + + + + + + + +BUSCONF + rm -f /run/dbus/system_bus_socket + dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork --nopidfile & + x64_bus=$! + for _ in $(seq 1 50); do [ -S /run/dbus/system_bus_socket ] && break; sleep 0.1; done + if [ -S /run/dbus/system_bus_socket ]; then + droiddeck-netmanager & + x64_nm=$! + step "network: system bus up, NetworkManager stand-in running" + else + step "network: the system bus did not come up; the client will show no connection" + fi +fi +# Left by a killed client: .crash opens the update UI on its spinner; stale Singleton* locks can blank the UI. +if ! pgrep -f 'steamrt64/steam( |$)' >/dev/null 2>&1; then + for f in "$STEAM_X64/.crash" "$STEAM_X64/steam.pid" "$STEAM_X64/config/htmlcache/SingletonLock" \ + "$STEAM_X64/config/htmlcache/SingletonCookie" "$STEAM_X64/config/htmlcache/SingletonSocket"; do + { [ -e "$f" ] || [ -L "$f" ]; } && rm -f "$f" + done +fi +if [ -n "${BL_CLIENT_CPUS:-}" ] && command -v taskset >/dev/null 2>&1; then + ( + while sleep 5; do + for pid in $(ps -A -o PID,ARGS | awk '($2 ~ /^(.*\/)?FEX(Interpreter)?$/) && ($3 ~ /steamrt64\/steam$/ || $3 ~ /steamwebhelper$/) {print $1} $2 ~ /steamrtarm64\/steamwebhelper$/ {print $1}'); do + taskset -a -p -c "$BL_CLIENT_CPUS" "$pid" >/dev/null 2>&1 + done + done + ) & + x64_pin=$! +fi +for attempt in 1 2 3 4 5; do + # The branch on disk must match -clientbeta before every start, or the client loops on rc=42. + echo "$X64_CHANNEL" > "$STEAM_X64/package/beta" + step "starting the x86-64 SteamRT3 client under FEX (attempt $attempt): $*" + rc=0 + if [ "${BL_X64_X86_WEBHELPER:-0}" != 1 ] || x64_webhelper_gpu; then cef=(-cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan); else cef=(-cef-disable-gpu); fi + ( x64_exec -clientbeta "$X64_CHANNEL" -no-cef-sandbox "${cef[@]}" "$@" ) >>"$LOG" 2>&1 || rc=$? + step "client exited rc=$rc" + [ "$rc" = 42 ] || break +done \ No newline at end of file diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-arm-proton b/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-arm-proton new file mode 100755 index 000000000..4a074be13 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-arm-proton @@ -0,0 +1,46 @@ +#!/bin/bash +# Runs natively: droiddeck-steam-x64's x86 wrappers start it through an arm64 binary, which FEX hands to the CPU. +tool=$1; shift +x64_home=$HOME + +# The game overlay tags the game's windows with the Steam app ID; without it gamescope never shows them. +overlay=0 +case ${LD_PRELOAD:-} in *gameoverlayrenderer.so*) overlay=1 ;; esac + +for v in $(compgen -e); do + case $v in FEX_*) unset "$v" ;; esac +done +unset LD_LIBRARY_PATH LD_PRELOAD STEAM_RUNTIME_STEAMRT STEAM_STEAMRT_RECURSION_GUARD \ + LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE GALLIUM_DRIVER BL_WEBHELPER_HIDE_TRACER PROOT_FP_OFF + +game_display=${DISPLAY:-} +if [ -f "$x64_home/arm-session.env" ]; then + . "$x64_home/arm-session.env" +else + export HOME=/root +fi +[ -z "$game_display" ] || export DISPLAY=$game_display +# Proton takes its Steam client parts from the client's install folder; the x86-64 client's are x86. +export BL_X64_STEAM_ROOT=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-} +export STEAM_COMPAT_CLIENT_INSTALL_PATH=$HOME/.local/share/Steam +arm_overlay=$STEAM_COMPAT_CLIENT_INSTALL_PATH/steamrtarm64/gameoverlayrenderer.so +if [ "$overlay" = 1 ] && [ -f "$arm_overlay" ]; then + export LD_PRELOAD=$arm_overlay${LD_PRELOAD:+:$LD_PRELOAD} +fi + +dir=$HOME/.local/share/Steam/compatibilitytools.d/$tool +manifest=$dir/toolmanifest.vdf +[ -f "$manifest" ] || { echo "droiddeck-x64-arm-proton: no tool $dir" >&2; exit 127; } +if ! grep -q droiddeck-proton-wrap "$manifest" 2>/dev/null; then + python3 - "${dir%/*}" <<'PY' >&2 || true +import importlib.machinery, importlib.util, sys +path = "/usr/local/bin/steam-compatibility" +spec = importlib.util.spec_from_file_location("compat", path, loader=importlib.machinery.SourceFileLoader("compat", path)) +compat = importlib.util.module_from_spec(spec) +spec.loader.exec_module(compat) +compat.adopt_extras(sys.argv[1]) +PY +fi +cmd=$(sed -n 's/^[[:space:]]*"commandline"[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -n 1) +entry=${cmd%% *} +exec "$dir/${entry#/}" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-arm-webhelper b/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-arm-webhelper new file mode 100755 index 000000000..646dbd01b --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-arm-webhelper @@ -0,0 +1,20 @@ +#!/bin/bash +# The arm64 client's web helper for the x86-64 client, run natively: FEX hands this arm64 program to the CPU. +x64_home=$HOME +arm=/root/.local/share/Steam/steamrtarm64 + +for v in $(compgen -e); do + case $v in FEX_*) unset "$v" ;; esac +done +unset LD_PRELOAD LIBGL_ALWAYS_SOFTWARE LIBGL_DRI3_DISABLE BL_WEBHELPER_HIDE_TRACER BL_X64_ARM_WEBHELPER +if [ -f "$x64_home/arm-session.env" ]; then + eval "$(grep -E '^declare -x (VK_ICD_FILENAMES|VK_DRIVER_FILES|GALLIUM_DRIVER|MESA_LOADER_DRIVER_OVERRIDE|LIBGL_KOPPER_DRI2|TU_DEBUG|ZINK_DEBUG|ZINK_DESCRIPTORS|MESA_NO_ERROR|MESA_DISK_CACHE_DATABASE)=' "$x64_home/arm-session.env")" +fi +export LD_LIBRARY_PATH=$arm:$arm/panorama LIBGL_KOPPER_DISABLE=true + +args=() +for a in "$@"; do + case $a in --disable-gpu|--disable-gpu-compositing) ;; *) args+=("$a") ;; esac +done +cd "$arm" || exit 1 +exec "$arm/steamwebhelper" "${args[@]}" --ignore-gpu-blocklist diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-webhelper b/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-webhelper new file mode 100755 index 000000000..10abdf2a4 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-x64-webhelper @@ -0,0 +1,10 @@ +#!/bin/bash +# Steam's compositor draws each browser window from its X pixmap: frames must reach X, not gamescope's WSI layer. +# gamescope sets vk_xwayland_wait_ready=false for its children; kopper swapchains flashed black even with the wait. +unset BL_X64_ARM_WEBHELPER +# The shim already added the GPU flags (preload/webhelper.c) to these arguments. +export BL_WEBHELPER_ARGS=stock DISABLE_GAMESCOPE_WSI=1 vk_xwayland_wait_ready=true LIBGL_KOPPER_DISABLE=true +if [ -x ./steamwebhelper ]; then + exec ./steamwebhelper "$@" +fi +exec "$HOME/.local/share/Steam/steamrt64/steamwebhelper" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index ef1147c87..e1ce3adeb 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -210,6 +210,34 @@ bl_idtech3() { } """ +# Source games report VAC-secure or insecure only in their console, written to console.log only with -condebug. +# The app keeps the switch and the records in /root: the x86-64 client runs games with its own HOME. +BL_VACDIAG_SETUP = """ +bl_vacdiag() { + [ -f /root/.droiddeck-vac-diagnostics ] || return 0 + [ "${verb:-}" = waitforexitandrun ] || return 0 + local exe= a dir added=no app=${SteamAppId:-${STEAM_COMPAT_APP_ID:-0}} + for a in "$@"; do case $a in *.exe|*.EXE) exe=$a ;; esac; done + [ -n "$exe" ] || return 0 + dir=$(dirname "$exe") + # Source: /gameinfo.txt beside the exe. Source 2: game/bin/win64/ with game//gameinfo.gi. + if ls "$dir"/*/gameinfo.txt >/dev/null 2>&1 || ls "$dir"/../../*/gameinfo.gi >/dev/null 2>&1; then + case " $* " in + *" -condebug "*) echo "droiddeck-proton: vac-diag: app $app already has -condebug" >&2 ;; + *) BL_EXTRA_ARGS+=(-condebug); added=yes + echo "droiddeck-proton: vac-diag: app $app is a Source title; launched with -condebug" >&2 ;; + esac + else + added=not-source + echo "droiddeck-proton: vac-diag: app $app is not a Source title; it keeps no console log to read" >&2 + fi + case " $* " in + *" -insecure "*) echo "droiddeck-proton: vac-diag: app $app has -insecure in its launch options - it can never be VAC-secure this way" >&2 ;; + esac + printf '%s\tapp %s\tcondebug %s\t%s\n' "$(date +%s)" "$app" "$added" "${exe##*/}" >> /root/.droiddeck-vac-launches +} +""" + BL_DIRECTAUDIO_SETUP = """ bl_directaudio() { base=$1 @@ -305,6 +333,7 @@ if [ -n "$depot" ]; then bl_directaudio "$depot" bl_game_cpus bl_idtech3 "$@" + bl_vacdiag "$@" bl_libpath BL_STORE_PROTON="$depot/proton"; export BL_STORE_PROTON bl_store_launch "$@" @@ -320,7 +349,7 @@ LAUNCHER_TEMPLATE = LAUNCHER_SH def launcher_sh(sources=SOURCES): return (LAUNCHER_TEMPLATE % " ".join('"%s"' % n for n in sources)).replace( "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1) + + BL_VACDIAG_SETUP + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1) LAUNCHER_SH = launcher_sh() @@ -359,6 +388,7 @@ bl_components "$here" bl_directaudio "$here" bl_game_cpus bl_idtech3 "$@" +bl_vacdiag "$@" bl_libpath BL_STORE_PROTON="$here/%s"; export BL_STORE_PROTON bl_store_launch "$@" @@ -586,7 +616,7 @@ def adopt_extras(tools): body = (EXTRA_WRAPPER_SH % entry_point).replace( 'here=$(dirname "$0")', 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1) + + BL_VACDIAG_SETUP + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP + BL_STORE_SETUP, 1) write_changed(wrapper, body, 0o755) write_changed(manifest, EXTRA_MANIFEST_VDF) write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, display_name(entry))) @@ -1154,7 +1184,8 @@ def live_helper_running(directory=None): return False -def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False, picks=()): +def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False, picks=(), + linux_tool=FEX_TOOL): """Writes the mapping: the "0" default and every title that follows it (see plan_mapping). With settle, the default itself is decided first (see choose_default): before the client starts, config.vdf holds what the client last kept, menu changes included.""" @@ -1178,9 +1209,12 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non default = state.get("default") if state.get("default") in names else TOOL steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path))) overrides = requested_games(game_requests(directory), catalog) + native = native_targets(steam_root, set(apps) | set(picks)) + if linux_tool != FEX_TOOL: + native = {app: linux_tool if tool == FEX_TOOL else tool for app, tool in native.items()} changes, auto = plan_mapping( current, apps, names, default, followers(state, current), - native_targets(steam_root, set(apps) | set(picks)), picks, + native, picks, overrides, state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else (), state.get("native", ())) for app, tool in changes.items(): @@ -1245,8 +1279,71 @@ def remove_tool(tool_dir): shutil.rmtree(tool_dir) +def local_tools(steam_root): + """The names of the compatibility tools installed in steam_root/compatibilitytools.d.""" + names = set() + tools = os.path.join(steam_root, "compatibilitytools.d") + try: + entries = os.listdir(tools) + except OSError: + return names + for entry in entries: + try: + with open(os.path.join(tools, entry, "compatibilitytool.vdf"), encoding="utf-8", errors="replace") as f: + m = re.search(r'"compat_tools"\s*\{\s*"([^"]+)"', f.read()) + except OSError: + continue + if m: + names.add(m.group(1).lower()) + return names + + +def x64_repoint(steam_root, tool, live=False): + """The x86-64 client (droiddeck-steam-x64): its tools are arm64 wrappers that script makes and + its Linux titles run as Linux builds (FEX), so only the entries the client aimed at a Proton it + cannot start are repointed, to `tool`. Tools in its own compatibilitytools.d are kept: the + wrappers include proton-cachyos-*-a64, which starts with "proton" like Valve's own. + Returns the app ids repointed.""" + config = os.path.join(steam_root, "config", "config.vdf") + if not tool or not os.path.isfile(config): + return [] + with open(config, encoding="utf-8", errors="replace") as f: + original = f.read() + tokens = tokenize(original) + mapping = mapping_block(tokens) + if mapping is None: + return [] + keep = local_tools(steam_root) + moved = [] + current = read_mapping(tokens, mapping) + if "0" not in current: + set_mapping(tokens, mapping, "0", tool) + for app, name in current.items(): + named = name.lower() + # A tool named arm64 is never this client's (its wrappers are -a64), so this legacy name is always repointed. + legacy = named == "droiddeck-proton-arm64" + if (named.startswith("proton") or legacy) and named != tool.lower() \ + and (legacy or named not in keep): + set_mapping(tokens, mapping, app, tool) + if app != "0": + moved.append(app) + text = serialize(tokens) + if text != serialize(tokenize(original)): + write_changed(config, text) + print("steam-compatibility: x86-64 client's Proton entries point at %s" % tool) + # Steam reads a mapping changed while it runs only at its next start; the session restarts it (.bl-compat-pending). + if moved and live: + with open(os.path.join(os.path.expanduser("~"), ".bl-compat-pending"), "a") as f: + f.write("".join("%s\n" % a for a in moved)) + print("steam-compatibility: repointed while the client runs: %s" % " ".join(moved)) + return moved + + def main(): steam_root = sys.argv[1] + if "--x64" in sys.argv[2:]: + x64_repoint(steam_root, os.environ.get("BL_COMPAT_TOOL"), "--live" in sys.argv[2:]) + return tools = os.path.join(steam_root, "compatibilitytools.d") os.makedirs(tools, exist_ok=True) legacy = os.path.join(tools, LEGACY_TOOL) diff --git a/tools/linuxfs/preload/dircache.c b/tools/linuxfs/preload/dircache.c index c20c7d0b4..7d6cffd12 100644 --- a/tools/linuxfs/preload/dircache.c +++ b/tools/linuxfs/preload/dircache.c @@ -91,7 +91,8 @@ static int enabled(void) { if (cached < 0) { const char *off = getenv("BL_NO_DIR_CACHE"); cached = strcmp(program_invocation_short_name, "steam") == 0 && - strstr(program_invocation_name, "steamrtarm64") != NULL && !(off && strcmp(off, "1") == 0); + (strstr(program_invocation_name, "steamrtarm64") != NULL || + strstr(program_invocation_name, "steamrt64") != NULL) && !(off && strcmp(off, "1") == 0); if (cached) pthread_atfork(NULL, NULL, after_fork); } return cached && !forked; diff --git a/tools/linuxfs/preload/net.c b/tools/linuxfs/preload/net.c index eb7846a3c..73b8c9c8b 100644 --- a/tools/linuxfs/preload/net.c +++ b/tools/linuxfs/preload/net.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -309,11 +310,39 @@ static int maybe_answer_lsof(const char *path, char *const argv[]) { _exit(0); } +static char **web_helper_redirect(const char *path, char *const argv[], const char **launcher) { + const char *want = getenv("BL_X64_ARM_WEBHELPER"); + if (!want || !*want || !path) return NULL; + const char *base = strrchr(path, '/'); + if (strcmp(base ? base + 1 : path, "steamwebhelper") != 0) return NULL; + if (!strstr(path, "/steamrt64/")) { + char cwd[PATH_MAX]; + size_t n; + if (path[0] == '/' || !getcwd(cwd, sizeof(cwd))) return NULL; + n = strlen(cwd); + if (n < 10 || strcmp(cwd + n - 10, "/steamrt64") != 0) return NULL; + } + int argc = 0; + while (argv && argv[argc]) argc++; + char **out = calloc((size_t)argc + 2, sizeof(char *)); + if (!out) return NULL; + out[0] = (char *)want; + for (int i = 1; i < argc; i++) out[i] = argv[i]; + *launcher = want; + return out; +} + int execve(const char *path, char *const argv_in[], char *const envp[]) { char *const *argv = bl_webhelper_argv(path, argv_in); static int (*real)(const char *, char *const[], char *const[]); if (!real) real = (int (*)(const char *, char *const[], char *const[]))dlsym(RTLD_NEXT, "execve"); maybe_answer_lsof(path, argv); + const char *launcher = NULL; + char **redirected = web_helper_redirect(path, argv, &launcher); + if (redirected) { + real(launcher, redirected, envp); + free(redirected); + } bl_binfmt_before(path, argv, envp); real(path, argv, envp); return bl_binfmt_retry(path, argv, envp, errno); @@ -349,6 +378,12 @@ int execv(const char *path, char *const argv_in[]) { static int (*real)(const char *, char *const[]); if (!real) real = (int (*)(const char *, char *const[]))dlsym(RTLD_NEXT, "execv"); maybe_answer_lsof(path, argv); + const char *launcher = NULL; + char **redirected = web_helper_redirect(path, argv, &launcher); + if (redirected) { + real(launcher, redirected); + free(redirected); + } bl_binfmt_before(path, argv, environ); real(path, argv); return bl_binfmt_retry(path, argv, environ, errno); diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 3cadddf85..0223f0bb6 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -538,11 +538,11 @@ static FILE *synthesize(const char *table) { return out; } -int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden"))); +int bl_status_without_tracer(const char *path, int flags, const void *caller) __attribute__((visibility("hidden"))); -static FILE *open_stream(fopen_fn real, const char *path, const char *mode) { +static FILE *open_stream(fopen_fn real, const char *path, const char *mode, const void *caller) { int status = mode && mode[0] == 'r' && !strchr(mode, '+') - ? bl_status_without_tracer(path, O_RDONLY | O_CLOEXEC) : -1; + ? bl_status_without_tracer(path, O_RDONLY | O_CLOEXEC, caller) : -1; if (status >= 0) { FILE *copy = fdopen(status, "r"); if (copy == NULL) close(status); @@ -560,11 +560,11 @@ static FILE *open_stream(fopen_fn real, const char *path, const char *mode) { FILE *fopen(const char *path, const char *mode) { static fopen_fn real; if (real == NULL) real = (fopen_fn) dlsym(RTLD_NEXT, "fopen"); - return open_stream(real, path, mode); + return open_stream(real, path, mode, __builtin_return_address(0)); } FILE *fopen64(const char *path, const char *mode) { static fopen_fn real; if (real == NULL) real = (fopen_fn) dlsym(RTLD_NEXT, "fopen64"); - return open_stream(real, path, mode); + return open_stream(real, path, mode, __builtin_return_address(0)); } diff --git a/tools/linuxfs/preload/opens.c b/tools/linuxfs/preload/opens.c index 7c0e766d2..3d1fdb35a 100644 --- a/tools/linuxfs/preload/opens.c +++ b/tools/linuxfs/preload/opens.c @@ -11,7 +11,7 @@ #include #include -int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden"))); +int bl_status_without_tracer(const char *path, int flags, const void *caller) __attribute__((visibility("hidden"))); int bl_writable_retry(int dirfd, const char *path, int flags) __attribute__((visibility("hidden"))); int bl_ntsync_open(const char *path, int flags) __attribute__((visibility("hidden"))); int bl_dircache_create(int dirfd, const char *path, int flags, mode_t mode, @@ -51,7 +51,7 @@ int open(const char *path, int flags, ...) { errno = EACCES; return -1; } - if ((fd = bl_status_without_tracer(path, flags)) >= 0) return fd; + if ((fd = bl_status_without_tracer(path, flags, __builtin_return_address(0))) >= 0) return fd; if ((fd = bl_ntsync_open(path, flags)) != -2) return fd; if (real_open == NULL) real_open = dlsym(RTLD_NEXT, "open"); if (real_open == NULL) { @@ -76,7 +76,7 @@ int openat(int dirfd, const char *path, int flags, ...) { errno = EACCES; return -1; } - if ((fd = bl_status_without_tracer(path, flags)) >= 0) return fd; + if ((fd = bl_status_without_tracer(path, flags, __builtin_return_address(0))) >= 0) return fd; if ((fd = bl_ntsync_open(path, flags)) != -2) return fd; if (real_openat == NULL) real_openat = dlsym(RTLD_NEXT, "openat"); if (real_openat == NULL) { diff --git a/tools/linuxfs/preload/pathcache.c b/tools/linuxfs/preload/pathcache.c index c183eba84..7fd4c630a 100644 --- a/tools/linuxfs/preload/pathcache.c +++ b/tools/linuxfs/preload/pathcache.c @@ -42,7 +42,8 @@ static int enabled(void) { if (cached >= 0) return cached; off = getenv("BL_NO_PATH_CACHE"); cached = strcmp(program_invocation_short_name, "steam") == 0 && - strstr(program_invocation_name, "steamrtarm64") != NULL && + (strstr(program_invocation_name, "steamrtarm64") != NULL || + strstr(program_invocation_name, "steamrt64") != NULL) && !(off != NULL && off[0] == '1'); return cached; } diff --git a/tools/linuxfs/preload/perms.c b/tools/linuxfs/preload/perms.c index 4ca79d3d4..685f15384 100644 --- a/tools/linuxfs/preload/perms.c +++ b/tools/linuxfs/preload/perms.c @@ -7,7 +7,7 @@ #include static int in_steam_dirs(const char *path) { - return path != NULL && (strstr(path, "/Steam/steamrtarm64/") != NULL + return path != NULL && (strstr(path, "/Steam/steamrtarm64/") != NULL || strstr(path, "/Steam/steamrt64/") != NULL || strstr(path, "/Steam/ubuntu12_64/") != NULL || strstr(path, "/Steam/linuxarm64/") != NULL); } diff --git a/tools/linuxfs/preload/pulseguard.c b/tools/linuxfs/preload/pulseguard.c new file mode 100644 index 000000000..c3011df67 --- /dev/null +++ b/tools/linuxfs/preload/pulseguard.c @@ -0,0 +1,55 @@ +/* In Deck mode (-steamos3) the x86-64 client's SteamOS audio manager passes libpulse the NULL operation of a + * failed request, and libpulse aborts on it ("Assertion 'o' failed ... pa_operation_get_state()"). */ +#if defined(__x86_64__) || defined(__i386__) +#define _GNU_SOURCE +#include +#include + +typedef struct pa_operation pa_operation; + +#define PA_OPERATION_CANCELLED 2 + +/* A libpulse loaded with RTLD_LOCAL (Wine's winepulse.so) is not in RTLD_NEXT's scope. */ +static void *libpulse(const char *name) { + void *fn = dlsym(RTLD_NEXT, name); + if (!fn) { + void *handle = dlopen("libpulse.so.0", RTLD_LAZY | RTLD_NOLOAD); + if (handle) { + fn = dlsym(handle, name); + dlclose(handle); + } + } + return fn; +} + +int pa_operation_get_state(pa_operation *o) { + static int (*next)(pa_operation *); + if (!o) + return PA_OPERATION_CANCELLED; + if (!next) + next = (int (*)(pa_operation *))libpulse("pa_operation_get_state"); + return next ? next(o) : PA_OPERATION_CANCELLED; +} + +void pa_operation_unref(pa_operation *o) { + static void (*next)(pa_operation *); + if (!o) + return; + if (!next) + next = (void (*)(pa_operation *))libpulse("pa_operation_unref"); + if (next) + next(o); +} + +void pa_operation_cancel(pa_operation *o) { + static void (*next)(pa_operation *); + if (!o) + return; + if (!next) + next = (void (*)(pa_operation *))libpulse("pa_operation_cancel"); + if (next) + next(o); +} +#else +typedef int pulseguard_arm_build_has_nothing_to_do; +#endif diff --git a/tools/linuxfs/preload/robust.c b/tools/linuxfs/preload/robust.c index 1be12ba9e..679904b29 100644 --- a/tools/linuxfs/preload/robust.c +++ b/tools/linuxfs/preload/robust.c @@ -2,14 +2,18 @@ #include #include #include +#include #include +#include #include #include #include #include #include +#include #include #include +#include #include #ifndef SYS_openat2 @@ -18,6 +22,17 @@ #define BL_RESOLVE_BENEATH 0x08 #define BL_RESOLVE_IN_ROOT 0x10 +// The kernel's MAXSYMLINKS. +#define BL_MAX_SYMLINKS 40 +#define BL_PROC_ROOT_INO 1 + +#ifdef SYS_newfstatat +#define BL_SYS_FSTATAT SYS_newfstatat +typedef struct stat bl_stat; +#else +#define BL_SYS_FSTATAT SYS_fstatat64 +typedef struct stat64 bl_stat; +#endif struct bl_open_how { uint64_t flags; @@ -76,6 +91,151 @@ static struct robust_list_head *thread_robust_head(void) { return head; } +// Magic links are procfs links below a process's directory; procfs's root holds only plain ones (self, thread-self, mounts). +static int is_magic_link(int dirfd, char *link, size_t parent_len) { + int fd = (int)real_syscall(SYS_openat, (long)dirfd, (long)link, (long)(O_PATH | O_NOFOLLOW | O_CLOEXEC), 0L); + if (fd < 0) { + return 0; + } + struct statfs fs; + int procfs = real_syscall(SYS_fstatfs, (long)fd, (long)&fs) == 0 && fs.f_type == PROC_SUPER_MAGIC; + real_syscall(SYS_close, (long)fd); + if (!procfs) { + return 0; + } + char saved = link[parent_len]; + link[parent_len] = 0; + bl_stat parent; + long result = real_syscall(BL_SYS_FSTATAT, (long)dirfd, (long)(parent_len ? link : "."), (long)&parent, 0L); + link[parent_len] = saved; + return result != 0 || parent.st_ino != BL_PROC_ROOT_INO; +} + +// RESOLVE_IN_ROOT walked one component at a time: absolute symlinks restart at dirfd and ".." stops there. +static long openat_in_root(int dirfd, const char *path, int flags, long mode) { + char resolved[PATH_MAX]; + char pending[PATH_MAX]; + size_t resolved_len = 0; + int symlinks = 0; + int have_root = 0; + int root_on_procfs = 0; + dev_t root_dev = 0; + // O_NOFOLLOW and O_CREAT | O_EXCL leave a final symlink alone, unless a trailing slash follows it. + int nofollow = (flags & O_NOFOLLOW) || (flags & (O_CREAT | O_EXCL)) == (O_CREAT | O_EXCL); + const char *rest = path; + for (;;) { + while (*rest == '/') { + rest++; + } + if (!*rest) { + break; + } + const char *name = rest; + const char *end = name; + while (*end && *end != '/') { + end++; + } + size_t len = (size_t)(end - name); + rest = end; + while (*rest == '/') { + rest++; + } + int last = !*rest; + int trailing_slash = last && rest != end; + if (len == 1 && name[0] == '.') { + continue; + } + if (len == 2 && name[0] == '.' && name[1] == '.') { + while (resolved_len > 0 && resolved[resolved_len - 1] != '/') { + resolved_len--; + } + if (resolved_len > 0) { + resolved_len--; + } + continue; + } + size_t separator = resolved_len > 0; + size_t candidate_len = resolved_len + separator + len; + if (candidate_len + 1 >= PATH_MAX) { + errno = ENAMETOOLONG; + return -1; + } + if (separator) { + resolved[resolved_len] = '/'; + } + memcpy(resolved + resolved_len + separator, name, len); + resolved[candidate_len] = 0; + bl_stat st; + int follow = !last || trailing_slash || !nofollow; + long stat_result = follow ? real_syscall(BL_SYS_FSTATAT, (long)dirfd, (long)resolved, (long)&st, (long)AT_SYMLINK_NOFOLLOW) : -1; + if (stat_result == 0 && S_ISLNK(st.st_mode)) { + if (++symlinks > BL_MAX_SYMLINKS) { + errno = ELOOP; + return -1; + } + if (!have_root) { + bl_stat root; + struct statfs root_fs; + if (real_syscall(BL_SYS_FSTATAT, (long)dirfd, (long)".", (long)&root, 0L) != 0) { + return -1; + } + root_dev = root.st_dev; + root_on_procfs = real_syscall(SYS_fstatfs, (long)dirfd, (long)&root_fs) == 0 && root_fs.f_type == PROC_SUPER_MAGIC; + have_root = 1; + } + if ((st.st_dev != root_dev || root_on_procfs) && is_magic_link(dirfd, resolved, resolved_len)) { + // RESOLVE_IN_ROOT refuses magic links. + errno = EXDEV; + return -1; + } + size_t rest_len = strlen(end); + if (rest_len + 1 >= PATH_MAX) { + errno = ENAMETOOLONG; + return -1; + } + char *tail = pending + PATH_MAX - rest_len - 1; + memmove(tail, end, rest_len + 1); + long target_len = real_syscall(SYS_readlinkat, (long)dirfd, (long)resolved, (long)pending, (long)(PATH_MAX - rest_len)); + if (target_len <= 0) { + if (target_len == 0) { + errno = ENOENT; + } + return -1; + } + if ((size_t)target_len + rest_len >= PATH_MAX) { + errno = ENAMETOOLONG; + return -1; + } + memmove(pending + target_len, tail, rest_len + 1); + if (pending[0] == '/') { + resolved_len = 0; + } + rest = pending; + continue; + } + if (last) { + resolved_len = candidate_len; + if (trailing_slash) { + resolved[resolved_len++] = '/'; + } + break; + } + if (stat_result != 0) { + return -1; + } + if (!S_ISDIR(st.st_mode)) { + errno = ENOTDIR; + return -1; + } + resolved_len = candidate_len; + } + if (resolved_len == 0) { + resolved[resolved_len++] = '.'; + } + resolved[resolved_len] = 0; + return real_syscall(SYS_openat, (long)dirfd, (long)resolved, (long)flags, mode); +} + static long openat2_via_openat(const long *args) { const char *path = (const char *)args[1]; const struct bl_open_how *how = (const struct bl_open_how *)args[2]; @@ -83,6 +243,9 @@ static long openat2_via_openat(const long *args) { errno = EINVAL; return -1; } + if ((how->resolve & BL_RESOLVE_IN_ROOT) && (int)args[0] != AT_FDCWD) { + return openat_in_root((int)args[0], path, (int)how->flags, (long)how->mode); + } if (how->resolve & BL_RESOLVE_IN_ROOT) { while (*path == '/') { path++; diff --git a/tools/linuxfs/preload/storage.c b/tools/linuxfs/preload/storage.c index 4b9c44e66..1e454b864 100644 --- a/tools/linuxfs/preload/storage.c +++ b/tools/linuxfs/preload/storage.c @@ -145,7 +145,8 @@ static int steam_client(void) { static int cached = -1; if (cached < 0) cached = strcmp(program_invocation_short_name, "steam") == 0 && - strstr(program_invocation_name, "steamrtarm64") != NULL; + (strstr(program_invocation_name, "steamrtarm64") != NULL || + strstr(program_invocation_name, "steamrt64") != NULL); return cached; } diff --git a/tools/linuxfs/preload/tracer.c b/tools/linuxfs/preload/tracer.c index f713cc254..132af5134 100644 --- a/tools/linuxfs/preload/tracer.c +++ b/tools/linuxfs/preload/tracer.c @@ -4,8 +4,10 @@ #define _GNU_SOURCE #include +#include #include #include +#include #include #include #include @@ -31,9 +33,16 @@ static int is_status_path(const char *path) { return strcmp(rest, "/status") == 0; } -static int is_web_helper(void) { - return strcmp(program_invocation_short_name, "steamwebhelper") == 0 || - strcmp(program_invocation_short_name, "vrwebhelper") == 0; +static int is_web_helper(const void *caller) { + if (strcmp(program_invocation_short_name, "steamwebhelper") != 0 && + strcmp(program_invocation_short_name, "vrwebhelper") != 0) + return 0; + const char *hide = getenv("BL_WEBHELPER_HIDE_TRACER"); + Dl_info info; + if (hide && hide[0] == '1' && caller && dladdr(caller, &info) && info.dli_fname + && strstr(info.dli_fname, "libtier0_s.so")) + return 0; + return 1; } static int write_all(int fd, const char *data, size_t len) { @@ -47,13 +56,13 @@ static int write_all(int fd, const char *data, size_t len) { return 0; } -__attribute__((visibility("hidden"))) int bl_status_without_tracer(const char *path, int flags) { +__attribute__((visibility("hidden"))) int bl_status_without_tracer(const char *path, int flags, const void *caller) { char text[STATUS_MAX]; size_t len = 0; int saved = errno; int out = -1; - if ((flags & O_ACCMODE) != O_RDONLY || !is_status_path(path) || is_web_helper()) return -1; + if ((flags & O_ACCMODE) != O_RDONLY || !is_status_path(path) || is_web_helper(caller)) return -1; int in = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC); if (in < 0) goto done; for (;;) { diff --git a/tools/proot/fastpath/fastpath.c b/tools/proot/fastpath/fastpath.c index 08bded6c5..53741f4a6 100644 --- a/tools/proot/fastpath/fastpath.c +++ b/tools/proot/fastpath/fastpath.c @@ -141,6 +141,26 @@ static int traced_by_match(const char *key) { return found; } +static int fex_runs_32bit(void) { + if (strcmp(program_invocation_short_name, "FEX") != 0 && strcmp(program_invocation_short_name, "FEXInterpreter") != 0) + return 0; + char args[PATH_MAX + 64]; + long fd = syscall(SYS_openat, AT_FDCWD, "/proc/self/cmdline", O_RDONLY | O_CLOEXEC); + if (fd < 0) return 1; + long n = syscall(SYS_read, fd, args, sizeof args - 1); + syscall(SYS_close, fd); + if (n <= 0) return 1; + args[n] = 0; + const char *prog = args + strlen(args) + 1; + if (prog >= args + n || !*prog) return 1; + unsigned char ident[5]; + fd = syscall(SYS_openat, AT_FDCWD, prog, O_RDONLY | O_CLOEXEC); + if (fd < 0) return 1; + n = syscall(SYS_read, fd, ident, sizeof ident); + syscall(SYS_close, fd); + return !(n == 5 && memcmp(ident, "\x7f" "ELF", 4) == 0 && ident[4] == 2); +} + static int in_tracer(struct bind *bd) { char h[PATH_MAX]; if (snprintf(h, sizeof h, "/proc/%ld%s", tracer, bd->host + 10) >= (int)sizeof h) return 0; @@ -157,6 +177,7 @@ static void fp_init(void) { const char *r = getenv("PROOT_FP_ROOT"), *b = getenv("PROOT_FP_BINDS"), *t = getenv("PROOT_FP_TTL_MS"); const char *key = getenv("PROOT_FP_KEY"); if (!r || !*r || !key || !*key || getenv("PROOT_FP_OFF")) return; + if (fex_runs_32bit()) return; void *p = mmap((void *)FP_STUB_ADDR, FP_STUB_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0); if (p != (void *)FP_STUB_ADDR) { diff --git a/tools/tests/test_openat2_preload.py b/tools/tests/test_openat2_preload.py new file mode 100644 index 000000000..2187272ca --- /dev/null +++ b/tools/tests/test_openat2_preload.py @@ -0,0 +1,192 @@ +import errno +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +# Opens each "\t\t" case with openat2(RESOLVE_IN_ROOT) as FEX does: first through the kernel's own +# openat2 on argv[1] when it has one, then, with openat2 failing ENOSYS as under proot, through the preload on argv[2]. +PROGRAM = r""" +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef SYS_openat2 +#define SYS_openat2 437 +#endif + +struct how { uint64_t flags, mode, resolve; }; +static char cases[256][512]; +static int count; + +static void run(long (*call)(long, ...), const char *tree) { + int root = open(tree, O_PATH | O_DIRECTORY | O_CLOEXEC), outer = open("/", O_PATH | O_DIRECTORY | O_CLOEXEC); + for (int i = 0; i < count; i++) { + char *flags = strchr(cases[i], '\t') + 1, *path = strchr(flags, '\t') + 1; + struct how h = {strtoull(flags, NULL, 0), 0, 0x10}; + if (h.flags & O_CREAT) h.mode = 0644; + long fd = call(SYS_openat2, (long)(cases[i][0] == 'o' ? outer : root), (long)path, (long)&h, (long)sizeof(h)); + struct stat st; + if (fd < 0) { + printf("ERR %d\n", errno); + } else { + fstat((int)fd, &st); + printf("OK %llu %llu\n", (unsigned long long)st.st_dev, (unsigned long long)st.st_ino); + close((int)fd); + } + } +} + +int main(int argc, char **argv) { + if (argc < 3) return 2; + while (count < 256 && fgets(cases[count], sizeof(cases[count]), stdin)) { + cases[count][strcspn(cases[count], "\n")] = 0; + count++; + } + long (*kernel)(long, ...) = (long (*)(long, ...))dlsym(dlopen("libc.so.6", RTLD_LAZY | RTLD_NOLOAD), "syscall"); + struct how probe = {O_RDONLY, 0, 0x10}; + long fd = kernel(SYS_openat2, (long)AT_FDCWD, (long)".", (long)&probe, (long)sizeof(probe)); + if (fd >= 0) { + close((int)fd); + printf("kernel\n"); + run(kernel, argv[1]); + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_openat2, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | ENOSYS), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW), + }; + struct sock_fprog prog = {sizeof(filter) / sizeof(filter[0]), filter}; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)) { + perror("seccomp"); + return 3; + } + } + printf("preload\n"); + run(syscall, argv[2]); + return 0; +} +""" + +O_PATH = getattr(os, "O_PATH", 0o10000000) + +# (dirfd: r = the tree, o = "/"), flags, path, expected: a path in the tree, an errno name, or "proc" for a file outside it. +CASES = [ + ("r", os.O_RDONLY, "etc/alternatives/hn", "etc/hostname"), + ("r", os.O_RDONLY, "/lib64/x86_64-linux-gnu/libfoo.so.1", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"), + ("r", os.O_RDONLY, "lib/x86_64-linux-gnu/libfoo.so.1", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"), + ("r", os.O_RDONLY, "etc/escape", "etc/hostname"), + ("r", os.O_RDONLY, "dirlink/../etc/hostname", "etc/hostname"), + ("r", os.O_RDONLY, "rootlink/rootlink//etc/./hostname", "etc/hostname"), + ("r", os.O_RDONLY, "../../etc/hostname", "etc/hostname"), + ("r", os.O_RDONLY | os.O_DIRECTORY, "/", "."), + ("r", os.O_RDONLY | os.O_DIRECTORY, "usr/../..", "."), + ("r", os.O_RDONLY, "chain/l2", "chain/end"), + ("r", os.O_RDONLY, "chain/l1", "ELOOP"), + ("r", os.O_RDONLY, "loop/a/x", "ELOOP"), + ("r", os.O_RDONLY | os.O_NOFOLLOW, "etc/alternatives/hn", "ELOOP"), + ("r", O_PATH | os.O_NOFOLLOW, "etc/alternatives/hn", "etc/alternatives/hn"), + ("r", os.O_RDONLY | os.O_NOFOLLOW, "lib/x86_64-linux-gnu/libfoo.so.1.2", "usr/lib/x86_64-linux-gnu/libfoo.so.1.2"), + ("r", os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY, "dirlink/", "usr"), + ("r", os.O_RDONLY, "file/", "ENOTDIR"), + ("r", os.O_RDONLY, "file/../etc/hostname", "ENOTDIR"), + ("r", os.O_RDONLY, "nonexist/../etc/hostname", "ENOENT"), + ("r", os.O_RDWR | os.O_CREAT | os.O_EXCL, "dangling", "EEXIST"), + ("r", os.O_RDWR | os.O_CREAT, "dangling", "usr/newfile"), + ("r", os.O_RDWR | os.O_CREAT, "missing/newfile", "ENOENT"), + ("r", os.O_RDWR | os.O_CREAT, "newdir/", "EISDIR"), + ("o", os.O_RDONLY | os.O_NONBLOCK, "proc/self/fd/0", "EXDEV"), + ("o", os.O_RDONLY, "proc/self/exe", "EXDEV"), + ("o", os.O_RDONLY, "proc/self/cwd/x", "EXDEV"), + ("o", os.O_RDONLY, "proc/self/status", "proc"), +] + + +def make_tree(root): + for folder in ("usr/lib/x86_64-linux-gnu", "etc/alternatives", "loop", "chain"): + (root / folder).mkdir(parents=True) + (root / "usr/lib/x86_64-linux-gnu/libfoo.so.1.2").write_text("x86") + (root / "etc/hostname").write_text("inside") + (root / "file").write_text("f") + (root / "chain/end").write_text("end") + links = {"usr/lib/x86_64-linux-gnu/libfoo.so.1": "libfoo.so.1.2", "lib": "usr/lib", "lib64": "/usr/lib", + "etc/alternatives/hn": "/etc/hostname", "etc/escape": "../../../../../../etc/hostname", "loop/a": "b", + "loop/b": "a", "dirlink": "/usr", "rootlink": "/", "dangling": "/usr/newfile"} + links.update({"chain/l%d" % i: ("l%d" % (i + 1) if i < 41 else "end") for i in range(1, 42)}) + for link, target in links.items(): + os.symlink(target, root / link) + + +def labels(lines, root): + inodes = {(os.lstat(root).st_dev, os.lstat(root).st_ino): "."} + for folder, dirs, files in os.walk(root): + for name in dirs + files: + st = os.lstat(os.path.join(folder, name)) + inodes[(st.st_dev, st.st_ino)] = os.path.relpath(os.path.join(folder, name), root) + out = [] + for line in lines: + kind, *values = line.split() + if kind == "OK": + out.append(inodes.get((int(values[0]), int(values[1])), "proc")) + else: + out.append(errno.errorcode[int(values[0])]) + return out + + +@unittest.skipUnless(shutil.which("cc") and os.path.isdir("/proc/self"), "needs cc and procfs") +class Openat2PreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = Path(tempfile.mkdtemp()) + cls.lib = cls.tmp / "libopenat2.so" + subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-fPIC", "-shared", "-pthread", "-o", str(cls.lib), + str(PRELOAD / "robust.c"), str(PRELOAD / "fsync.c"), str(PRELOAD / "noexec.c"), "-ldl"], check=True) + source = cls.tmp / "openat2.c" + source.write_text(PROGRAM) + subprocess.run(["cc", "-O2", "-Wall", "-Werror", "-o", str(cls.tmp / "openat2"), str(source), "-ldl"], check=True) + cls.trees = (cls.tmp / "kernel", cls.tmp / "preload") + for tree in cls.trees: + make_tree(tree) + cases = "".join("%s\t%d\t%s\n" % case[:3] for case in CASES) + result = subprocess.run([str(cls.tmp / "openat2"), *map(str, cls.trees)], input=cases, capture_output=True, text=True, + env=dict(os.environ, LD_PRELOAD=str(cls.lib)), timeout=60) + if result.returncode != 0: + raise AssertionError(result.stdout + result.stderr) + lines = result.stdout.splitlines() + cls.kernel = labels(lines[1:1 + len(CASES)], cls.trees[0]) if lines[0] == "kernel" else None + cls.preload = labels(lines[-len(CASES):], cls.trees[1]) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def test_preload_resolves_in_the_root_without_openat2(self): + for case, got in zip(CASES, self.preload): + self.assertEqual(got, case[3], case) + self.assertTrue((self.trees[1] / "usr/newfile").exists()) + + def test_the_kernel_agrees(self): + if self.kernel is None: + self.skipTest("this kernel or sandbox has no openat2") + for case, got in zip(CASES, self.kernel): + self.assertEqual(got, case[3], case) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_compat_x64.py b/tools/tests/test_steam_compat_x64.py new file mode 100644 index 000000000..3d4b069e0 --- /dev/null +++ b/tools/tests/test_steam_compat_x64.py @@ -0,0 +1,108 @@ +import os +from pathlib import Path +import runpy +import tempfile +import unittest + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) + +CONFIG = '''"InstallConfigStore" +{ +\t"Software" +\t{ +\t\t"Valve" +\t\t{ +\t\t\t"Steam" +\t\t\t{ +\t\t\t\t"CompatToolMapping" +\t\t\t\t{ +\t\t\t\t\t"550" +\t\t\t\t\t{ +\t\t\t\t\t\t"name"\t\t"proton_experimental" +\t\t\t\t\t\t"config"\t\t"" +\t\t\t\t\t\t"priority"\t\t"250" +\t\t\t\t\t} +\t\t\t\t\t"620" +\t\t\t\t\t{ +\t\t\t\t\t\t"name"\t\t"proton-cachyos-a64" +\t\t\t\t\t\t"config"\t\t"" +\t\t\t\t\t\t"priority"\t\t"250" +\t\t\t\t\t} +\t\t\t\t\t"880" +\t\t\t\t\t{ +\t\t\t\t\t\t"name"\t\t"droiddeck-proton-arm64" +\t\t\t\t\t\t"config"\t\t"" +\t\t\t\t\t\t"priority"\t\t"250" +\t\t\t\t\t} +\t\t\t\t\t"730" +\t\t\t\t\t{ +\t\t\t\t\t\t"name"\t\t"steamlinuxruntime" +\t\t\t\t\t\t"config"\t\t"" +\t\t\t\t\t\t"priority"\t\t"250" +\t\t\t\t\t} +\t\t\t\t} +\t\t\t} +\t\t} +\t} +} +''' + + +class X64RepointTest(unittest.TestCase): + """steam-compatibility --x64: the x86-64 client's Proton entries go to its arm64 wrapper.""" + + def setUp(self): + self.home = tempfile.TemporaryDirectory() + self.root = Path(self.home.name) / "Steam" + (self.root / "config").mkdir(parents=True) + (self.root / "config" / "config.vdf").write_text(CONFIG) + # A wrapper of its own whose name starts with "proton": kept, never repointed. + own = self.root / "compatibilitytools.d" / "proton-cachyos-a64" + own.mkdir(parents=True) + (own / "compatibilitytool.vdf").write_text('"compatibilitytools"\n{\n\t"compat_tools"\n\t{\n\t\t"proton-cachyos-a64"\n\t\t{\n\t\t}\n\t}\n}\n') + self.old_home = os.environ.get("HOME") + os.environ["HOME"] = self.home.name + + def tearDown(self): + if self.old_home is None: + os.environ.pop("HOME", None) + else: + os.environ["HOME"] = self.old_home + self.home.cleanup() + + def mapping(self): + tokens = COMPAT["tokenize"]((self.root / "config" / "config.vdf").read_text()) + return COMPAT["read_mapping"](tokens, COMPAT["mapping_block"](tokens)) + + def test_repoints_valve_proton_only(self): + moved = COMPAT["x64_repoint"](str(self.root), "GE-Proton11-7-a64") + self.assertEqual(moved, ["550", "880"]) + m = self.mapping() + self.assertEqual(m["550"], "GE-Proton11-7-a64") + self.assertEqual(m["620"], "proton-cachyos-a64") + self.assertEqual(m["730"], "steamlinuxruntime") + # The arm64 client's own Proton name, which this client ignores. + self.assertEqual(m["880"], "GE-Proton11-7-a64") + self.assertEqual(m["0"], "GE-Proton11-7-a64") + + def test_repoints_arm64_name_even_with_its_stray_tool(self): + # An arm64 compat pass that ran against this client also left its tool folder here. + stray = self.root / "compatibilitytools.d" / "droiddeck-proton-arm64" + stray.mkdir(parents=True) + (stray / "compatibilitytool.vdf").write_text('"compatibilitytools"\n{\n\t"compat_tools"\n\t{\n\t\t"droiddeck-proton-arm64"\n\t\t{\n\t\t}\n\t}\n}\n') + self.assertIn("880", COMPAT["x64_repoint"](str(self.root), "GE-Proton11-7-a64")) + self.assertEqual(self.mapping()["880"], "GE-Proton11-7-a64") + + def test_live_notes_pending_restart(self): + COMPAT["x64_repoint"](str(self.root), "GE-Proton11-7-a64", live=True) + self.assertEqual((Path(self.home.name) / ".bl-compat-pending").read_text(), "550\n880\n") + + def test_no_tool_changes_nothing(self): + before = (self.root / "config" / "config.vdf").read_text() + self.assertEqual(COMPAT["x64_repoint"](str(self.root), None), []) + self.assertEqual((self.root / "config" / "config.vdf").read_text(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_desktop_launch.py b/tools/tests/test_steam_desktop_launch.py index bf91609d1..626631e7f 100644 --- a/tools/tests/test_steam_desktop_launch.py +++ b/tools/tests/test_steam_desktop_launch.py @@ -23,9 +23,13 @@ class DesktopSteamLaunchTest(unittest.TestCase): supervisor = self.root / 'supervisor' supervisor.write_text('#!/bin/bash\nprintf "%s\\n" "$BL_INSIDE" "$BL_STEAM_UI" "$DISPLAY" "$@"\n') supervisor.chmod(0o755) + x64 = self.root / 'steam-x64' + x64.write_text('#!/bin/bash\nprintf "%s\\n" x64 "$@"\n') + x64.chmod(0o755) self.launcher = self.root / 'launch' self.launcher.write_text((BIN / 'droiddeck-steam-launch').read_text().replace( - '/usr/local/bin/droiddeck-session', str(supervisor))) + '/usr/local/bin/droiddeck-session', str(supervisor)).replace( + '/usr/local/bin/droiddeck-steam-x64', str(x64))) def launch(self, *args): return subprocess.run(['bash', str(self.launcher), *args], env=self.env, @@ -62,6 +66,22 @@ class DesktopSteamLaunchTest(unittest.TestCase): '-cef-disable-gpu', 'steam://open/settings']) self.assertFalse((self.session / 'steam-launch').exists()) + def test_second_click_forwards_to_the_x86_64_client_in_its_own_home(self): + lock = self.root / 'cache/droiddeck/steam-desktop.lock' + lock.parent.mkdir(parents=True) + x64_home = self.root / 'x64' + (x64_home / '.steam').mkdir(parents=True) + os.mkfifo(x64_home / '.steam/steam.pipe') + (x64_home / '.steam/steam.pid').write_text(str(os.getpid())) + self.env.update(BL_STEAM_CLIENT_ARCH='x86_64', BL_X64_HOME=str(x64_home)) + with lock.open('w') as handle: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = self.launch('desktop', '501590') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines(), ['x64', '--exec', '-nobigpicture', '-no-cef-sandbox', + '-cef-ozone-platform=x11', '-cef-disable-gpu', + 'steam://rungameid/501590']) + def test_second_click_waits_for_steam_ipc_during_startup(self): lock = self.root / 'cache/droiddeck/steam-desktop.lock' lock.parent.mkdir(parents=True) diff --git a/tools/tests/test_steam_shortcut_recovery.py b/tools/tests/test_steam_shortcut_recovery.py index a5148c3c2..fc196e94f 100644 --- a/tools/tests/test_steam_shortcut_recovery.py +++ b/tools/tests/test_steam_shortcut_recovery.py @@ -36,12 +36,13 @@ class SteamShortcutRecoveryTest(unittest.TestCase): "exit \"$last\"\n") self.agent.chmod(0o755) - def launch_game(self, states, focusable="769", extra=None): + def launch_game(self, states, focusable="769", extra=None, client=None): xprop = self.bin / "xprop" xprop.write_text("#!/bin/sh\necho 'GAMESCOPE_FOCUSABLE_APPS(CARDINAL) = %s'\n" % focusable) xprop.chmod(0o755) - script = "nap() { :; }\nsteam_root=%s\n%s\nsteam_launch_game %s\n" % ( - self.root / "steam", FUNCTIONS.replace("/usr/local/bin/droiddeck-agent", str(self.agent)), GAME) + script = "nap() { :; }\nsteam_root=%s\n%s%s\nsteam_launch_game %s\n" % ( + self.root / "steam", "steam_client=(%s --exec)\n" % client if client else "", + FUNCTIONS.replace("/usr/local/bin/droiddeck-agent", str(self.agent)), GAME) env = dict(os.environ, HOME=str(self.root), BL_LAUNCH_DIR=str(self.launch), PATH=str(self.bin) + os.pathsep + os.environ["PATH"], STEAM_LOG=str(self.steam_log), AGENT_COUNT=str(self.root / "agent-count"), AGENT_STATES=states, **(extra or {})) @@ -63,6 +64,16 @@ class SteamShortcutRecoveryTest(unittest.TestCase): self.assertEqual(GAME, restart) self.assertIn("dropped shortcut", out) + def test_the_running_x86_64_client_gets_the_launch_and_the_restart(self): + x64 = self.bin / "droiddeck-steam-x64" + x64_log = self.root / "x64-args" + x64.write_text("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$X64_LOG\"\n") + x64.chmod(0o755) + steam, restart, _ = self.launch_game("0 0 4", client=x64, extra={"X64_LOG": str(x64_log)}) + self.assertEqual([], steam) + self.assertEqual(["--exec steam://rungameid/" + GAME, "--exec -shutdown"], x64_log.read_text().splitlines()) + self.assertEqual(GAME, restart) + def test_a_shortcut_already_lost_restarts_steam_without_launching(self): steam, restart, out = self.launch_game("4") self.assertEqual(["-shutdown"], steam) diff --git a/tools/tests/test_storage_preload.py b/tools/tests/test_storage_preload.py index 2bf01eb01..4bdee7826 100644 --- a/tools/tests/test_storage_preload.py +++ b/tools/tests/test_storage_preload.py @@ -88,6 +88,9 @@ class StoragePreloadTest(unittest.TestCase): subprocess.run(["cc", "-O2", "-o", str(cls.steam), str(build / "program.c")], check=True) cls.other = build / "steamwebhelper" shutil.copy(cls.steam, cls.other) + cls.steam_x64 = cls.dir / "steamrt64/steam" + cls.steam_x64.parent.mkdir() + shutil.copy(cls.steam, cls.steam_x64) @classmethod def tearDownClass(cls): @@ -117,6 +120,12 @@ class StoragePreloadTest(unittest.TestCase): self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate 0 - 0", "ftruncate 0 - 0"], out) self.assertEqual(["data.pak 0"], self.reached()) + def test_x86_64_client_reservation_is_not_zero_filled_either(self): + data = self.staging / "data.pak" + out = self.run_program(("fallocate", data, 1 << 20), ("ftruncate", data, 1 << 20), program=self.steam_x64) + self.assertEqual(["fallocate -1 EOPNOTSUPP 0", "ftruncate 0 - 0"], out) + self.assertEqual([], self.reached()) + def test_reservation_is_real_until_the_device_has_refused_one(self): data = self.staging / "data.pak" self.assertEqual(["ftruncate 0 - 4096"], self.run_program(("ftruncate", data, 4096)))