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