feat: add persistent per-game compatibility profiles (#449)

* feat(profiles): add persistent per-game compatibility profiles

* fix(profiles): show Windows component overrides

Include per-game Windows component selections in the resolved profile summary so the session overlay marks those games as custom and shows the selected components.

* fix(session): detect Proton games across runtime paths

* fix(profiles): select the active Gamescope game

* fix(profiles): improve per-game reset feedback

* fix(profiles): align session components with game overrides

* fix(session): prefer selected Steam game during refresh

* fix(session): resume profile following when reopening games

* chore: keep local dev testing outside the repository

* test(session): remove local dev package path

* fix(session): preserve profiles when refresh fails

* fix(session): reject empty game profile reset scope

* perf(session): cache drawer inventory by refresh revision

* fix(profiles): align game presets and profile reset behavior

* fix(profiles): preserve originals and normalize shortcut ids

* fix(profiles): keep editor scope stable after rebase

* fix(tests): host Compose tests in both build variants

* fix(profiles): recover interrupted component selection saves

---------

Co-authored-by: Kurt Himebauch <[email protected]>
This commit is contained in:
Juan MartíandKurt Himebauch authored and GitHub committed 2026-10-11 17:00:28 -04:00
1 parent 78fcb60fd5
commit 435beb7f19
46 files changed
+3501 -150

No files matched your search

+3
View File
@@ -302,4 +302,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.compose.ui:ui-test-junit4'
// The Android archive does not include the host natives used by JVM archive tests.
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
}
@@ -1183,6 +1183,9 @@ class MainActivity : ComponentActivity() {
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
// Reached by a hop (a game's Components card, Play's Graphics drivers): back the way it came.
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
onAuto = { on -> drivers.setMode(on) },
@@ -40,6 +40,7 @@ import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.agent.AgentGuest
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
@@ -64,6 +65,9 @@ import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.GameProfileFollowState
import com.droiddeck.launcher.session.GameProfileInventoryCache
import com.droiddeck.launcher.session.GameProfileRefreshState
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionArtifacts
@@ -73,6 +77,9 @@ import com.droiddeck.launcher.session.SessionPaths
import com.droiddeck.launcher.wayland.HdrSupport
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.ProtonDefault
import com.droiddeck.launcher.session.SelectedGameProfile
import com.droiddeck.launcher.session.WinComponents
import com.droiddeck.launcher.ui.CursorOverlay
import androidx.compose.ui.graphics.asImageBitmap
import com.droiddeck.launcher.ui.DrawerActions
@@ -86,6 +93,7 @@ import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.LatestRequestCoordinator
import com.droiddeck.launcher.ui.SessionDrawerPage
import com.droiddeck.launcher.ui.sessionDrawerPages
import com.droiddeck.launcher.ui.step
@@ -247,6 +255,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var closeAtOnce = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerGameProfileId by mutableStateOf<Long?>(null)
private val drawerGameInventory = GameProfileInventoryCache<Library.SteamGame>()
private var drawerGameInventoryRevision = 0L
private var drawerGameFexPresetSaving by mutableStateOf(false)
private val drawerGameProfileFollowState = GameProfileFollowState()
private data class DrawerGameProfileRefresh(
val followsSteam: Boolean,
val requestedAppId: Long?,
val inventoryRevision: Long,
)
private data class DrawerGameProfileResult(
val profiles: List<Pair<Long, String>>,
val selectedAppId: Long?,
val profile: SelectedGameProfile?,
val error: Exception? = null,
)
private var drawerGameProfileRefresh by mutableStateOf(GameProfileRefreshState<DrawerGameProfileResult>())
private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>()
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
@@ -260,6 +286,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var effects by mutableStateOf(ScreenEffects.OFF)
private var textureAnisotropy by mutableStateOf(0)
private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF)
private var drawerTextureAnisotropy by mutableStateOf<Int?>(null)
private var drawerTextureLodBias by mutableStateOf<String?>(null)
private var lossless by mutableStateOf(Lossless.State.NONE)
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
@@ -544,7 +572,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
androidApps = androidApps,
hudOn = hudOn,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
effects = effects,
textureAnisotropy = if (drawerGameProfileId == null) textureAnisotropy else drawerTextureAnisotropy,
textureLodBias = if (drawerGameProfileId == null) textureLodBias else drawerTextureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy,
@@ -569,8 +599,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e
e.push(upscaler)
},
onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v },
onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v },
onTextureAnisotropy = { value ->
drawerGameProfileId?.let { appId ->
SessionPrefs.setGameTextureAnisotropy(this@SessionActivity, appId.toString(), value)
drawerTextureAnisotropy = value
} ?: value?.let {
SessionPrefs.setTextureAnisotropy(this@SessionActivity, it)
textureAnisotropy = it
}
},
onTextureLodBias = { value ->
drawerGameProfileId?.let { appId ->
SessionPrefs.setGameTextureLodBias(this@SessionActivity, appId.toString(), value)
drawerTextureLodBias = value
} ?: value?.let {
SessionPrefs.setTextureLodBias(this@SessionActivity, it)
textureLodBias = it
}
},
onFrameGenPick = { mode ->
FrameGen.set(this@SessionActivity, mode)
readPrefs()
@@ -666,7 +712,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onClose = { drawerOpen = false },
components = drawerComponents,
onComponentsRefresh = { refreshDrawerComponents() },
gameProfiles = drawerGameProfileRefresh.data?.profiles.orEmpty(),
selectedGameProfileId = drawerGameProfileId,
selectedGameProfile = drawerGameProfileRefresh.data
?.takeIf { it.selectedAppId == drawerGameProfileId }?.profile,
gameProfilesLoaded = drawerGameProfileRefresh.loaded,
gameProfileRefreshFailed = drawerGameProfileRefresh.failed,
onSelectedGameProfile = ::selectDrawerGameProfile,
onGameProfilesPageOpened = {
drawerGameProfileFollowState.resumeFollowingSteam()
drawerGameInventoryRevision++
},
onSelectedGameProfileRefresh = ::refreshDrawerGameProfile,
gameFexPresetSaving = drawerGameFexPresetSaving,
onGameFexPreset = ::setDrawerGameFexPreset,
onComponentSwap = { pid, comp, value -> swapDrawerComponent(pid, comp, value) },
onGameComponent = ::setDrawerGameComponent,
))
if (SessionState.suspended) SessionPausedOverlay(
title = pausedTitle(),
@@ -763,6 +824,107 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "drawer-components").start()
}
private fun selectDrawerGameProfile(appId: Long?) {
drawerGameProfileFollowState.selectManually()
drawerGameProfileId = appId
drawerGameProfileRefresh = drawerGameProfileRefresh.retrying()
readDrawerTextureFiltering(appId)
refreshDrawerGameProfile()
}
private fun readDrawerTextureFiltering(appId: Long?) {
val choice = appId?.let { SessionPrefs.gameTextureFiltering(this, it.toString()) }
drawerTextureAnisotropy = choice?.anisotropy
drawerTextureLodBias = choice?.lodBias
}
private fun refreshDrawerGameProfile() {
val refresh = DrawerGameProfileRefresh(
drawerGameProfileFollowState.followsSteam,
drawerGameProfileId,
drawerGameInventoryRevision,
)
if (!drawerGameProfileRefreshes.submit(refresh)) return
Thread({
var request = drawerGameProfileRefreshes.takeLatest()
while (request != null) {
val current = request
val result = resolveDrawerGameProfileRefresh(current.value)
uiHandler.post {
if (!drawerGameProfileRefreshes.isLatest(current)) return@post
applyDrawerGameProfileRefresh(current.value, result)
}
request = drawerGameProfileRefreshes.takeLatest()
}
}, "drawer-game-profile").start()
}
private fun resolveDrawerGameProfileRefresh(
refresh: DrawerGameProfileRefresh,
): DrawerGameProfileResult {
return try {
val games = drawerGameInventory.get(refresh.inventoryRevision) { Library.launchableGames(this) }
val options = games.mapNotNull { game ->
game.profileKey.toLongOrNull()?.let { it to game.name }
}.sortedBy { it.second.lowercase() }
val installedAppIds = options.mapTo(mutableSetOf()) { it.first }
val selectedAppId = if (refresh.followsSteam) {
val activeAppId = AgentGuest.activeSteamAppId(this, installedAppIds)
val selectedSteamAppId = runCatching { AgentGuest.selectedSteamAppId(this) }
AgentGuest.selectedOrActiveInstalledSteamAppId(selectedSteamAppId, activeAppId, installedAppIds)
?: refresh.requestedAppId
} else {
refresh.requestedAppId
}
val profile = selectedAppId?.let { resolveDrawerGameProfile(it, games) }
DrawerGameProfileResult(options, selectedAppId, profile)
} catch (error: Exception) {
DrawerGameProfileResult(emptyList(), refresh.requestedAppId, null, error)
}
}
private fun applyDrawerGameProfileRefresh(
refresh: DrawerGameProfileRefresh,
result: DrawerGameProfileResult,
) {
if (result.error != null) {
if (!drawerGameProfileRefresh.failed) {
Log.w(TAG, "Could not refresh the selected game profile", result.error)
}
drawerGameProfileRefresh = drawerGameProfileRefresh.failed()
return
}
drawerGameProfileRefresh = drawerGameProfileRefresh.succeeded(result)
if (refresh.followsSteam) {
if (drawerGameProfileFollowState.followsSteam) {
drawerGameProfileId = result.selectedAppId
readDrawerTextureFiltering(result.selectedAppId)
}
}
}
private fun resolveDrawerGameProfile(
selectedAppId: Long,
games: List<Library.SteamGame>,
): SelectedGameProfile? {
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
val snapshot = drawerComponents ?: ComponentsManager.snapshot(this)
val protons = snapshot.protons.map { it.proton }
val gameChoice = ProtonDefault.gameChoice(this, game.profileKey)
return SelectedGameProfile.resolve(
selectedAppId = selectedAppId,
games = games,
generalFexPreset = SessionPrefs.fexPreset(this),
environment = GameEnvironmentStore.read(this),
snapshot = snapshot,
generalProtonId = ProtonDefault.selectedId(this, protons),
gameProtonChoice = gameChoice,
gameProtonId = ProtonDefault.gameSelectedId(this, game.profileKey, protons),
gameComponents = ComponentsManager.gameComponents(this, game.profileKey),
gameWindowsComponents = WinComponents.picks(this, game.profileKey),
)
}
private fun swapDrawerComponent(protonId: String, comp: String, value: String) {
Thread({
val message = runCatching {
@@ -784,6 +946,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "drawer-components-swap").start()
}
private fun setDrawerGameComponent(appId: Long, comp: String, file: String?) {
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
Thread({
val message = runCatching {
ComponentsManager.setGameComponent(this, appId.toString(), comp, file)
getString(R.string.comp_game_saved_next, name)
}.getOrElse { e ->
getString(R.string.session_profile_save_failed, e.message ?: e.javaClass.simpleName)
}
uiHandler.post {
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
refreshDrawerComponents()
refreshDrawerGameProfile()
}
}, "drawer-game-component").start()
}
private fun setDrawerGameFexPreset(appId: Long, preset: String?) {
if (drawerGameFexPresetSaving) return
drawerGameFexPresetSaving = true
val name = drawerGameProfileRefresh.data?.profiles?.firstOrNull { it.first == appId }?.second ?: appId.toString()
Thread({
val message = try {
GameEnvironmentStore.setGameFexPreset(this, appId.toString(), preset)
getString(R.string.comp_game_saved_next, name)
} catch (error: Exception) {
Log.w(TAG, "Could not save the game FEX preset for $appId", error)
getString(R.string.session_profile_save_failed, error.message ?: error.javaClass.simpleName)
}
uiHandler.post {
drawerGameFexPresetSaving = false
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
refreshDrawerGameProfile()
}
}, "drawer-game-fex-preset").start()
}
/** The session this screen is bringing up: the live one when re-attached, else the intent's. */
private fun loadingMode(): String =
if (SessionState.running) SessionState.mode
@@ -85,6 +85,63 @@ object AgentGuest {
fun focus(context: Context): JSONObject? =
if (SessionState.running) readJson(File(dir(context), "focus.json")) else null
/** The Steam library game whose tile currently has controller or keyboard focus. */
fun selectedSteamAppId(context: Context): Long? {
val result = call(context, JSONObject().put("kind", "steam-selection"), 6_000)
if (!result.optBoolean("ok") || result.isNull("appId")) return null
return result.optLong("appId").takeIf { it in 1..4_294_967_295L }
}
/** The installed game Gamescope is presenting, including while Steam's overlay has focus. */
fun activeSteamAppId(context: Context, installedAppIds: Set<Long>): Long? {
val snapshot = focus(context) ?: return null
val focusedAppId = snapshot.optLong("focusedApp").takeIf { snapshot.has("focusedApp") }
val focusableAppIds = snapshot.optJSONArray("focusableApps")?.let { apps ->
buildList {
for (index in 0 until apps.length()) {
apps.optLong(index).takeIf { it > 0 }?.let(::add)
}
}
}.orEmpty()
val baselayerAppIds = snapshot.optJSONArray("baselayerAppIds")?.let { apps ->
buildList {
for (index in 0 until apps.length()) {
apps.optLong(index).takeIf { it > 0 }?.let(::add)
}
}
}.orEmpty()
return activeInstalledSteamAppId(focusedAppId, focusableAppIds, installedAppIds, baselayerAppIds)
}
internal fun focusedInstalledSteamAppId(focusedAppId: Long?, installedAppIds: Set<Long>): Long? =
focusedAppId?.takeIf { it in installedAppIds }
internal fun activeInstalledSteamAppId(
focusedAppId: Long?,
focusableAppIds: List<Long>,
installedAppIds: Set<Long>,
baselayerAppIds: List<Long> = emptyList(),
): Long? = focusedInstalledSteamAppId(focusedAppId, installedAppIds)
?: focusableAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
?: baselayerAppIds.filter { it in installedAppIds }.distinct().singleOrNull()
internal fun preferredInstalledSteamAppId(
selectedAppId: Long?,
activeAppId: Long?,
installedAppIds: Set<Long>,
): Long? = selectedAppId?.takeIf { it in installedAppIds }
?: activeAppId?.takeIf { it in installedAppIds }
internal fun selectedOrActiveInstalledSteamAppId(
selectedAppId: Result<Long?>,
activeAppId: Long?,
installedAppIds: Set<Long>,
): Long? {
preferredInstalledSteamAppId(selectedAppId.getOrNull(), activeAppId, installedAppIds)?.let { return it }
selectedAppId.exceptionOrNull()?.let { throw it }
return null
}
/** Send one request and wait for its answer. Throws [AgentException] with a stable code. */
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
@@ -30,4 +30,11 @@ object FexPreset {
/** Game-launch variables in KEY=VALUE form. */
fun env(id: String): List<String> = byId(id).env
val environmentKeys: Set<String> by lazy {
all.flatMap { it.env }.mapTo(linkedSetOf()) { it.substringBefore('=') }
}
fun environment(id: String): Map<String, String> =
env(id).associate { it.substringBefore('=') to it.substringAfter('=') }
}
@@ -46,4 +46,37 @@ object GameEnvironment {
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
fun hasOverride(config: Config, scope: String, name: String): Boolean =
scope.isNotEmpty() && config.entries(scope).containsKey(name)
fun withScopeValue(config: Config, scope: String, name: String, value: String?): Config {
require(scope.isEmpty() || validScope(scope))
val entries = config.entries(scope).toMutableMap()
if (value == null) entries.remove(name) else entries[name] = value
return config.withEntries(scope, entries)
}
fun gameFexPreset(config: Config, scope: String): String? {
require(scope.isNotEmpty() && validScope(scope))
val entries = config.games[scope].orEmpty()
if (FexPreset.environmentKeys.none(entries::containsKey)) return null
return FexPreset.all.firstOrNull { preset ->
val expected = FexPreset.environment(preset.id)
FexPreset.environmentKeys.all { key -> entries.containsKey(key) && entries[key] == expected[key] }
}?.id ?: CUSTOM_FEX_PRESET
}
fun withGameFexPreset(config: Config, scope: String, preset: String?): Config {
require(scope.isNotEmpty() && validScope(scope))
require(preset == null || FexPreset.all.any { it.id == preset })
val entries = config.entries(scope).filterKeys { it !in FexPreset.environmentKeys }.toMutableMap()
if (preset != null) {
val values = FexPreset.environment(preset)
FexPreset.environmentKeys.forEach { key -> entries[key] = values[key] }
}
return config.withEntries(scope, entries)
}
const val CUSTOM_FEX_PRESET = "__custom__"
}
@@ -31,6 +31,8 @@ object Library {
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
/** Key used by per-game environment and Windows-component profiles. */
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam, and the [SteamGame.source] of a plain added folder. */
const val ADDED = "added"
@@ -6,6 +6,7 @@ import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
@@ -79,7 +80,14 @@ object ComponentsManager {
private const val PLUGIN_DATA = "root/homebrew/data/decky-components-manager"
data class Proton(val id: String, val name: String, val dir: File, val guestPath: String, val version: String, val valve: Boolean)
data class Component(val detected: String, val detail: String, val inUse: String, val activeFile: String?, val queued: String?)
data class Component(
val detected: String,
val detail: String,
val inUse: String,
val selected: String,
val activeFile: String?,
val queued: String?,
)
data class Original(val comp: String, val protonVersion: String, val label: String, val size: Long)
data class Package(val file: String, val comp: String, val version: String, val description: String, val size: Long)
data class CatalogItem(val file: String, val comp: String, val release: String, val url: String, val size: Long, val digest: String)
@@ -129,23 +137,121 @@ object ComponentsManager {
// ------------------------------------------------------------------ state
private fun loadState(context: Context): JSONObject =
runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
private fun pendingStateFile(context: Context) = File(dataDir(context), "state.pending.json")
private fun loadState(context: Context): JSONObject {
recoverState(context)
return runCatching { JSONObject(stateFile(context).readText()) }.getOrElse { JSONObject() }.apply {
if (!has("active")) put("active", JSONObject())
if (!has("queued")) put("queued", JSONObject())
if (!has("profiles")) put("profiles", JSONObject())
}
}
private fun writeStateFile(file: File, text: String) {
val tmp = File(file.parentFile, file.name + ".tmp")
try {
writeSynced(tmp, text)
check(tmp.renameTo(file)) { "Could not save component selections: ${file.name}" }
} finally {
tmp.delete()
}
}
private fun writeSynced(file: File, text: String) {
FileOutputStream(file).use {
it.write(text.toByteArray(Charsets.UTF_8))
it.fd.sync()
}
}
private fun recoverState(context: Context) {
val pending = pendingStateFile(context)
if (!pending.exists()) return
val transaction = JSONObject(pending.readText())
val state = transaction.getJSONObject("state")
val expected = transaction.getString("launchSha256")
val published = File(launchDir(context), "desired.tsv")
val staged = File(launchDir(context), "desired.tsv.tmp")
// The launch-table rename is the commit point. Never read stale app selections after it.
if (!staged.exists() && published.isFile && Hashes.sha256(published) == expected) {
writeStateFile(stateFile(context), state.toString(2))
Log.i(TAG, "recovered published component selections")
} else {
Log.i(TAG, "discarded unpublished component selections")
}
check(pending.delete()) { "Could not finish component selection recovery" }
if (staged.exists()) check(staged.delete()) { "Could not remove unpublished component selections" }
}
private fun saveState(context: Context, state: JSONObject) {
val f = stateFile(context)
val tmp = File(f.parentFile, f.name + ".tmp")
tmp.writeText(state.toString(2))
tmp.renameTo(f)
// Every change of choice reaches the launch wrappers at once.
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
val prepared = prepareLaunchState(context, state)
val dir = launchDir(context)
val tmp = File(dir, "desired.tsv.tmp")
try {
writeSynced(tmp, prepared.text)
writeStateFile(pendingStateFile(context), JSONObject()
.put("state", state)
.put("launchSha256", Hashes.sha256(tmp))
.toString())
check(tmp.renameTo(File(dir, "desired.tsv"))) { "Could not publish component selections" }
writeStateFile(stateFile(context), state.toString(2))
check(pendingStateFile(context).delete()) { "Could not finish saving component selections" }
} finally {
if (!pendingStateFile(context).exists()) tmp.delete()
}
File(dir, "store").listFiles()?.filter { it.isDirectory && it.name !in prepared.keep }
?.forEach { it.deleteRecursively() }
}
private fun JSONObject.sub(key: String): JSONObject = optJSONObject(key) ?: JSONObject().also { put(key, it) }
// Keep launch-time restore rows after the last per-game selection is removed.
private fun rememberProfiledComponent(state: JSONObject, comp: String) {
val profiled = state.optJSONArray("profiledComponents")?.let { values ->
(0 until values.length()).mapNotNull { values.optString(it).takeIf(COMPONENTS::contains) }
}.orEmpty().toMutableSet()
if (profiled.add(comp)) state.put("profiledComponents", JSONArray(profiled.sorted()))
}
fun gameComponents(context: Context, scope: String): Map<String, String> = synchronized(lock) {
val profile = loadState(context).sub("profiles").optJSONObject(scope) ?: return emptyMap()
COMPONENTS.mapNotNull { comp -> profile.optString(comp).takeIf(String::isNotEmpty)?.let { comp to it } }.toMap()
}
fun setGameComponent(context: Context, scope: String, comp: String, file: String?) = synchronized(lock) {
require(scope.matches(Regex("[1-9][0-9]{0,9}")) && scope.toLong() <= 4294967295L)
require(comp in COMPONENTS)
val state = loadState(context)
val profiles = state.sub("profiles")
val profile = profiles.optJSONObject(scope) ?: JSONObject().also { profiles.put(scope, it) }
if (file == null) {
if (profile.optString(comp).isNotEmpty()) rememberProfiledComponent(state, comp)
profile.remove(comp)
if (profile.length() == 0) profiles.remove(scope)
} else {
val name = safeName(file)
val wcp = File(packagesDir(context), name)
require(packageInfo(context, wcp).comp == comp)
for (proton in protons(context)) ensureOriginal(context, proton, comp, state)
rememberProfiledComponent(state, comp)
profile.put(comp, name)
}
saveState(context, state)
}
fun clearGameComponents(context: Context, scope: String) = synchronized(lock) {
require(GameEnvironment.validScope(scope))
val state = loadState(context)
val profiles = state.sub("profiles")
profiles.optJSONObject(scope)?.let { profile ->
COMPONENTS.filter { profile.optString(it).isNotEmpty() }
.forEach { rememberProfiledComponent(state, it) }
}
profiles.remove(scope)
saveState(context, state)
}
/** Takes over the Decky plugin's packages, originals and swap records, once. */
private fun migratePlugin(context: Context) {
val marker = File(dataDir(context), ".plugin-imported")
@@ -220,24 +326,33 @@ object ComponentsManager {
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
*/
fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
fun inUse(context: Context, proton: Proton): Boolean =
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = false) }
/**
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
*/
fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
fun gameRunning(context: Context, proton: Proton): Boolean =
anyProcess(context, proton) { paths, text -> processUsesProton(paths, text, gameOnly = true) }
private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean {
val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath))
.map { it.trimEnd('/') + "/" }
internal fun processUsesProton(paths: List<String>, text: String, gameOnly: Boolean): Boolean =
paths.any { path ->
val needle = path.trimEnd('/') + "/"
if (gameOnly) text.contains(needle + "proton waitforexitandrun")
else text.contains(needle + "proton ") || text.contains(needle + "files/bin")
}
private fun anyProcess(context: Context, proton: Proton, match: (List<String>, String) -> Boolean): Boolean {
val guestPaths = listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath)
val paths = (guestPaths + guestPaths.map { host(context, it).absolutePath } + proton.dir.absolutePath).distinct()
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
val text = String(cmd).replace('\u0000', ' ')
if (needles.any { match(it, text) }) return true
if (match(paths, text)) return true
}
return false
}
@@ -261,7 +376,7 @@ object ComponentsManager {
private fun compFiles(dir: File, comp: String): List<String> {
if (comp == "fex") return FEX_FILES.filter { File(dir, it).isFile }
val base = File(dir, COMP_DIR.getValue(comp))
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).path }.sorted().toList()
return base.walkTopDown().filter { it.isFile }.map { it.relativeTo(dir).invariantSeparatorsPath }.sorted().toList()
}
private fun owned(comp: String, rel: String) = comp == "fex" || PE_ARCHES.any { "/$it/" in rel }
@@ -352,7 +467,7 @@ object ComponentsManager {
tar.closeArchiveEntry()
}
}
tmp.renameTo(dest)
check(tmp.renameTo(dest)) { "Could not save original bundle ${dest.name}" }
}
// ------------------------------------------------------------------ originals
@@ -380,8 +495,18 @@ object ComponentsManager {
if (dest.isFile) return
val active = state.sub("active").optJSONObject(p.id)?.optJSONObject(comp)
if (active != null && active.optString("protonVersion") == p.version) return
val published = File(launchDir(context), "desired.tsv")
if (published.isFile) {
val mayHaveOverride = published.useLines { rows ->
rows.any { row ->
val fields = row.split('\t')
fields.size == 7 && fields[0] != "*" && fields[1] == p.dir.name &&
fields[2] == p.version && fields[3] == comp
}
}
check(!mayHaveOverride) { context.getString(R.string.cmgr_original_missing) }
}
val rels = compFiles(p.dir, comp)
if (rels.isEmpty()) return
val current = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
val profile = JSONObject()
.put("type", TYPE_OF.getValue(comp))
@@ -400,7 +525,7 @@ object ComponentsManager {
for (p in protons(context)) for (comp in COMPONENTS) {
runCatching { ensureOriginal(context, p, comp, state) }.onFailure { Log.w(TAG, "original $comp of ${p.name}", it) }
}
runCatching { syncLaunchState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
runCatching { saveState(context, state) }.onFailure { Log.w(TAG, "launch state", it) }
}
// ------------------------------------------------------------------ view
@@ -431,9 +556,14 @@ object ComponentsManager {
else context.getString(R.string.cmgr_changed_outside)
}
}
val selectedLabel = when {
active == null || active.optString("protonVersion") != p.version ->
context.getString(R.string.comp_tag_original)
else -> originalLabel(context, active) ?: active.optString("label", active.optString("file"))
}
val q = state.sub("queued").optJSONObject(p.id)?.optJSONObject(comp)
Component(
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel,
detected.ifEmpty { context.getString(R.string.cmgr_not_present) }, detail, inUseLabel, selectedLabel,
active?.optString("file")?.takeIf { active.optString("protonVersion") == p.version },
q?.let { originalLabel(context, it) ?: it.optString("label") },
)
@@ -455,64 +585,111 @@ object ComponentsManager {
// ------------------------------------------------------------------ launch-time enforcement
private data class PreparedLaunchState(val text: String, val keep: Set<String>)
/**
* Writes what each Proton should be using for the launch wrappers: desired.tsv, one line per
* file (tool dir, Proton build, component, file, sha256, source copy), and an unpacked copy of
* every package or older-build original in use to copy from. Rebuilt whole after every change, so
* a restored or deleted choice simply stops being enforced. The Proton's own current-build
* originals are not listed: the wrappers also give Steam's probe runs their own prefix, which is
* what overwrote files in the first place.
* Stages what each Proton should use for the launch wrappers. General rows use scope `*`;
* per-game rows use the game's compatdata id and replace General for that component. Deletion
* rows remove managed DLLs absent from the chosen package, so switching games cannot leave files
* from the previous game's component set behind.
*/
private fun syncLaunchState(context: Context, state: JSONObject) {
private fun prepareLaunchState(context: Context, state: JSONObject): PreparedLaunchState {
val dir = launchDir(context)
val store = File(dir, "store").apply { mkdirs() }
val all = protons(context)
val lines = StringBuilder()
val keep = HashSet<String>()
val active = state.sub("active")
for (pid in active.keys()) {
val p = all.firstOrNull { it.id == pid } ?: continue
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) {
val a = comps.optJSONObject(comp) ?: continue
if (a.optString("protonVersion") != p.version) continue
val file = a.optString("file")
val (key, wcp) = if (file.startsWith("original ")) {
val v = file.removePrefix("original ")
"original-$pid-${safeName(v)}-$comp" to File(originalsDir(context), "$pid/${safeName(v)}/$comp.wcp")
} else safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
if (!wcp.isFile) continue
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
unpacked.deleteRecursively()
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
fun unpack(key: String, wcp: File, comp: String): File {
check(wcp.isFile) { context.getString(R.string.cmgr_not_stored) }
val unpacked = File(store, key)
if (!File(unpacked, ".complete").isFile) {
check(packageInfo(context, wcp).comp == comp) { "Wrong component in ${wcp.name}" }
unpacked.deleteRecursively()
check(unpacked.mkdirs()) { "Could not stage component bundle ${wcp.name}" }
readWcp(wcp) { tar ->
while (true) {
val e = tar.nextTarEntry ?: break
val rel = normalize(e.name)
if (e.isDirectory || !rel.startsWith("files/") || rel.split('/').any { it == ".." }) continue
val out = File(unpacked, rel)
out.parentFile?.mkdirs()
FileOutputStream(out).use { tar.copyTo(it) }
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
val files = a.optJSONObject("files") ?: continue
for (rel in files.keys()) {
val src = File(unpacked, rel)
if (!src.isFile) continue
val guestSrc = "/" + src.absolutePath.removePrefix(root(context).absolutePath.trimEnd('/')).trimStart('/')
lines.append(p.dir.name).append('\t').append(p.version).append('\t').append(comp).append('\t')
.append(rel).append('\t').append(files.optString(rel)).append('\t').append(guestSrc).append('\n')
}
File(unpacked, ".complete").writeText("1\n")
}
keep += key
return unpacked
}
fun managed(comp: String): Set<String> = if (comp == "fex") FEX_FILES.toSet() else buildSet {
for (arch in PE_ARCHES) for (dll in COMP_DLLS.getValue(comp)) add("${COMP_DIR.getValue(comp)}/$arch/$dll")
}
fun append(scope: String, p: Proton, comp: String, key: String, wcp: File) {
val unpacked = unpack(key, wcp, comp)
val files = unpacked.let { base ->
File(base, "files").walkTopDown().filter { it.isFile }.map { it.relativeTo(base).invariantSeparatorsPath }
.filter { owned(comp, it) }.associateWith { File(base, it) }
}
for (rel in managed(comp) - files.keys) {
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
.append('\t').append(rel).append("\t-\t-\n")
}
for ((rel, src) in files) {
val guestSrc = "/" + src.relativeTo(root(context)).invariantSeparatorsPath
lines.append(scope).append('\t').append(p.dir.name).append('\t').append(p.version).append('\t').append(comp)
.append('\t').append(rel).append('\t').append(Hashes.sha256(src)).append('\t').append(guestSrc).append('\n')
}
}
store.listFiles()?.filter { it.isDirectory && it.name !in keep }?.forEach { it.deleteRecursively() }
val list = File(dir, "desired.tsv")
val tmp = File(dir, "desired.tsv.tmp")
tmp.writeText(lines.toString())
tmp.renameTo(list)
fun choice(p: Proton, comp: String, entry: JSONObject): Pair<String, File>? {
val file = entry.optString("file")
return if (file.startsWith("original ")) {
val version = safeName(file.removePrefix("original "))
"original-${p.id}-$version-$comp" to File(originalsDir(context), "${p.id}/$version/$comp.wcp")
} else {
safeName(file.removeSuffix(".wcp")) to File(packagesDir(context), file)
}.takeIf { it.second.isFile }
}
val profiles = state.sub("profiles")
val profiledComponents = buildSet {
state.optJSONArray("profiledComponents")?.let { stored ->
for (index in 0 until stored.length()) {
stored.optString(index).takeIf(COMPONENTS::contains)?.let(::add)
}
}
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
COMPONENTS.filterTo(this) { profile.optString(it).isNotEmpty() }
}
}
val active = state.sub("active")
for (p in all) for (comp in COMPONENTS) {
val entry = active.optJSONObject(p.id)?.optJSONObject(comp)
val selected = entry?.takeIf { it.optString("protonVersion") == p.version }?.let { choice(p, comp, it) }
if (selected != null) append("*", p, comp, selected.first, selected.second)
else if (comp in profiledComponents) {
ensureOriginal(context, p, comp, state)
val original = originalFile(context, p, comp)
check(original.isFile) { context.getString(R.string.cmgr_original_missing) }
append("*", p, comp, "original-${p.id}-${safeName(p.version)}-$comp", original)
}
}
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
for (comp in COMPONENTS) {
val file = profile.optString(comp)
if (file.isEmpty()) continue
val wcp = File(packagesDir(context), file)
if (!wcp.isFile) continue
val key = safeName(file.removeSuffix(".wcp"))
for (p in all) append(scope, p, comp, key, wcp)
}
}
return PreparedLaunchState(lines.toString(), keep)
}
// ------------------------------------------------------------------ actions
@@ -615,6 +792,11 @@ object ComponentsManager {
val comps = active.optJSONObject(pid) ?: continue
for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
}
val profiles = state.sub("profiles")
for (scope in profiles.keys()) {
val profile = profiles.optJSONObject(scope) ?: continue
check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
}
val f = File(packagesDir(context), name)
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
f.delete()
@@ -13,6 +13,7 @@ object GameEnvironmentStore {
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
/** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */
const val DXVK_CONFIG = "dxvkConfig"
const val DXVK_CONFIG_GAMES = "dxvkConfigGames"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
@@ -57,6 +58,13 @@ object GameEnvironmentStore {
publish(context, config)
}
@Synchronized
fun setGameFexPreset(context: Context, scope: String, preset: String?): GameEnvironment.Config {
val config = GameEnvironment.withGameFexPreset(read(context), scope, preset)
save(context, config)
return config
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
@@ -68,6 +76,21 @@ object GameEnvironmentStore {
TextureFiltering.autoLodBias(SessionState.upscaleRatio),
)
if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk)
val autoBias = TextureFiltering.autoLodBias(SessionState.upscaleRatio)
val generalAnisotropy = SessionPrefs.textureAnisotropy(context)
val generalLodBias = SessionPrefs.textureLodBias(context)
val gameDxvk = JSONObject()
SessionPrefs.gameTextureFiltering(context).forEach { (scope, choice) ->
gameDxvk.put(
scope,
TextureFiltering.dxvkOptions(
choice.anisotropy ?: generalAnisotropy,
choice.lodBias ?: generalLodBias,
autoBias,
),
)
}
if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
}
@@ -0,0 +1,14 @@
package com.droiddeck.launcher.session
internal class GameProfileFollowState {
var followsSteam = true
private set
fun selectManually() {
followsSteam = false
}
fun resumeFollowingSteam() {
followsSteam = true
}
}
@@ -0,0 +1,16 @@
package com.droiddeck.launcher.session
internal class GameProfileInventoryCache<T : Any> {
private val lock = Any()
private var revision: Long? = null
private var value: List<T>? = null
// Called only by the refresh worker; the UI invalidates through request revisions.
fun get(requestedRevision: Long, load: () -> List<T>): List<T> = synchronized(lock) {
value?.takeIf { revision == requestedRevision } ?: load().toList().also {
value = it
revision = requestedRevision
}
}
}
@@ -0,0 +1,31 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.core.GameEnvironment
object GameProfileManager {
@Synchronized
fun reset(context: Context, scope: String) {
require(scope.isNotEmpty() && GameEnvironment.validScope(scope))
val failures = mutableListOf<Pair<String, Throwable>>()
fun clear(name: String, action: () -> Unit) {
runCatching(action).onFailure { failures += name to it }
}
clear("environment") {
GameEnvironmentStore.save(
context,
GameEnvironmentStore.read(context).withEntries(scope, emptyMap()),
)
}
clear("Proton") { ProtonDefault.requestGame(context, scope, null) }
clear("component packages") { ComponentsManager.clearGameComponents(context, scope) }
clear("texture filtering") { SessionPrefs.clearGameTextureFiltering(context, scope) }
clear("Windows components") { WinComponents.setPicks(context, scope, emptyList()) }
if (failures.isNotEmpty()) {
val names = failures.joinToString { it.first }
throw IllegalStateException("Could not clear: $names", failures.first().second)
}
}
}
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.session
internal data class GameProfileRefreshState<T>(
val data: T? = null,
val loaded: Boolean = false,
val failed: Boolean = false,
) {
fun succeeded(data: T) = GameProfileRefreshState(data, loaded = true)
fun failed() = copy(failed = true)
fun retrying() = copy(failed = false)
}
@@ -0,0 +1,33 @@
package com.droiddeck.launcher.session
internal class LatestRequestCoordinator<T> {
data class Request<T>(val generation: Long, val value: T)
private val lock = Any()
private var generation = 0L
private var running = false
private var latest: Request<T>? = null
private var pending: Request<T>? = null
fun submit(value: T): Boolean = synchronized(lock) {
if (latest?.value != value) generation++
pending = Request(generation, value).also { latest = it }
if (running) {
false
} else {
running = true
true
}
}
fun takeLatest(): Request<T>? = synchronized(lock) {
pending.also {
pending = null
if (it == null) running = false
}
}
fun isLatest(request: Request<T>): Boolean = synchronized(lock) {
request.generation == latest?.generation
}
}
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
@@ -9,10 +10,12 @@ import java.io.File
object ProtonDefault {
private const val TAG = "ProtonDefault"
private const val DIR = "root/.local/share/droiddeck-compat"
private const val GAMES = "games.json"
data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean)
data class Request(val seq: Long, val valve: Boolean, val dir: String)
data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>)
data class GameChoice(val valve: Boolean, val dir: String)
enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE }
@@ -60,6 +63,33 @@ object ProtonDefault {
return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id
}
internal fun parseGames(text: String?): Map<String, GameChoice?> {
val root = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return emptyMap()
if (root.optInt("version", 0) != 1) return emptyMap()
val games = root.optJSONObject("games") ?: return emptyMap()
return buildMap {
games.keys().forEach { id ->
if (!GameEnvironment.validScope(id)) return@forEach
if (games.isNull(id)) {
put(id, null)
} else {
val value = games.optJSONObject(id) ?: return@forEach
val gameDir = value.optString("dir")
val valve = value.opt("valve")
if (gameDir.isNotEmpty() && valve is Boolean) put(id, GameChoice(valve, gameDir))
}
}
}
}
fun gameChoice(context: Context, scope: String): GameChoice? =
parseGames(read(File(dir(context), GAMES)))[scope]
fun gameSelectedId(context: Context, scope: String, protons: List<ComponentsManager.Proton>): String? {
val pick = gameChoice(context, scope) ?: return null
return protons.firstOrNull { it.valve == pick.valve && it.dir.name == pick.dir }?.id
}
internal fun nextSeq(now: Long, state: State, previous: Request?): Long =
maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1)
@@ -83,4 +113,33 @@ object ProtonDefault {
val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name }
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
}
@Synchronized
fun requestGame(context: Context, scope: String, proton: ComponentsManager.Proton?): Outcome {
require(GameEnvironment.validScope(scope))
val d = dir(context)
val choices = parseGames(read(File(d, GAMES))).toMutableMap()
choices[scope] = proton?.let { GameChoice(it.valve, it.dir.name) }
val games = JSONObject()
choices.forEach { (id, choice) ->
games.put(id, choice?.let { JSONObject().put("valve", it.valve).put("dir", it.dir) } ?: JSONObject.NULL)
}
val staged = File(d, "$GAMES.staged")
try {
d.mkdirs()
staged.writeText(JSONObject().put("version", 1).put("games", games).toString(1) + "\n")
if (!staged.renameTo(File(d, GAMES))) throw java.io.IOException("rename failed")
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write the per-game Proton request", e)
throw e
}
val state = state(context)
val tool = proton?.let { selected -> state.tools.firstOrNull { it.valve == selected.valve && it.dir == selected.dir.name } }
return if (SessionState.running && state.live && (proton == null || tool != null && state.pending != tool.name)) {
Outcome.LIVE
} else {
Outcome.NEXT_START
}
}
}
@@ -0,0 +1,70 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.frontend.Library
data class ProfileValue(val value: String, val inherited: Boolean)
data class SelectedGameProfile(
val appId: Long,
val name: String,
val fexPreset: ProfileValue,
val proton: ProfileValue,
val protonId: String?,
val components: Map<String, ProfileValue>,
val componentFiles: Map<String, String>,
val environmentOverrides: Int,
val windowsComponentNames: List<String>,
) {
val hasOverrides: Boolean
get() = !fexPreset.inherited ||
!proton.inherited ||
components.values.any { !it.inherited } ||
environmentOverrides > 0 ||
windowsComponentNames.isNotEmpty()
companion object {
fun resolve(
selectedAppId: Long,
games: List<Library.SteamGame>,
generalFexPreset: String,
environment: GameEnvironment.Config,
snapshot: ComponentsManager.Snapshot,
generalProtonId: String?,
gameProtonChoice: ProtonDefault.GameChoice?,
gameProtonId: String?,
gameComponents: Map<String, String>,
gameWindowsComponents: List<String>,
): SelectedGameProfile? {
val game = games.firstOrNull { it.profileKey == selectedAppId.toString() } ?: return null
val fexOverride = GameEnvironment.gameFexPreset(environment, game.profileKey)
val selectedProtonId = if (gameProtonChoice == null) generalProtonId else gameProtonId
val selectedProton = snapshot.protons.firstOrNull { it.proton.id == selectedProtonId }
val packages = snapshot.packages.associateBy { it.file }
val components = ComponentsManager.COMPONENTS.associateWith { component ->
val override = gameComponents[component]
ProfileValue(
value = override?.let { packages[it]?.version ?: it }
?: selectedProton?.components?.get(component)?.selected.orEmpty(),
inherited = override == null,
)
}
val otherEnvironment = environment.games[game.profileKey].orEmpty().keys - FexPreset.environmentKeys
return SelectedGameProfile(
appId = selectedAppId,
name = game.name,
fexPreset = ProfileValue(fexOverride ?: generalFexPreset, fexOverride == null),
proton = ProfileValue(
selectedProton?.proton?.name ?: gameProtonChoice?.dir.orEmpty(),
gameProtonChoice == null,
),
protonId = selectedProtonId,
components = components,
componentFiles = gameComponents,
environmentOverrides = otherEnvironment.size,
windowsComponentNames = gameWindowsComponents.map(WinComponentNames::of),
)
}
}
}
@@ -4,12 +4,15 @@ import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.ScreenEffects
import org.json.JSONObject
/** The in-session switches: the HUD and how the on-screen controls decide to appear. */
object SessionPrefs {
data class GameTextureFiltering(val anisotropy: Int? = null, val lodBias: String? = null)
const val SUSPEND_AUTO = "auto"
const val SUSPEND_NATIVE = "native"
const val SUSPEND_MANUAL = "manual"
@@ -868,6 +871,74 @@ object SessionPrefs {
publishGameEnvironment(context)
}
fun gameTextureFiltering(context: Context, scope: String): GameTextureFiltering {
require(GameEnvironment.validScope(scope))
return gameTextureFiltering(context)[scope] ?: GameTextureFiltering()
}
fun gameTextureFiltering(context: Context): Map<String, GameTextureFiltering> {
val json = runCatching {
JSONObject(prefs(context).getString("gameTextureFiltering", null) ?: "{}")
}.getOrElse { JSONObject() }
return buildMap {
json.keys().forEach { scope ->
if (!GameEnvironment.validScope(scope)) return@forEach
val entry = json.optJSONObject(scope) ?: return@forEach
val anisotropy = entry.optInt("anisotropy").takeIf {
entry.has("anisotropy") && it in TextureFiltering.ANISOTROPY
}
val lodBias = entry.optString("lodBias").takeIf {
entry.has("lodBias") && it in TextureFiltering.LOD_BIAS
}
if (anisotropy != null || lodBias != null) {
put(scope, GameTextureFiltering(anisotropy, lodBias))
}
}
}
}
fun setGameTextureAnisotropy(context: Context, scope: String, value: Int?) {
require(value == null || value in TextureFiltering.ANISOTROPY)
updateGameTextureFiltering(context, scope) { it.copy(anisotropy = value) }
}
fun setGameTextureLodBias(context: Context, scope: String, choice: String?) {
require(choice == null || choice in TextureFiltering.LOD_BIAS)
updateGameTextureFiltering(context, scope) { it.copy(lodBias = choice) }
}
fun clearGameTextureFiltering(context: Context, scope: String) {
require(GameEnvironment.validScope(scope))
val choices = gameTextureFiltering(context).toMutableMap()
if (choices.remove(scope) == null) return
saveGameTextureFiltering(context, choices)
publishGameEnvironment(context)
}
private fun updateGameTextureFiltering(
context: Context,
scope: String,
change: (GameTextureFiltering) -> GameTextureFiltering,
) {
require(GameEnvironment.validScope(scope))
val choices = gameTextureFiltering(context).toMutableMap()
val next = change(choices[scope] ?: GameTextureFiltering())
if (next.anisotropy == null && next.lodBias == null) choices.remove(scope) else choices[scope] = next
saveGameTextureFiltering(context, choices)
publishGameEnvironment(context)
}
private fun saveGameTextureFiltering(context: Context, choices: Map<String, GameTextureFiltering>) {
val json = JSONObject()
choices.forEach { (id, choice) ->
json.put(id, JSONObject().apply {
choice.anisotropy?.let { put("anisotropy", it) }
choice.lodBias?.let { put("lodBias", it) }
})
}
prefs(context).edit().putString("gameTextureFiltering", json.toString()).apply()
}
/** Hands the change to the next game launch (GameEnvironmentStore); the running game keeps its own. */
private fun publishGameEnvironment(context: Context) {
runCatching { GameEnvironmentStore.publish(context) }
@@ -37,6 +37,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -80,6 +81,19 @@ import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ComponentsManager.CatalogItem
import com.droiddeck.launcher.session.ComponentsManager.Snapshot
import kotlinx.coroutines.delay
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.rememberCoroutineScope
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.GameProfileManager
import com.droiddeck.launcher.session.ProtonDefault
import com.droiddeck.launcher.session.WinComponents
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
@@ -108,6 +122,13 @@ private class InstalledItem(
val protonVersion: String?,
)
private data class ProfileEditorState(
val environment: GameEnvironment.Config,
val protonChoice: ProtonDefault.GameChoice?,
val protonId: String?,
val components: Map<String, String>,
)
private val GOLD = Color(0xFFF2C66D)
private val QUEUED = Color(0xFFFFB86B)
private val ROW_SHAPE = RoundedCornerShape(10.dp)
@@ -142,6 +163,9 @@ fun ComponentsPage(
onRefresh: () -> Unit,
onImport: () -> Unit,
onBack: () -> Unit,
games: List<Library.SteamGame> = emptyList(),
generalFexPreset: String = "",
onGeneralFexPreset: (String) -> Unit = {},
requestInitialFocus: Boolean = true,
/** The GPU drivers layer ([GPU_TAB]): what it shows and does. */
gpu: GpuDriversState = GpuDriversState(),
@@ -160,6 +184,209 @@ fun ComponentsPage(
val inputMode = LocalInputModeManager.current
val layer = if (comp in LAYERS) comp else GPU_TAB
BackHandler(onBack = onBack)
var scopeMenu by remember { mutableStateOf(false) }
var selectedGameKey by remember { mutableStateOf<String?>(null) }
var fexMenu by remember { mutableStateOf(false) }
var gameProtonMenu by remember { mutableStateOf(false) }
var gameProtonId by remember { mutableStateOf<String?>(null) }
var gameProtonChoice by remember { mutableStateOf<ProtonDefault.GameChoice?>(null) }
var gameComponents by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var gameComponentMenu by remember { mutableStateOf<String?>(null) }
var environmentEditor by remember { mutableStateOf(false) }
var winComponentsOpen by remember { mutableStateOf<Boolean?>(null) }
var winComponentsRevision by remember { mutableStateOf(0) }
var environment by remember { mutableStateOf<GameEnvironment.Config?>(null) }
var profileLoading by remember { mutableStateOf(false) }
var profileLoadGeneration by remember { mutableStateOf(0L) }
var environmentBusy by remember { mutableStateOf(false) }
var environmentError by remember { mutableStateOf(false) }
var profileNotice by remember { mutableStateOf<String?>(null) }
var confirm by remember { mutableStateOf<Pair<String, () -> Unit>?>(null) }
var confirmTitle by remember { mutableStateOf("") }
var confirmVerb by remember { mutableStateOf(R.string.comp_swap) }
fun ask(title: String, body: String, verb: Int, action: () -> Unit) {
confirmTitle = title
confirmVerb = verb
confirm = body to action
}
val coroutine = rememberCoroutineScope()
val selectedGame = games.firstOrNull { it.profileKey == selectedGameKey }
LaunchedEffect(Unit) {
runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.read(ctx) } }
.onSuccess { environment = it }
.onFailure { environmentError = true }
}
LaunchedEffect(games, selectedGameKey) {
if (selectedGameKey != null && games.none { it.profileKey == selectedGameKey }) selectedGameKey = null
}
LaunchedEffect(selectedGame?.profileKey, snapshot) {
profileLoadGeneration++
val generation = profileLoadGeneration
val game = selectedGame
profileNotice = null
environmentError = false
gameProtonChoice = null
gameProtonId = null
gameComponents = emptyMap()
gameProtonMenu = false
gameComponentMenu = null
fexMenu = false
if (game == null) {
profileLoading = false
return@LaunchedEffect
}
profileLoading = true
try {
val (choice, id, components) = withContext(Dispatchers.IO) {
val choice = ProtonDefault.gameChoice(ctx, game.profileKey)
Triple(
choice,
ProtonDefault.gameSelectedId(ctx, game.profileKey, snapshot?.protons.orEmpty().map { it.proton }),
ComponentsManager.gameComponents(ctx, game.profileKey),
)
}
if (profileLoadGeneration == generation && selectedGameKey == game.profileKey) {
gameProtonChoice = choice
gameProtonId = id
gameComponents = components
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
if (profileLoadGeneration == generation && selectedGameKey == game.profileKey) {
environmentError = true
profileNotice = ctx.getString(
R.string.comp_game_load_failed,
game.name,
error.message ?: error.javaClass.simpleName,
)
}
} finally {
if (profileLoadGeneration == generation) profileLoading = false
}
}
fun setGameFexPreset(preset: String?) {
val game = selectedGame ?: return
environmentBusy = true
profileNotice = null
coroutine.launch {
runCatching {
withContext(Dispatchers.IO) {
GameEnvironmentStore.setGameFexPreset(ctx, game.profileKey, preset)
}
}.onSuccess { saved ->
environment = saved
if (selectedGameKey == game.profileKey) {
environmentError = false
profileNotice = ctx.getString(R.string.comp_game_saved_next, game.name)
}
}.onFailure {
if (selectedGameKey == game.profileKey) {
environmentError = true
profileNotice = ctx.getString(R.string.comp_game_save_failed, it.message ?: it.javaClass.simpleName)
}
}
environmentBusy = false
}
}
fun setGameProton(id: String?) {
val game = selectedGame ?: return
val proton = id?.let { wanted -> snapshot?.protons?.firstOrNull { it.proton.id == wanted }?.proton }
environmentBusy = true
profileNotice = null
coroutine.launch {
runCatching { withContext(Dispatchers.IO) { ProtonDefault.requestGame(ctx, game.profileKey, proton) } }
.onSuccess { outcome ->
if (selectedGameKey == game.profileKey) {
gameProtonChoice = proton?.let { ProtonDefault.GameChoice(it.valve, it.dir.name) }
gameProtonId = id
environmentError = false
profileNotice = ctx.getString(
when (outcome) {
ProtonDefault.Outcome.LIVE -> R.string.comp_game_proton_live
ProtonDefault.Outcome.NEXT_START -> R.string.comp_game_proton_next
ProtonDefault.Outcome.NOT_RUNNABLE -> R.string.comp_game_proton_not_runnable
},
game.name,
)
}
}
.onFailure {
if (selectedGameKey == game.profileKey) {
environmentError = true
profileNotice = ctx.getString(R.string.comp_game_save_failed, it.message ?: it.javaClass.simpleName)
}
}
environmentBusy = false
}
}
fun setGameComponent(comp: String, file: String?) {
val game = selectedGame ?: return
environmentBusy = true
profileNotice = null
coroutine.launch {
runCatching { withContext(Dispatchers.IO) { ComponentsManager.setGameComponent(ctx, game.profileKey, comp, file) } }
.onSuccess {
val components = ComponentsManager.gameComponents(ctx, game.profileKey)
if (selectedGameKey == game.profileKey) {
gameComponents = components
environmentError = false
profileNotice = ctx.getString(R.string.comp_game_saved_next, game.name)
}
}
.onFailure {
if (selectedGameKey == game.profileKey) {
environmentError = true
profileNotice = ctx.getString(R.string.comp_game_save_failed, it.message ?: it.javaClass.simpleName)
}
}
environmentBusy = false
}
}
fun resetGameProfile() {
val game = selectedGame ?: return
environmentBusy = true
profileNotice = null
coroutine.launch {
runCatching {
withContext(Dispatchers.IO) {
val resetFailure = runCatching { GameProfileManager.reset(ctx, game.profileKey) }.exceptionOrNull()
val config = GameEnvironmentStore.read(ctx)
val choice = ProtonDefault.gameChoice(ctx, game.profileKey)
val id = ProtonDefault.gameSelectedId(
ctx,
game.profileKey,
snapshot?.protons.orEmpty().map { it.proton },
)
val components = ComponentsManager.gameComponents(ctx, game.profileKey)
ProfileEditorState(config, choice, id, components) to resetFailure
}
}.onSuccess { (state, resetFailure) ->
environment = state.environment
if (selectedGameKey == game.profileKey) {
gameProtonChoice = state.protonChoice
gameProtonId = state.protonId
gameComponents = state.components
winComponentsRevision++
environmentError = resetFailure != null
profileNotice = if (resetFailure == null) {
ctx.getString(R.string.comp_game_reset_done, game.name)
} else {
ctx.getString(
R.string.comp_game_reset_failed,
resetFailure.message ?: resetFailure.javaClass.simpleName,
)
}
}
}.onFailure {
if (selectedGameKey == game.profileKey) {
environmentError = true
profileNotice = ctx.getString(R.string.comp_game_reset_failed, it.message ?: it.javaClass.simpleName)
}
}
environmentBusy = false
}
}
val views = snapshot?.protons ?: emptyList()
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
@@ -247,7 +474,7 @@ fun ComponentsPage(
val layerFocus = remember { LAYERS.associateWith { FocusRequester() } }
// Arrived (from the rail, a hop, a game's card): a pad starts on the chosen layer.
LaunchedEffect(requestInitialFocus) {
if (!requestInitialFocus || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect
if (!requestInitialFocus || selectedGame != null || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect
delay(Motion.ms(200).toLong())
focusSoon { layerFocus.getValue(layer) }
}
@@ -259,7 +486,7 @@ fun ComponentsPage(
LaunchedEffect(layer) {
if (seenLayer[0] == layer) return@LaunchedEffect
seenLayer[0] = layer
if (!pageFocused || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect
if (selectedGame != null || !pageFocused || inputMode.inputMode != InputMode.Keyboard) return@LaunchedEffect
if (narrowOpen != null) runCatching { firstRow.requestFocus() }
else focusSoon { layerFocus.getValue(layer) }
}
@@ -534,6 +761,21 @@ fun ComponentsPage(
Column(Modifier.weight(1f)) { slide { k -> RowGroup(availableTitle(k)) { availableRows(k) } } }
}
Box(Modifier.padding(top = 14.dp)) { slide { k -> actions(k) } }
if (l == "fex") {
SettingsGroup(stringResource(R.string.fex_preset_title)) {
SettingsRow(stringResource(R.string.fex_preset_title), null) {
FexPresetChip(
preset = generalFexPreset,
inherited = false,
open = fexMenu,
enabled = true,
onToggle = { fexMenu = !fexMenu },
onDismiss = { fexMenu = false },
onPick = { it?.let(onGeneralFexPreset) },
)
}
}
}
if (l == PROTON_TAB) {
Spacer(Modifier.height(10.dp))
if (!protons.runtimeReady) EmptyLine(stringResource(R.string.content_needs_runtime))
@@ -550,7 +792,10 @@ fun ComponentsPage(
Box(Modifier.fillMaxSize().onGloballyPositioned { pageAt = it.positionInRoot() }.onFocusChanged { pageFocused = it.hasFocus }) {
Column(
Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)
.bumpers(onPrevious = { pick(layerStep(layer, -1)) }, onNext = { pick(layerStep(layer, 1)) }),
.then(if (selectedGame == null) Modifier.bumpers(
onPrevious = { pick(layerStep(layer, -1)) },
onNext = { pick(layerStep(layer, 1)) },
) else Modifier),
) {
// ---- header: the title, the device, when the lists were checked and the one refresh ----
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
@@ -569,29 +814,109 @@ fun ComponentsPage(
}
if (narrow) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 6.dp)) { DeviceChip(gpu) }
Spacer(Modifier.height(14.dp))
Row(Modifier.weight(1f).fillMaxWidth()) {
// ---- the stack ----
Box(
(if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight()
.onGloballyPositioned { stackAt = it.positionInRoot() },
) {
val tint = rememberUpdatedState(pal.signal)
val local = cardsAt[layer]?.translate(-stackAt)
ChipGlide(LAYERS.indexOf(layer), local, tint, GlideAxis.Vertical, corner = 12.dp)
Stack(
layer, view, gpu, focus = layerFocus, landed = landed, rings = rings,
onPlaced = { l, r -> cardsAt[l] = r },
onFocus = { l -> pick(l) },
onOpen = { l ->
pick(l)
if (narrow) narrowOpen = l
else runCatching { firstRow.requestFocus() }
if (games.isNotEmpty()) {
SettingsGroup(stringResource(R.string.comp_profile_configure)) {
SettingsRow(
stringResource(R.string.comp_profile),
if (selectedGame == null) {
stringResource(R.string.comp_scope_general_detail)
} else {
stringResource(R.string.comp_profile_game_detail, selectedGame.name)
},
)
) {
Box {
ValueChip(
selectedGame?.name ?: stringResource(R.string.comp_scope_general),
scopeMenu,
modifier = Modifier.widthIn(max = 320.dp).heightIn(min = 44.dp),
) { scopeMenu = !scopeMenu }
AnchoredMenu(
scopeMenu,
onDismiss = { scopeMenu = false },
title = stringResource(R.string.comp_profile_configure),
) { first ->
MenuItem(
stringResource(R.string.comp_scope_general),
checked = selectedGame == null,
detail = stringResource(R.string.comp_scope_general_detail),
focusRequester = first,
) {
selectedGameKey = null
scopeMenu = false
}
games.sortedBy { it.name.lowercase() }.forEach { game ->
MenuItem(game.name, checked = selectedGameKey == game.profileKey) {
selectedGameKey = game.profileKey
scopeMenu = false
}
}
}
}
}
}
if (!narrow) {
Spacer(Modifier.width(16.dp))
Box(Modifier.weight(1f).fillMaxHeight()) { detail(layer, false) }
}
if (selectedGame != null) {
GameComponentOverrides(
game = selectedGame,
config = environment,
generalFexPreset = generalFexPreset,
generalProton = view?.proton,
protons = views.map { it.proton },
protonId = gameProtonId,
protonChoice = gameProtonChoice,
packages = snapshot?.packages.orEmpty(),
componentChoices = gameComponents,
componentMenu = gameComponentMenu,
protonMenu = gameProtonMenu,
fexMenu = fexMenu,
busy = environmentBusy || profileLoading,
error = environmentError,
notice = profileNotice,
winComponentsRevision = winComponentsRevision,
onFexMenu = { fexMenu = it },
onFexPreset = ::setGameFexPreset,
onProtonMenu = { gameProtonMenu = it },
onProton = ::setGameProton,
onComponentMenu = { gameComponentMenu = it },
onComponent = ::setGameComponent,
onEnvironment = { environmentEditor = true },
onWinComponents = { winComponentsOpen = false },
onReset = {
ask(
ctx.getString(R.string.comp_game_reset_title, selectedGame.name),
ctx.getString(R.string.comp_game_reset_body),
R.string.comp_game_reset,
::resetGameProfile,
)
},
modifier = Modifier.weight(1f).fillMaxWidth(),
)
} else {
Row(Modifier.weight(1f).fillMaxWidth()) {
// ---- the stack ----
Box(
(if (narrow) Modifier.fillMaxWidth() else Modifier.width(250.dp)).fillMaxHeight()
.onGloballyPositioned { stackAt = it.positionInRoot() },
) {
val tint = rememberUpdatedState(pal.signal)
val local = cardsAt[layer]?.translate(-stackAt)
ChipGlide(LAYERS.indexOf(layer), local, tint, GlideAxis.Vertical, corner = 12.dp)
Stack(
layer, view, gpu, focus = layerFocus, landed = landed, rings = rings,
onPlaced = { l, r -> cardsAt[l] = r },
onFocus = { l -> pick(l) },
onOpen = { l ->
pick(l)
if (narrow) narrowOpen = l
else runCatching { firstRow.requestFocus() }
},
)
}
if (!narrow) {
Spacer(Modifier.width(16.dp))
Box(Modifier.weight(1f).fillMaxHeight()) { detail(layer, false) }
}
}
}
}
@@ -636,6 +961,50 @@ fun ComponentsPage(
if (from == null) body() else PageFlood(from, driverLeaving) { body() }
}
}
confirm?.let { (body, action) ->
AlertDialog(
onDismissRequest = { confirm = null },
title = { Text(confirmTitle) },
text = { Text(body, fontSize = 14.sp) },
// Opens on Cancel, so a stray A press on a controller never swaps or deletes anything.
confirmButton = {
val del = confirmVerb == R.string.common_delete || confirmVerb == R.string.comp_game_reset
FocusText(stringResource(confirmVerb), if (del) colors.error else pal.signal) { confirm = null; action() }
},
dismissButton = {
val cancelFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { cancelFocus.requestFocus() } }
FocusText(stringResource(R.string.common_cancel), colors.onBackground, modifier = Modifier.focusRequester(cancelFocus)) { confirm = null }
},
)
}
if (environmentEditor && selectedGame != null) {
GameEnvironmentEditor(
byPad = androidx.compose.ui.platform.LocalInputModeManager.current.inputMode == androidx.compose.ui.input.InputMode.Keyboard,
initialScope = selectedGame.profileKey,
) {
environmentEditor = false
coroutine.launch {
runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.read(ctx) } }
.onSuccess { environment = it; environmentError = false }
.onFailure { environmentError = true }
}
}
}
val winOpen = winComponentsOpen
if (winOpen != null && selectedGame != null) {
WinComponentsDialog(
selectedGame.profileKey,
selectedGame.name,
selectedGame.gameFiles,
winOpen,
compat = selectedGame.protonPrefix,
steamAppId = selectedGame.appId.takeIf { selectedGame.library != Library.ADDED },
) {
winComponentsOpen = null
winComponentsRevision++
}
}
}
/** The Installed rows of one component layer for the Proton the page shows. */
@@ -798,6 +1167,253 @@ private fun EmptyLine(text: String) {
Text(text, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp))
}
@Composable
private fun GameComponentOverrides(
game: Library.SteamGame,
config: GameEnvironment.Config?,
generalFexPreset: String,
generalProton: ComponentsManager.Proton?,
protons: List<ComponentsManager.Proton>,
protonId: String?,
protonChoice: ProtonDefault.GameChoice?,
packages: List<ComponentsManager.Package>,
componentChoices: Map<String, String>,
componentMenu: String?,
protonMenu: Boolean,
fexMenu: Boolean,
busy: Boolean,
error: Boolean,
notice: String?,
winComponentsRevision: Int,
onFexMenu: (Boolean) -> Unit,
onFexPreset: (String?) -> Unit,
onProtonMenu: (Boolean) -> Unit,
onProton: (String?) -> Unit,
onComponentMenu: (String?) -> Unit,
onComponent: (String, String?) -> Unit,
onEnvironment: () -> Unit,
onWinComponents: () -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val override = config?.let { GameEnvironment.gameFexPreset(it, game.profileKey) }
val proton = protons.firstOrNull { it.id == protonId }
val effectiveProton = if (protonChoice == null) generalProton else proton
val picks = remember(game.profileKey, winComponentsRevision) { WinComponents.picks(context, game.profileKey) }
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
modifier = modifier.verticalScroll(rememberScrollState()).padding(top = 14.dp, bottom = 16.dp),
) {
Text(
stringResource(R.string.comp_game_hint),
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SettingsGroup(stringResource(R.string.fex_preset_title)) {
SettingsRow(
stringResource(R.string.comp_game_fex),
stringResource(if (override == null) R.string.comp_game_fex_inherited else R.string.comp_game_fex_override),
) {
FexPresetChip(
preset = override ?: generalFexPreset,
inherited = override == null,
open = fexMenu,
enabled = config != null && !busy,
allowInheritance = true,
onToggle = { onFexMenu(!fexMenu) },
onDismiss = { onFexMenu(false) },
onPick = onFexPreset,
)
}
GamePackageRow(
comp = "fex",
packages = packages,
selectedFile = componentChoices["fex"],
open = componentMenu == "fex",
busy = busy,
onMenu = onComponentMenu,
onPick = onComponent,
)
}
SettingsGroup(stringResource(R.string.comp_game_compatibility)) {
SettingsRow(
stringResource(R.string.comp_proton),
stringResource(if (protonChoice == null) R.string.comp_game_proton_inherited else R.string.comp_game_proton_override),
) {
Box {
ValueChip(
proton?.name ?: protonChoice?.dir ?: stringResource(R.string.comp_game_use_general),
protonMenu,
enabled = protons.isNotEmpty() && !busy,
modifier = Modifier.widthIn(max = 280.dp),
) { onProtonMenu(!protonMenu) }
AnchoredMenu(
protonMenu,
onDismiss = { onProtonMenu(false) },
title = stringResource(R.string.comp_proton),
) { first ->
MenuItem(
stringResource(R.string.comp_game_use_general),
checked = protonChoice == null,
focusRequester = first,
) { onProton(null); onProtonMenu(false) }
protons.forEach { option ->
MenuItem(option.name, checked = proton?.id == option.id) {
onProton(option.id)
onProtonMenu(false)
}
}
}
}
}
GamePackageRow(
comp = "dxvk",
packages = packages,
selectedFile = componentChoices["dxvk"],
open = componentMenu == "dxvk",
busy = busy,
proton = effectiveProton,
onMenu = onComponentMenu,
onPick = onComponent,
onRuntimeOptions = onEnvironment,
)
GamePackageRow(
comp = "vkd3d",
packages = packages,
selectedFile = componentChoices["vkd3d"],
open = componentMenu == "vkd3d",
busy = busy,
proton = effectiveProton,
onMenu = onComponentMenu,
onPick = onComponent,
onRuntimeOptions = onEnvironment,
)
SettingsRow(
stringResource(R.string.game_env_title),
stringResource(R.string.comp_game_environment_other_hint),
) {
SecondaryButton(stringResource(R.string.game_env_edit), enabled = !busy, onClick = onEnvironment)
}
SettingsRow(
stringResource(R.string.wincomp_title),
if (picks.isEmpty()) stringResource(R.string.wincomp_card_none)
else picks.joinToString(", ") { com.droiddeck.launcher.session.WinComponentNames.of(it) },
) {
SecondaryButton(stringResource(R.string.game_env_edit), onClick = onWinComponents)
}
}
notice?.let {
Text(
it,
fontSize = 13.sp,
color = if (error) MaterialTheme.colorScheme.error else LocalPalette.current.good,
)
}
if (error && notice == null) Text(stringResource(R.string.game_env_error), fontSize = 13.sp, color = MaterialTheme.colorScheme.error)
if (!busy) {
FocusText(
stringResource(R.string.comp_game_reset),
MaterialTheme.colorScheme.error,
onClick = onReset,
)
}
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
@Composable
private fun GamePackageRow(
comp: String,
packages: List<ComponentsManager.Package>,
selectedFile: String?,
open: Boolean,
busy: Boolean,
proton: ComponentsManager.Proton? = null,
onMenu: (String?) -> Unit,
onPick: (String, String?) -> Unit,
onRuntimeOptions: (() -> Unit)? = null,
) {
val choices = packages.filter { it.comp == comp }
val selected = choices.firstOrNull { it.file == selectedFile }
val title = ComponentsManager.LABEL[comp] ?: comp
SettingsRow(
title,
if (selected == null) {
if (proton == null) stringResource(R.string.comp_game_component_general)
else stringResource(R.string.comp_game_component_runtime, proton.name)
} else stringResource(R.string.comp_game_component_override, selected.version),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Box {
ValueChip(
selected?.version ?: stringResource(R.string.comp_game_use_general),
open,
enabled = !busy,
modifier = Modifier.widthIn(max = 240.dp),
) { onMenu(if (open) null else comp) }
AnchoredMenu(open, onDismiss = { onMenu(null) }, title = title) { first ->
MenuItem(
stringResource(R.string.comp_game_use_general),
checked = selected == null,
focusRequester = first,
) { onPick(comp, null); onMenu(null) }
choices.forEach { option ->
MenuItem(option.version, checked = option.file == selectedFile, detail = option.description) {
onPick(comp, option.file)
onMenu(null)
}
}
}
}
if (onRuntimeOptions != null) {
SecondaryButton(stringResource(R.string.comp_game_runtime_options), enabled = !busy, onClick = onRuntimeOptions)
}
}
}
}
@Composable
internal fun FexPresetChip(
preset: String,
inherited: Boolean,
open: Boolean,
enabled: Boolean,
onToggle: () -> Unit,
onDismiss: () -> Unit,
onPick: (String?) -> Unit,
allowInheritance: Boolean = false,
modifier: Modifier = Modifier,
) {
val chosen = FexPreset.all.firstOrNull { it.id == preset }
val label = when {
preset == GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom)
chosen == null -> stringResource(FexPreset.byId("").label)
inherited -> stringResource(R.string.comp_game_use_general)
else -> stringResource(chosen.label)
}
Box {
ValueChip(label, open, enabled = enabled, modifier = modifier.widthIn(max = 260.dp), onClick = onToggle)
AnchoredMenu(open && enabled, onDismiss = onDismiss, title = stringResource(R.string.fex_preset_title)) { first ->
if (allowInheritance) {
MenuItem(
stringResource(R.string.comp_game_use_general),
checked = inherited,
focusRequester = first,
) { onPick(null); onDismiss() }
}
FexPreset.all.forEachIndexed { index, option ->
MenuItem(
stringResource(option.label),
checked = !inherited && option.id == preset,
detail = stringResource(option.detail),
focusRequester = if (index == 0 && !allowInheritance) first else null,
) { onPick(option.id); onDismiss() }
}
}
}
}
@Composable
internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
@@ -478,10 +478,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
// The game's prefix is compatdata/<id>, as droiddeck-game-env reads it: a Steam title's appid, an
// added game's shortcut appid (unsigned). Recommended scans the game's own folder either way.
val wincompKey = game?.let {
if (it.library == com.droiddeck.launcher.frontend.Library.ADDED) Integer.toUnsignedString(it.appId)
else it.appId.takeIf { id -> id > 0 }?.toString()
}
val wincompKey = game?.takeIf { it.library == com.droiddeck.launcher.frontend.Library.ADDED || it.appId > 0 }?.profileKey
var wincompOpen by remember(wincompKey) { mutableStateOf<Boolean?>(null) }
val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() }
val cards = buildList<@Composable (Modifier) -> Unit> {
@@ -52,7 +52,11 @@ import kotlinx.coroutines.withContext
// dialog, with each variable's actions in a menu off its value as everywhere else.
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true) {
fun GameEnvironmentRow(
modifier: Modifier = Modifier,
showHint: Boolean = true,
initialScope: String = "",
) {
var open by remember { mutableStateOf(false) }
// Opened with a pad, the editor starts with focus in it; the dialog is a window of its own and
// would otherwise give the pad nothing to move from.
@@ -64,7 +68,7 @@ fun GameEnvironmentRow(modifier: Modifier = Modifier, showHint: Boolean = true)
open = true
}
}
if (open) GameEnvironmentEditor(byPad) { open = false }
if (open) GameEnvironmentEditor(byPad, initialScope) { open = false }
}
/** With a pad driving, focus [target] once the dialog is laid out, and keep the dialog in pad mode. */
@@ -81,19 +85,19 @@ internal fun PadFocus(byPad: Boolean, target: FocusRequester) {
}
@Composable
private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
internal fun GameEnvironmentEditor(byPad: Boolean, initialScope: String = "", onClose: () -> Unit) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
val shown = rememberShown(onClose)
val close = { shown.targetState = false }
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
var scope by remember { mutableStateOf("") }
var scope by remember { mutableStateOf(initialScope) }
var otherId by remember { mutableStateOf<String?>(null) }
var menu by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
var editing by remember { mutableStateOf<EnvironmentVariableEdit?>(null) }
val preset = SessionPrefs.fexPreset(context)
val forceSsbs = SessionPrefs.forceSsbs(context)
val firstFocus = remember { FocusRequester() }
@@ -168,7 +172,7 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
VariableRow(
name, value, inherited = !own.containsKey(name), open = menu == "var:$name", enabled = !busy,
onToggle = { menu = if (menu == "var:$name") null else "var:$name" }, onDismiss = { if (menu == "var:$name") menu = null },
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = name to (value ?: "") },
onEdit = { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, name, value ?: "") },
// A value set here or inherited can be switched off for this profile; one set here can go back to inheriting.
onUnset = if (value != null) ({ menu = null; save(current.withEntries(scope, own + (name to null))) }) else null,
onRestore = if (own.containsKey(name)) ({ menu = null; save(current.withEntries(scope, own - name)) }) else null,
@@ -185,9 +189,9 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
SecondaryButton(stringResource(R.string.game_env_add), enabled = !busy) { menu = if (menu == "add") null else "add" }
AnchoredMenu(menu == "add", onDismiss = { if (menu == "add") menu = null }, title = stringResource(R.string.game_env_add)) { first ->
for ((i, option) in GameEnvironmentOptions.all.withIndex()) MenuItem(option.name, checked = false, focusRequester = if (i == 0) first else null) {
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = option.name to option.value
menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, option.name, option.value)
}
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = "" to "" }
MenuItem(stringResource(R.string.game_env_custom), checked = false) { menu = null; editByPad = dialogInput.inputMode == InputMode.Keyboard; editing = EnvironmentVariableEdit(scope, profileName, "", "") }
}
}
if (current != null && current.entries(scope).isNotEmpty()) FocusText(stringResource(R.string.game_env_reset), LocalPalette.current.signal) {
@@ -197,19 +201,21 @@ private fun GameEnvironmentEditor(byPad: Boolean, onClose: () -> Unit) {
PrimaryButton(stringResource(R.string.game_env_done), enabled = !busy, onClick = close)
}
}
editing?.let { (name, value) ->
VariableDialog(name, value, profileName, editByPad, onDismiss = { editing = null }) { key, content ->
editing?.let { edit ->
VariableDialog(edit.name, edit.value, edit.profileName, editByPad, onDismiss = { editing = null }) { key, content ->
config?.let {
val entries = it.entries(scope).toMutableMap()
if (name.isNotEmpty() && name != key) entries[name] = null
val entries = it.entries(edit.scope).toMutableMap()
if (edit.name.isNotEmpty() && edit.name != key) entries[edit.name] = null
entries[key] = content
save(it.withEntries(scope, entries))
save(it.withEntries(edit.scope, entries))
}
editing = null
}
}
}
private data class EnvironmentVariableEdit(val scope: String, val profileName: String, val name: String, val value: String)
/** The panel the profile and the variables sit in, as a settings group does. */
@Composable
private fun Panel(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) =
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import kotlin.math.roundToInt
@@ -88,6 +89,9 @@ import com.droiddeck.launcher.HomeApp
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.session.ProfileValue
import com.droiddeck.launcher.session.SelectedGameProfile
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.semantics.stateDescription
@@ -205,8 +209,8 @@ class DrawerActions(
/** The compositor's post chain (gpu/ScreenEffects), changed live. */
val effects: ScreenEffects = ScreenEffects.OFF,
/** Texture filtering for DirectX 9-11 games (core/TextureFiltering); lands on their next launch. */
val textureAnisotropy: Int = 0,
val textureLodBias: String = TextureFiltering.LOD_BIAS_OFF,
val textureAnisotropy: Int? = 0,
val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF,
val secondScreenMode: SecondScreenMode,
val secondScreenDisplays: List<SecondScreenDisplay>,
val selectedSecondScreenDisplay: Int,
@@ -235,8 +239,8 @@ class DrawerActions(
val onUpscaler: (Int) -> Unit = {},
val onUpscaleSharpness: (Int) -> Unit = {},
val onEffects: (ScreenEffects) -> Unit = {},
val onTextureAnisotropy: (Int) -> Unit = {},
val onTextureLodBias: (String) -> Unit = {},
val onTextureAnisotropy: (Int?) -> Unit = {},
val onTextureLodBias: (String?) -> Unit = {},
val onSecondScreenMode: (SecondScreenMode) -> Unit,
val onSecondScreenDisplay: (Int) -> Unit,
val onLaunchAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit,
@@ -257,8 +261,22 @@ class DrawerActions(
val components: ComponentsManager.Snapshot? = null,
/** Re-reads the Protons (and runs swaps that waited for a game to close). */
val onComponentsRefresh: () -> Unit = {},
/** Installed games available for profile inspection in the session drawer. */
val gameProfiles: List<Pair<Long, String>> = emptyList(),
/** The selected game profile, or null for the Default profile. */
val selectedGameProfileId: Long? = null,
val selectedGameProfile: SelectedGameProfile? = null,
val gameProfilesLoaded: Boolean = false,
val gameProfileRefreshFailed: Boolean = false,
val onSelectedGameProfile: (Long?) -> Unit = {},
val onGameProfilesPageOpened: () -> Unit = {},
val onSelectedGameProfileRefresh: () -> Unit = {},
val gameFexPresetSaving: Boolean = false,
val onGameFexPreset: (Long, String?) -> Unit = { _, _ -> },
/** Swaps [value] ("orig:<build>" or a stored package file) into a Proton's component. */
val onComponentSwap: (protonId: String, comp: String, value: String) -> Unit = { _, _, _ -> },
/** Saves a stored package for a game, or null to inherit that component from the Default profile. */
val onGameComponent: (appId: Long, comp: String, file: String?) -> Unit = { _, _, _ -> },
)
@OptIn(ExperimentalComposeUiApi::class)
@@ -286,7 +304,19 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
}
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, page) { if (open && page == SessionDrawerPage.GAMES) a.onComponentsRefresh() }
LaunchedEffect(a.selectedGameProfileId) {
if (host.open == "fex") host.open = null
}
LaunchedEffect(open, page) {
if (open && page == SessionDrawerPage.GAMES) {
a.onGameProfilesPageOpened()
a.onComponentsRefresh()
while (true) {
a.onSelectedGameProfileRefresh()
delay(1_000)
}
}
}
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
@@ -479,14 +509,17 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont
SecondScreenGroup(host, a) { key -> focus.track(page, key) }
}
SessionDrawerPage.GAMES -> {
if (a.steam) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) }
Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp,
color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp))
TextureFilteringGroup(host, a) { key -> focus.track(page, key) }
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), null,
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"), showHint = false)
DrawerFexPresetRow(host, a, focus.track(page, "fex"))
GameEnvironmentRow(
modifier = focus.track(page, "game-env"),
showHint = false,
initialScope = a.selectedGameProfileId?.toString().orEmpty(),
)
}
ComponentsDrawerPage(host, a) { key -> focus.track(page, key) }
}
@@ -874,20 +907,187 @@ private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String)
}
}
@Composable
private fun DrawerFexPresetRow(host: MenuHost, a: DrawerActions, modifier: Modifier) {
val appId = a.selectedGameProfileId
val profile = a.selectedGameProfile?.takeIf { it.appId == appId }
val inherited = profile?.fexPreset?.inherited == true
val hint = if (profile == null) null else stringResource(
if (inherited) R.string.comp_game_fex_inherited else R.string.comp_game_fex_override,
)
SettingsRow(stringResource(R.string.fex_preset_title), hint, highlighted = host.open == "fex") {
if (appId != null && profile == null) {
ValueChip("-", false, enabled = false, modifier = modifier) {}
} else {
FexPresetChip(
preset = profile?.fexPreset?.value ?: a.fexPreset,
inherited = inherited,
open = host.open == "fex",
enabled = !a.gameFexPresetSaving,
onToggle = { host.open = if (host.open == "fex") null else "fex" },
onDismiss = { if (host.open == "fex") host.open = null },
onPick = { preset ->
if (appId == null) {
preset?.let(a.onFexPreset)
} else {
a.onGameFexPreset(appId, preset)
}
},
allowInheritance = appId != null,
modifier = modifier,
)
}
}
}
/** Texture filtering on the Games page: DXVK options for the next DirectX 9-11 launch. */
@Composable
private fun TextureFilteringGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val inherited = a.selectedGameProfileId != null
val anisotropyChoices: List<Pair<Int?, String>> =
(if (inherited) listOf(null to stringResource(R.string.comp_game_use_general)) else emptyList()) +
SessionPrefs.textureAnisotropyChoices(LocalContext.current).map { (value, label) -> value as Int? to label }
val lodBiasChoices: List<Pair<String?, String>> =
(if (inherited) listOf(null to stringResource(R.string.comp_game_use_general)) else emptyList()) +
SessionPrefs.textureLodBiasChoices(LocalContext.current).map { (value, label) -> value as String? to label }
SettingsGroup(stringResource(R.string.drawer_texture)) {
ChoiceRow(host, "anisotropy", stringResource(R.string.drawer_anisotropy), null,
SessionPrefs.textureAnisotropyChoices(LocalContext.current), a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
anisotropyChoices, a.textureAnisotropy, chipModifier = track("anisotropy"), onPick = a.onTextureAnisotropy)
ChoiceRow(host, "texture-sharpness", stringResource(R.string.drawer_texture_sharpness), null,
SessionPrefs.textureLodBiasChoices(LocalContext.current), a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
lodBiasChoices, a.textureLodBias, note = stringResource(R.string.drawer_texture_sharpness_note),
chipModifier = track("texture-sharpness"), onPick = a.onTextureLodBias)
}
}
private fun signed(v: Int) = if (v > 0) "+$v" else "$v"
@Composable
private fun SelectedGameProfileGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) {
val colors = MaterialTheme.colorScheme
val options: List<Pair<Long?, String>> = listOf(null to stringResource(R.string.comp_scope_general)) +
a.gameProfiles.map { (id, name) -> Pair(id, name) }
val profile = a.selectedGameProfile
SettingsGroup(stringResource(R.string.drawer_selected_profile)) {
DrawerStackedChoice(
host = host,
key = "game-profile",
label = stringResource(R.string.comp_profile),
hint = if (a.selectedGameProfileId == null) stringResource(R.string.comp_scope_general_detail)
else stringResource(R.string.drawer_profile_inspect_hint),
options = options,
selected = a.selectedGameProfileId,
chipModifier = track("game-profile"),
onPick = a.onSelectedGameProfile,
)
if (a.gameProfileRefreshFailed) {
Text(
stringResource(
if (a.gameProfilesLoaded) {
R.string.drawer_profile_refresh_failed_stale
} else {
R.string.drawer_profile_refresh_failed_initial
},
),
fontSize = 13.sp,
color = colors.error,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
if (a.selectedGameProfileId == null) return@SettingsGroup
if (profile == null) {
if (!a.gameProfileRefreshFailed) Text(
stringResource(R.string.drawer_selected_profile_none),
fontSize = 13.sp,
color = colors.onSurfaceVariant,
modifier = Modifier.padding(14.dp),
)
return@SettingsGroup
}
val hasOverrides = profile.hasOverrides ||
a.textureAnisotropy != null ||
a.textureLodBias != null
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) {
Text(
stringResource(
if (hasOverrides) R.string.drawer_profile_custom
else R.string.drawer_profile_default,
),
fontSize = 12.sp,
color = colors.onSurfaceVariant,
modifier = Modifier.padding(bottom = if (hasOverrides) 5.dp else 0.dp),
)
if (!profile.fexPreset.inherited) {
ProfileSummaryRow(stringResource(R.string.comp_game_fex), fexProfileLabel(profile.fexPreset))
}
if (!profile.proton.inherited) {
ProfileSummaryRow(stringResource(R.string.comp_proton), profileValueLabel(profile.proton))
}
for (component in ComponentsManager.COMPONENTS) {
val value = profile.components.getValue(component)
if (!value.inherited) {
ProfileSummaryRow(ComponentsManager.LABEL.getValue(component), profileValueLabel(value))
}
}
if (profile.environmentOverrides > 0) {
ProfileSummaryRow(
stringResource(R.string.game_env_title),
pluralStringResource(
R.plurals.drawer_profile_environment_overrides,
profile.environmentOverrides,
profile.environmentOverrides,
),
)
}
if (profile.windowsComponentNames.isNotEmpty()) {
ProfileSummaryRow(
stringResource(R.string.wincomp_title),
profile.windowsComponentNames.joinToString(", "),
)
}
a.textureAnisotropy?.let { value ->
ProfileSummaryRow(
stringResource(R.string.drawer_anisotropy),
SessionPrefs.textureAnisotropyChoices(LocalContext.current)
.firstOrNull { it.first == value }?.second.orEmpty(),
)
}
a.textureLodBias?.let { value ->
ProfileSummaryRow(
stringResource(R.string.drawer_texture_sharpness),
SessionPrefs.textureLodBiasChoices(LocalContext.current)
.firstOrNull { it.first == value }?.second.orEmpty(),
)
}
}
}
}
@Composable
private fun fexProfileLabel(value: ProfileValue): String {
return when (value.value) {
GameEnvironment.CUSTOM_FEX_PRESET -> stringResource(R.string.comp_game_fex_custom)
else -> stringResource(FexPreset.byId(value.value).label)
}
}
@Composable
private fun profileValueLabel(value: ProfileValue) =
value.value.ifEmpty { stringResource(R.string.comp_no_proton_short) }
@Composable
private fun ProfileSummaryRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 3.dp)) {
Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(96.dp))
Text(
value,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/**
* The Games tab's components: quick swaps between what is already installed, per Proton. A swap
* into the Proton a running game uses waits until that game closes; downloading, importing and
@@ -903,6 +1103,54 @@ private fun ComponentsDrawerPage(host: MenuHost, a: DrawerActions, track: (Strin
}
return
}
val gameProfile = a.selectedGameProfileId?.let { appId ->
a.selectedGameProfile?.takeIf { it.appId == appId }
}
if (a.selectedGameProfileId != null) {
if (gameProfile == null) {
SettingsGroup(stringResource(R.string.drawer_page_components)) {
Text(
stringResource(R.string.comp_reading),
fontSize = 13.sp,
color = colors.onSurfaceVariant,
modifier = Modifier.padding(14.dp),
)
}
return
}
SettingsGroup(stringResource(R.string.drawer_page_components)) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 11.dp)) {
ProfileSummaryRow(stringResource(R.string.comp_proton), gameProfile.proton.value)
}
for (comp in ComponentsManager.COMPONENTS) {
val value = gameProfile.components.getValue(comp)
val options: List<Pair<String?, String>> =
listOf(null to stringResource(R.string.comp_game_use_general)) +
snap.packages.filter { it.comp == comp }.map { it.file to it.version }
DrawerStackedChoice(
host = host,
key = "game-cmp-$comp",
label = ComponentsManager.LABEL.getValue(comp),
hint = if (value.inherited) {
stringResource(R.string.drawer_component_inherited, value.value)
} else {
null
},
options = options,
selected = gameProfile.componentFiles[comp],
chipModifier = track("cmp-$comp"),
onPick = { file -> a.onGameComponent(gameProfile.appId, comp, file) },
)
}
}
Text(
stringResource(R.string.drawer_game_components_note, gameProfile.name),
fontSize = 12.sp,
color = colors.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp),
)
return
}
val running = snap.protons.filter { it.inUseByGame }
var pick by rememberSaveable { mutableStateOf<String?>(null) }
val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull()
+50 -1
View File
@@ -58,7 +58,7 @@
<string name="game_env_save">Save</string>
<string name="game_env_remove">Remove</string>
<string name="game_env_restore">Inherit again</string>
<string name="game_env_reset">Reset this profile</string>
<string name="game_env_reset">Clear environment overrides</string>
<string name="game_env_unset">Unset for this profile</string>
<string name="game_env_empty">No variables set.</string>
<string name="game_env_inherited">Inherited</string>
@@ -413,6 +413,41 @@
<string name="comp_check_nightlies">Check the Nightlies for new packages</string>
<string name="comp_proton">Proton</string>
<string name="comp_proton_note">The Proton chosen here runs every game that has not picked its own in Steam, and a change made in Steam shows here too. Steam replaces Valve\'s Proton when it updates; originals are kept per build.</string>
<string name="comp_scope">Configuration</string>
<string name="comp_profile_configure">Configure profile</string>
<string name="comp_profile">Profile</string>
<string name="comp_scope_general">Default profile</string>
<string name="comp_scope_general_detail">Used automatically by games without overrides.</string>
<string name="comp_profile_game_detail">Saved settings used automatically when %1$s launches.</string>
<string name="comp_game_hint">Choose only the settings this game needs. Everything else continues to use the Default profile.</string>
<string name="comp_game_fex">FEX preset</string>
<string name="comp_game_fex_inherited">Using the Default profile preset.</string>
<string name="comp_game_fex_override">Custom preset saved for this game.</string>
<string name="comp_game_fex_custom">Custom FEX variables</string>
<string name="comp_game_use_general">Use Default</string>
<string name="comp_game_compatibility">Game compatibility</string>
<string name="comp_game_environment_hint">Proton, DXVK, VKD3D and synchronization environment options for this game.</string>
<string name="comp_game_proton_inherited">Using the Default profile Proton.</string>
<string name="comp_game_proton_override">Custom Proton saved for this game through Steam compatibility mapping.</string>
<string name="comp_no_proton_short">No Proton</string>
<string name="comp_game_dxvk">DXVK</string>
<string name="comp_game_vkd3d">VKD3D-Proton</string>
<string name="comp_game_component_runtime">Uses the Default profile package from %1$s unless this game selects another package.</string>
<string name="comp_game_component_general">Uses the Default profile component package.</string>
<string name="comp_game_component_override">Uses %1$s only for this game.</string>
<string name="comp_game_runtime_options">Runtime options</string>
<string name="comp_game_environment_other_hint">Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options.</string>
<string name="comp_game_saved_next">%1$s\'s profile is saved for the next game launch.</string>
<string name="comp_game_proton_live">%1$s\'s Proton choice is saved and available in Steam now.</string>
<string name="comp_game_proton_next">%1$s\'s Proton choice is saved. Restart Steam before launching the game.</string>
<string name="comp_game_proton_not_runnable">%1$s cannot use the selected Proton on this device.</string>
<string name="comp_game_load_failed">Could not load %1$s\'s profile: %2$s</string>
<string name="comp_game_save_failed">The game profile could not be saved: %1$s</string>
<string name="comp_game_reset">Reset game profile</string>
<string name="comp_game_reset_title">Reset %1$s\'s profile?</string>
<string name="comp_game_reset_body">Remove this game\'s Proton, FEX, component package, environment, texture filtering, and Windows component overrides? It will use the Default profile.</string>
<string name="comp_game_reset_done">%1$s now uses the Default profile.</string>
<string name="comp_game_reset_failed">The game profile could not be reset completely: %1$s</string>
<string name="comp_default_live">%1$s now runs every game that has not picked its own Proton in Steam.</string>
<string name="comp_default_next">%1$s will run every game that has not picked its own Proton, from the next time Steam starts.</string>
<string name="comp_default_not_runnable">%1$s cannot start games here, so it cannot be the default.</string>
@@ -576,6 +611,19 @@
<string name="drawer_stop_title">Stop session?</string>
<string name="drawer_running_now">Running now</string>
<string name="drawer_no_game">No game is running on a Proton</string>
<string name="drawer_selected_profile">Game profile</string>
<string name="drawer_selected_profile_none">Profile details are not available yet.</string>
<string name="drawer_profile_refresh_failed_initial">Couldn\'t load game profiles. Retrying automatically.</string>
<string name="drawer_profile_refresh_failed_stale">Couldn\'t refresh game profiles. Showing the last available data; retrying automatically.</string>
<string name="drawer_profile_inspect_hint">View and edit the saved settings used on the next launch.</string>
<string name="drawer_profile_default">Using Default profile</string>
<string name="drawer_profile_custom">Custom profile</string>
<string name="drawer_component_inherited">Using %1$s from the Default profile.</string>
<string name="drawer_game_components_note">Changes are saved to %1$s\'s profile and apply the next time the game launches.</string>
<plurals name="drawer_profile_environment_overrides">
<item quantity="one">%1$d game override</item>
<item quantity="other">%1$d game overrides</item>
</plurals>
<string name="drawer_no_proton">No Proton is installed yet.</string>
<string name="drawer_game_running">game running</string>
<string name="drawer_reapplied">re-applied at launch %1$s</string>
@@ -1257,6 +1305,7 @@
<!-- Shown as "Auto (direct)" in the drawer's touch setting -->
<string name="session_touch_auto_direct">direct</string>
<string name="session_swap_failed">Swap failed: %1$s</string>
<string name="session_profile_save_failed">Profile save failed: %1$s</string>
<string name="session_desktop_paused">The desktop is paused</string>
<!-- %1$s is the program's name -->
<string name="session_named_paused">%1$s is paused</string>
@@ -0,0 +1,88 @@
package com.droiddeck.launcher.agent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AgentGuestSelectionTest {
private val installedAppIds = setOf(646570L, 3180310L)
@Test fun focusedInstalledGameIsSelected() {
assertEquals(3180310L, AgentGuest.focusedInstalledSteamAppId(3180310L, installedAppIds))
}
@Test fun focusedInstalledGameIsActive() {
assertEquals(
3180310L,
AgentGuest.activeInstalledSteamAppId(3180310L, listOf(769L, 3180310L), installedAppIds),
)
}
@Test fun selectedSteamGameWinsOverStaleActiveSteamGame() {
assertEquals(
646570L,
AgentGuest.preferredInstalledSteamAppId(646570L, 3180310L, installedAppIds),
)
}
@Test fun validGamescopeGameFallsBackWhenSteamSelectionFails() {
assertEquals(
3180310L,
AgentGuest.selectedOrActiveInstalledSteamAppId(
Result.failure(IllegalStateException("agent unavailable")),
3180310L,
installedAppIds,
),
)
}
@Test fun absentSelectionAndActiveGameRemainAValidNoSelection() {
assertNull(AgentGuest.selectedOrActiveInstalledSteamAppId(Result.success(null), null, installedAppIds))
}
@Test(expected = IllegalStateException::class)
fun selectionFailureWithoutGamescopeFallbackRemainsAnError() {
AgentGuest.selectedOrActiveInstalledSteamAppId(
Result.failure(IllegalStateException("agent unavailable")),
null,
installedAppIds,
)
}
@Test fun soleFocusableInstalledGameRemainsActiveUnderSteamOverlay() {
assertEquals(
3180310L,
AgentGuest.activeInstalledSteamAppId(769L, listOf(3180310L), installedAppIds),
)
}
@Test fun ambiguousFocusableGamesAreIgnored() {
assertNull(
AgentGuest.activeInstalledSteamAppId(769L, listOf(646570L, 3180310L), installedAppIds),
)
}
@Test fun soleBaselayerGameResolvesAmbiguousSteamOverlayFocus() {
assertEquals(
646570L,
AgentGuest.activeInstalledSteamAppId(
focusedAppId = 769L,
focusableAppIds = listOf(646570L, 3180310L),
installedAppIds = installedAppIds,
baselayerAppIds = listOf(769L, 646570L),
),
)
}
@Test fun steamClientFocusIsIgnored() {
assertNull(AgentGuest.focusedInstalledSteamAppId(769L, installedAppIds))
}
@Test fun unknownGameFocusIsIgnored() {
assertNull(AgentGuest.focusedInstalledSteamAppId(42L, installedAppIds))
}
@Test fun missingFocusSnapshotIsIgnored() {
assertNull(AgentGuest.focusedInstalledSteamAppId(null, installedAppIds))
}
}
@@ -102,4 +102,24 @@ class GameEnvironmentTest {
assertTrue(GameEnvironment.validScope("4294967295"))
for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope))
}
@Test fun gameFexPresetOverridesGeneralAndCanInheritAgain() {
val performance = GameEnvironment.withGameFexPreset(GameEnvironment.Config(), "42", "PERFORMANCE")
assertEquals("PERFORMANCE", GameEnvironment.gameFexPreset(performance, "42"))
assertEquals("0", GameEnvironment.effective(performance, "STABILITY", "42")["FEX_TSOENABLED"])
assertEquals("1", GameEnvironment.effective(performance, "STABILITY", "43")["FEX_TSOENABLED"])
val inherited = GameEnvironment.withGameFexPreset(performance, "42", null)
assertNull(GameEnvironment.gameFexPreset(inherited, "42"))
assertFalse(inherited.games.containsKey("42"))
}
@Test fun gameFexPresetPreservesOtherOverridesAndRecognizesCustomValues() {
val original = GameEnvironment.Config(games = mapOf("42" to mapOf("CUSTOM" to "value", "FEX_MULTIBLOCK" to "7")))
assertEquals(GameEnvironment.CUSTOM_FEX_PRESET, GameEnvironment.gameFexPreset(original, "42"))
val compatibility = GameEnvironment.withGameFexPreset(original, "42", "COMPATIBILITY")
assertEquals("value", compatibility.games.getValue("42")["CUSTOM"])
assertEquals("COMPATIBILITY", GameEnvironment.gameFexPreset(compatibility, "42"))
}
}
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ComponentsProcessTest {
private val guestPath = "/root/.local/share/Steam/steamapps/common/Proton"
private val hostPath = "/data/user/0/com.droiddeck.launcher/files/root/root/.local/share/Steam/steamapps/common/Proton"
@Test
fun `running game is recognized through guest path`() {
val command = "python3 $guestPath/proton waitforexitandrun game.exe"
assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
}
@Test
fun `running game is recognized through Android host path`() {
val command = "python3 $hostPath/proton waitforexitandrun game.exe"
assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
}
@Test
fun `Steam startup Proton process is not reported as a running game`() {
val command = "python3 $hostPath/proton run wineboot.exe"
assertFalse(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
assertTrue(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = false))
}
@Test
fun `unrelated Proton process is ignored`() {
val command = "python3 /other/Proton/proton waitforexitandrun game.exe"
assertFalse(ComponentsManager.processUsesProton(listOf(guestPath, hostPath), command, gameOnly = true))
}
}
@@ -2,7 +2,10 @@ package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.core.Hashes
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdOutputStream
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
@@ -89,6 +92,283 @@ class ForcedPackageTest {
assertNull(install(gpu(650, GpuInfo.Family.A6XX)))
}
@Test fun gameComponentProfilesReadOnlyKnownComponents() {
val state = File(context.filesDir, "components/state.json")
state.parentFile!!.mkdirs()
state.writeText(JSONObject()
.put("active", JSONObject())
.put("queued", JSONObject())
.put("profiles", JSONObject().put("42", JSONObject()
.put("fex", "fex.wcp")
.put("dxvk", "dxvk.wcp")
.put("unknown", "ignored.wcp")))
.toString())
assertEquals(mapOf("fex" to "fex.wcp", "dxvk" to "dxvk.wcp"), ComponentsManager.gameComponents(context, "42"))
assertTrue(ComponentsManager.gameComponents(context, "43").isEmpty())
}
@Test fun removingLastGameOverrideKeepsBundledComponentRestoreRow() {
val protonName = "proton-exp"
proton(protonName)
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
ComponentsManager.clearGameComponents(context, "42")
assertFalse(ComponentsManager.gameComponents(context, "42").containsKey("dxvk"))
assertDefaultDxvkRestoreRow(protonName)
}
@Test fun usingDefaultForLastGameOverrideKeepsBundledComponentRestoreRow() {
val protonName = "proton-exp"
proton(protonName)
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
ComponentsManager.setGameComponent(context, "42", "dxvk", null)
assertFalse(ComponentsManager.gameComponents(context, "42").containsKey("dxvk"))
assertDefaultDxvkRestoreRow(protonName)
}
@Test fun aNewProtonIsCapturedWhenTheLastOverrideIsReset() {
proton("existing")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
ComponentsManager.snapshotAll(context)
proton("new-proton")
ComponentsManager.clearGameComponents(context, "42")
assertDefaultDxvkRestoreRow("existing")
assertDefaultDxvkRestoreRow("new-proton")
}
@Test fun anUpdatedBuildIsCapturedEvenAfterTheLastProfileWasRemoved() {
proton("proton-exp")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
ComponentsManager.clearGameComponents(context, "42")
proton("proton-exp", "experimental-12")
ComponentsManager.setGameComponent(context, "43", "dxvk", null)
assertDefaultDxvkRestoreRow("proton-exp", "experimental-12")
assertTrue(original("proton-exp", "experimental-11").isFile)
assertTrue(original("proton-exp", "experimental-12").isFile)
}
@Test fun aKnownEmptyComponentHasAnExplicitOriginalBundle() {
proton("proton-exp", dxvk = false)
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
ComponentsManager.clearGameComponents(context, "42")
assertTrue(original("proton-exp").isFile)
val rows = launchState().readLines().map { it.split('\t') }
assertEquals(14, rows.size)
assertTrue(rows.all { it[3] == "dxvk" && it[5] == "-" && it[6] == "-" })
}
@Test fun missingOriginalsCannotBeRecapturedFromPreviouslyProfiledFiles() {
proton("proton-exp")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
val saved = File(context.filesDir, "components/state.json").readText()
val prior = "42\tproton-exp\texperimental-11\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t-\t-\n"
launchState().apply { parentFile!!.mkdirs(); writeText(prior) }
assertThrows(IllegalStateException::class.java) {
ComponentsManager.setGameComponent(context, "42", "dxvk", null)
}
assertEquals(saved, File(context.filesDir, "components/state.json").readText())
assertEquals(prior, launchState().readText())
assertFalse(original("proton-exp").isFile)
}
@Test fun aBrokenOriginalDoesNotPublishDeletionRowsOrRemoveThePreviousStore() {
proton("proton-exp")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
val state = File(context.filesDir, "components/state.json")
val saved = state.readText()
val prior = "*\tother\tbuild\tdxvk\tfiles/old.dll\told-hash\t/old.dll\n"
launchState().apply { parentFile!!.mkdirs(); writeText(prior) }
val oldSource = File(launchState().parentFile, "store/old/files/old.dll")
oldSource.apply { parentFile!!.mkdirs(); writeText("old component") }
original("proton-exp").apply { parentFile!!.mkdirs(); writeText("not a bundle") }
assertThrows(java.io.IOException::class.java) {
ComponentsManager.clearGameComponents(context, "42")
}
assertEquals(saved, state.readText())
assertEquals(prior, launchState().readText())
assertEquals("old component", oldSource.readText())
}
@Test fun anArchiveWithoutAProfileIsNotAKnownEmptyOriginal() {
proton("proton-exp")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
val bundle = original("proton-exp")
bundle.parentFile!!.mkdirs()
TarArchiveOutputStream(ZstdOutputStream(bundle.outputStream())).use { }
assertThrows(IllegalArgumentException::class.java) {
ComponentsManager.clearGameComponents(context, "42")
}
assertFalse(launchState().exists())
assertEquals("custom-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
}
@Test fun aFailedStateCommitIsRecoveredBeforeSelectionsCanBeRead() {
proton("proton-exp")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
val state = File(context.filesDir, "components/state.json")
val saved = state.readText()
val blocked = File(context.filesDir, "components/state.json.tmp")
blocked.mkdir()
val blocker = File(blocked, "blocker").apply { writeText("keep directory nonempty") }
val oldSource = File(launchState().parentFile, "store/old/files/old.dll").apply {
parentFile!!.mkdirs()
writeText("old component")
}
assertThrows(java.io.IOException::class.java) {
ComponentsManager.clearGameComponents(context, "42")
}
assertEquals(saved, state.readText())
assertTrue(pendingState().isFile)
assertTrue(launchState().isFile)
assertEquals(Hashes.sha256(launchState()), JSONObject(pendingState().readText()).getString("launchSha256"))
assertEquals("old component", oldSource.readText())
assertThrows(java.io.IOException::class.java) {
ComponentsManager.gameComponents(context, "42")
}
assertTrue(pendingState().isFile)
assertTrue(blocker.delete())
assertTrue(blocked.delete())
assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty())
assertFalse(pendingState().exists())
assertDefaultDxvkRestoreRow("proton-exp")
assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty())
ComponentsManager.snapshotAll(context)
assertFalse(oldSource.exists())
assertDefaultDxvkRestoreRow("proton-exp")
}
@Test fun aFailedLaunchPublicationRetainsTheOldSelectionsAndStore() {
proton("proton-exp")
writeGameProfile("42", "dxvk", "custom-dxvk.wcp")
val state = File(context.filesDir, "components/state.json")
val saved = state.readText()
launchState().mkdirs()
val blocker = File(launchState(), "blocker").apply { writeText("block rename") }
val oldSource = File(launchState().parentFile, "store/old/files/old.dll").apply {
parentFile!!.mkdirs()
writeText("old component")
}
assertThrows(IllegalStateException::class.java) {
ComponentsManager.clearGameComponents(context, "42")
}
assertEquals(saved, state.readText())
assertTrue(pendingState().isFile)
assertEquals("custom-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
assertFalse(pendingState().exists())
assertEquals("old component", oldSource.readText())
assertTrue(blocker.delete())
assertTrue(launchState().delete())
ComponentsManager.clearGameComponents(context, "42")
assertTrue(ComponentsManager.gameComponents(context, "42").isEmpty())
assertFalse(oldSource.exists())
assertDefaultDxvkRestoreRow("proton-exp")
}
@Test fun interruptionBeforePublicationDiscardsOnlyThePendingSelections() {
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
val state = File(context.filesDir, "components/state.json")
val saved = state.readText()
val next = JSONObject(saved).apply {
getJSONObject("profiles").getJSONObject("42").put("dxvk", "new-dxvk.wcp")
}
val staged = File(launchState().parentFile, "desired.tsv.tmp").apply {
parentFile!!.mkdirs()
writeText("new launch table")
}
launchState().writeText("old launch table")
writePendingState(next, staged)
assertEquals("old-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
assertEquals(saved, state.readText())
assertEquals("old launch table", launchState().readText())
assertFalse(pendingState().exists())
assertFalse(staged.exists())
}
@Test fun identicalLaunchTablesDoNotCommitUnpublishedSettings() {
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
val state = File(context.filesDir, "components/state.json")
val saved = state.readText()
val next = JSONObject(saved).put("queued", JSONObject().put("proton-exp", JSONObject()))
val staged = File(launchState().parentFile, "desired.tsv.tmp").apply {
parentFile!!.mkdirs()
writeText("unchanged launch table")
}
launchState().writeText(staged.readText())
writePendingState(next, staged)
assertEquals("old-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
assertEquals(saved, state.readText())
assertFalse(pendingState().exists())
assertFalse(staged.exists())
}
@Test fun interruptionAfterPublicationRecoversAllSelectionsAndIsIdempotent() {
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
val next = JSONObject()
.put("active", JSONObject().put("proton-exp", JSONObject().put("dxvk", JSONObject().put("file", dxvk2))))
.put("queued", JSONObject().put("proton-exp", JSONObject().put("fex", JSONObject().put("file", "fex.wcp"))))
.put("profiles", JSONObject()
.put("42", JSONObject().put("dxvk", "new-dxvk.wcp"))
.put("43", JSONObject().put("fex", "fex.wcp")))
.put("profiledComponents", JSONArray(listOf("dxvk", "fex")))
launchState().apply { parentFile!!.mkdirs(); writeText("published launch table") }
writePendingState(next, launchState())
assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
assertEquals("fex.wcp", ComponentsManager.gameComponents(context, "43")["fex"])
assertEquals(next.toString(2), File(context.filesDir, "components/state.json").readText())
assertFalse(pendingState().exists())
assertEquals("published launch table", launchState().readText())
assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
}
@Test fun aPendingTransactionAfterTheStateCommitCanBeRecoveredAgain() {
writeGameProfile("42", "dxvk", "new-dxvk.wcp")
val state = File(context.filesDir, "components/state.json")
val next = JSONObject(state.readText())
launchState().apply { parentFile!!.mkdirs(); writeText("published launch table") }
writePendingState(next, launchState())
assertEquals("new-dxvk.wcp", ComponentsManager.gameComponents(context, "42")["dxvk"])
assertEquals(next.toString(2), state.readText())
assertFalse(pendingState().exists())
}
@Test fun anUnreadableRecoveryRecordDoesNotSilentlyReturnStaleSelections() {
writeGameProfile("42", "dxvk", "old-dxvk.wcp")
pendingState().writeText("incomplete recovery record")
assertThrows(org.json.JSONException::class.java) {
ComponentsManager.gameComponents(context, "42")
}
assertTrue(pendingState().isFile)
}
@Test fun theSamePackageOnThisProtonBuildIsNotFetchedAgain() {
proton("proton-exp")
writeState("proton-exp", dxvk2, "experimental-11")
@@ -204,11 +484,57 @@ class ForcedPackageTest {
private fun refuseApply(protonId: String, file: String): String = error("apply $protonId $file")
private fun proton(name: String, version: String = "experimental-11") {
private fun writeGameProfile(scope: String, comp: String, file: String) {
val state = File(context.filesDir, "components/state.json")
state.parentFile!!.mkdirs()
state.writeText(
JSONObject()
.put("active", JSONObject())
.put("queued", JSONObject())
.put("profiles", JSONObject().put(scope, JSONObject().put(comp, file)))
.toString(),
)
}
private fun launchState() = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-components/desired.tsv")
private fun pendingState() = File(context.filesDir, "components/state.pending.json")
private fun writePendingState(state: JSONObject, launch: File) {
pendingState().writeText(JSONObject()
.put("state", state)
.put("launchSha256", Hashes.sha256(launch))
.toString())
}
private fun original(protonName: String, version: String = "experimental-11") =
File(context.filesDir, "components/originals/$protonName/$version/dxvk.wcp")
private fun assertDefaultDxvkRestoreRow(protonName: String, version: String = "experimental-11") {
val row = launchState().readLines().map { it.split('\t') }.single {
it.size == 7 && it[0] == "*" && it[1] == protonName && it[2] == version &&
it[3] == "dxvk" && it[4].endsWith("/aarch64-windows/d3d11.dll")
}
assertNotEquals("-", row[6])
val source = File(LinuxRuntime.rootDir(context), row[6].removePrefix("/"))
assertEquals("bundled DXVK $version", source.readText())
assertEquals(Hashes.sha256(source), row[5])
assertTrue(original(protonName, version).isFile)
val state = JSONObject(File(context.filesDir, "components/state.json").readText())
assertEquals(listOf("dxvk"), state.getJSONArray("profiledComponents").let { values ->
(0 until values.length()).map(values::getString)
})
}
private fun proton(name: String, version: String = "experimental-11", dxvk: Boolean = true) {
val dir = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps/common/$name")
File(dir, "files/lib/wine").mkdirs()
File(dir, "proton").writeText("#!/bin/sh\n")
File(dir, "version").writeText("1 $version\n")
if (dxvk) File(dir, "files/lib/wine/dxvk/aarch64-windows/d3d11.dll").apply {
parentFile!!.mkdirs()
writeText("bundled DXVK $version")
}
}
private fun writeState(protonId: String, file: String, protonVersion: String) {
@@ -69,6 +69,91 @@ class GameEnvironmentStoreTest {
SessionState.upscaleRatio = 0f
}
@Test fun changingGameFexPresetPreservesDefaultAndOtherGameSettings() {
SessionPrefs.setFexPreset(context, "PERFORMANCE_TSO")
val original = GameEnvironment.withGameFexPreset(
GameEnvironment.Config(
shared = mapOf("PROTON_LOG" to "1"),
games = mapOf("42" to mapOf("DXVK_HUD" to "fps")),
),
"43",
"COMPATIBILITY",
)
GameEnvironmentStore.save(context, original)
GameEnvironmentStore.setGameFexPreset(context, "42", "STABILITY")
val saved = GameEnvironmentStore.read(context)
assertEquals("STABILITY", GameEnvironment.gameFexPreset(saved, "42"))
assertEquals("fps", saved.entries("42")["DXVK_HUD"])
assertEquals(original.shared, saved.shared)
assertEquals(original.entries("43"), saved.entries("43"))
assertEquals("PERFORMANCE_TSO", SessionPrefs.fexPreset(context))
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
assertEquals(saved.games, published.games)
assertEquals("0", published.shared["FEX_HALFBARRIERTSOENABLED"])
assertEquals("1", published.entries("42")["FEX_HALFBARRIERTSOENABLED"])
}
@Test fun gameFexDefaultsAndInheritanceRemainDistinct() {
SessionPrefs.setFexPreset(context, "EXTREME")
val original = GameEnvironment.Config(
games = mapOf("42" to mapOf("DXVK_HUD" to "fps")),
)
GameEnvironmentStore.save(context, original)
GameEnvironmentStore.setGameFexPreset(context, "42", "")
val explicitDefaults = GameEnvironmentStore.read(context)
assertEquals("", GameEnvironment.gameFexPreset(explicitDefaults, "42"))
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
assertTrue(published.entries("42").containsKey("FEX_SMCCHECKS"))
assertNull(published.entries("42")["FEX_SMCCHECKS"])
GameEnvironmentStore.setGameFexPreset(context, "42", null)
assertEquals(original, GameEnvironmentStore.read(context))
val inherited = GameEnvironmentStore.decode(JSONObject(guest.readText()))
assertFalse(inherited.entries("42").containsKey("FEX_SMCCHECKS"))
assertEquals("none", inherited.shared["FEX_SMCCHECKS"])
assertEquals("EXTREME", SessionPrefs.fexPreset(context))
}
@Test fun invalidGamePresetEditsCannotChangeSavedOrPublishedSettings() {
val original = GameEnvironment.Config(shared = mapOf("PROTON_LOG" to "1"))
GameEnvironmentStore.save(context, original)
val published = guest.readText()
assertThrows(IllegalArgumentException::class.java) {
GameEnvironmentStore.setGameFexPreset(context, "", "STABILITY")
}
assertThrows(IllegalArgumentException::class.java) {
GameEnvironmentStore.setGameFexPreset(context, "42", GameEnvironment.CUSTOM_FEX_PRESET)
}
assertEquals(original, GameEnvironmentStore.read(context))
assertEquals(published, guest.readText())
}
@Test fun targetedFexUpdateDoesNotRestoreEnvironmentClearedByACompletedReset() {
val original = GameEnvironment.withGameFexPreset(
GameEnvironment.Config(games = mapOf("42" to mapOf("DXVK_HUD" to "fps"))),
"42",
"COMPATIBILITY",
)
GameEnvironmentStore.save(context, original)
val staleEditorSnapshot = GameEnvironmentStore.read(context)
val resetEnvironment = staleEditorSnapshot.withEntries("42", emptyMap())
GameEnvironmentStore.save(context, resetEnvironment)
GameEnvironmentStore.setGameFexPreset(context, "43", "PERFORMANCE_TSO")
val saved = GameEnvironmentStore.read(context)
assertTrue(saved.entries("42").isEmpty())
assertEquals("PERFORMANCE_TSO", GameEnvironment.gameFexPreset(saved, "43"))
assertFalse(saved.games.containsKey("42"))
}
@Test fun unchosenFexPresetPublishesPerformanceTso() {
GameEnvironmentStore.save(context, GameEnvironment.Config())
val shared = JSONObject(guest.readText()).getJSONObject("shared")
@@ -0,0 +1,17 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class GameProfileFollowStateTest {
@Test fun reopeningGamesPageResumesFollowingSteamAfterManualInspection() {
val state = GameProfileFollowState()
state.selectManually()
assertFalse(state.followsSteam)
state.resumeFollowingSteam()
assertTrue(state.followsSteam)
}
}
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class GameProfileInventoryCacheTest {
@Test
fun loadsOnlyOncePerRevision() {
val cache = GameProfileInventoryCache<String>()
var loads = 0
assertEquals(listOf("one"), cache.get(0) { loads++; listOf("one") })
assertEquals(listOf("one"), cache.get(0) { loads++; listOf("two") })
assertEquals(1, loads)
assertEquals(listOf("two"), cache.get(1) { loads++; listOf("two") })
assertEquals(2, loads)
}
@Test
fun failedLoadIsRetriedInsteadOfCached() {
val cache = GameProfileInventoryCache<String>()
var loads = 0
assertThrows(IllegalStateException::class.java) {
cache.get(0) { loads++; error("inventory unavailable") }
}
assertEquals(listOf("recovered"), cache.get(0) { loads++; listOf("recovered") })
assertEquals(2, loads)
}
@Test
fun failedNewRevisionDoesNotReuseThePreviousInventory() {
val cache = GameProfileInventoryCache<String>()
assertEquals(listOf("old"), cache.get(0) { listOf("old") })
assertThrows(IllegalStateException::class.java) {
cache.get(1) { error("inventory unavailable") }
}
assertEquals(listOf("new"), cache.get(1) { listOf("new") })
assertEquals(listOf("new"), cache.get(1) { error("should use cached inventory") })
}
@Test
fun emptyInventoryIsCachedUntilTheRevisionChanges() {
val cache = GameProfileInventoryCache<String>()
assertEquals(emptyList<String>(), cache.get(0) { emptyList() })
assertEquals(emptyList<String>(), cache.get(0) { error("should use cached inventory") })
assertEquals(listOf("installed"), cache.get(1) { listOf("installed") })
}
@Test(timeout = 10_000)
fun reopeningDuringAScanQueuesFreshInventoryWithoutWaitingAndRejectsOldResults() {
data class Refresh(val appId: Long, val inventoryRevision: Long)
val cache = GameProfileInventoryCache<String>()
val coordinator = LatestRequestCoordinator<Refresh>()
val scanStarted = CountDownLatch(1)
val finishScan = CountDownLatch(1)
val worker = Executors.newSingleThreadExecutor()
try {
assertTrue(coordinator.submit(Refresh(42, 0)))
val first = coordinator.takeLatest()!!
val oldInventory = worker.submit<List<String>> {
cache.get(first.value.inventoryRevision) {
scanStarted.countDown()
check(finishScan.await(5, TimeUnit.SECONDS))
listOf("old")
}
}
assertTrue(scanStarted.await(5, TimeUnit.SECONDS))
assertFalse(coordinator.submit(Refresh(42, 1)))
assertFalse(oldInventory.isDone)
assertFalse(coordinator.isLatest(first))
finishScan.countDown()
assertEquals(listOf("old"), oldInventory.get(5, TimeUnit.SECONDS))
assertFalse(coordinator.isLatest(first))
val reopened = coordinator.takeLatest()!!
val newInventory = worker.submit<List<String>> {
cache.get(reopened.value.inventoryRevision) { listOf("new") }
}
assertEquals(listOf("new"), newInventory.get(5, TimeUnit.SECONDS))
assertTrue(coordinator.isLatest(reopened))
assertNull(coordinator.takeLatest())
} finally {
finishScan.countDown()
worker.shutdownNow()
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS))
}
}
}
@@ -0,0 +1,103 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
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
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class GameProfileManagerTest {
private lateinit var context: Context
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
File(context.filesDir, "game-environment.json").delete()
File(context.filesDir, "wincomponents.json").delete()
File(context.filesDir, "components").deleteRecursively()
File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat").deleteRecursively()
}
@Test
fun resetClearsOnlyTheSelectedGameProfile() {
val game = "42"
val other = "43"
GameEnvironmentStore.save(
context,
GameEnvironment.Config(
games = mapOf(
game to mapOf("DXVK_HUD" to "fps"),
other to mapOf("DXVK_HUD" to "compiler"),
),
),
)
ProtonDefault.requestGame(context, game, proton("game"))
ProtonDefault.requestGame(context, other, proton("other"))
seedComponentProfiles(game, other)
SessionPrefs.setGameTextureAnisotropy(context, game, 4)
SessionPrefs.setGameTextureAnisotropy(context, other, 8)
WinComponents.setPicks(context, game, listOf("vcrun2022"))
WinComponents.setPicks(context, other, listOf("dotnet48"))
GameProfileManager.reset(context, game)
val environment = GameEnvironmentStore.read(context)
assertFalse(environment.games.containsKey(game))
assertEquals("compiler", environment.entries(other)["DXVK_HUD"])
assertNull(ProtonDefault.gameChoice(context, game))
assertEquals("other", ProtonDefault.gameChoice(context, other)?.dir)
assertEquals(emptyMap<String, String>(), ComponentsManager.gameComponents(context, game))
assertEquals("other.wcp", ComponentsManager.gameComponents(context, other)["dxvk"])
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, game))
assertEquals(8, SessionPrefs.gameTextureFiltering(context, other).anisotropy)
assertEquals(emptyList<String>(), WinComponents.picks(context, game))
assertEquals(listOf("dotnet48"), WinComponents.picks(context, other))
}
@Test
fun resetRejectsEmptyScopeWithoutChangingSharedSettings() {
GameEnvironmentStore.save(
context,
GameEnvironment.Config(shared = mapOf("DXVK_HUD" to "fps")),
)
assertThrows(IllegalArgumentException::class.java) {
GameProfileManager.reset(context, "")
}
assertEquals("fps", GameEnvironmentStore.read(context).shared["DXVK_HUD"])
}
private fun proton(name: String) = ComponentsManager.Proton(
id = name,
name = name,
dir = File(context.filesDir, name),
guestPath = "/proton/$name",
version = "1",
valve = false,
)
private fun seedComponentProfiles(game: String, other: String) {
val components = File(context.filesDir, "components").apply { mkdirs() }
File(components, "state.json").writeText(
JSONObject().put(
"profiles",
JSONObject()
.put(game, JSONObject().put("dxvk", "game.wcp"))
.put(other, JSONObject().put("dxvk", "other.wcp")),
).toString(),
)
}
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GameProfileRefreshStateTest {
@Test
fun firstFailureKeepsInitialStateWithoutCachedData() {
val state = GameProfileRefreshState<String>().failed()
assertTrue(state.failed)
assertFalse(state.loaded)
assertNull(state.data)
}
@Test
fun failureAfterSuccessPreservesCachedData() {
val state = GameProfileRefreshState<String>().succeeded("cached profiles").failed()
assertTrue(state.failed)
assertTrue(state.loaded)
assertEquals("cached profiles", state.data)
}
@Test
fun successAfterFailureReplacesCachedDataAndClearsWarning() {
val state = GameProfileRefreshState<String>()
.succeeded("old profiles")
.failed()
.succeeded("new profiles")
assertFalse(state.failed)
assertTrue(state.loaded)
assertEquals("new profiles", state.data)
}
@Test
fun retryingClearsWarningWithoutDiscardingCachedData() {
val state = GameProfileRefreshState<String>().succeeded("cached profiles").failed().retrying()
assertFalse(state.failed)
assertTrue(state.loaded)
assertEquals("cached profiles", state.data)
}
}
@@ -0,0 +1,68 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LatestRequestCoordinatorTest {
@Test fun coalescesPendingRequestsToTheLatestValue() {
val coordinator = LatestRequestCoordinator<String>()
assertTrue(coordinator.submit("first"))
val first = coordinator.takeLatest()!!
assertFalse(coordinator.submit("second"))
assertFalse(coordinator.submit("third"))
assertFalse(coordinator.isLatest(first))
val latest = coordinator.takeLatest()!!
assertEquals("third", latest.value)
assertTrue(coordinator.isLatest(latest))
assertNull(coordinator.takeLatest())
}
@Test fun requestAfterWorkerBecomesIdleStartsAnotherWorker() {
val coordinator = LatestRequestCoordinator<String>()
assertTrue(coordinator.submit("first"))
coordinator.takeLatest()
assertNull(coordinator.takeLatest())
assertTrue(coordinator.submit("second"))
assertEquals("second", coordinator.takeLatest()!!.value)
}
@Test fun newerRequestInvalidatesCompletedResultBeforeItIsApplied() {
val coordinator = LatestRequestCoordinator<String>()
assertTrue(coordinator.submit("first"))
val completed = coordinator.takeLatest()!!
assertFalse(coordinator.submit("second"))
assertFalse(coordinator.isLatest(completed))
assertEquals("second", coordinator.takeLatest()!!.value)
}
@Test fun profileSwitchInvalidatesAnOlderFailedRefresh() {
val coordinator = LatestRequestCoordinator<Long?>()
assertTrue(coordinator.submit(42L))
val failedRefresh = coordinator.takeLatest()!!
assertFalse(coordinator.submit(43L))
assertFalse(coordinator.isLatest(failedRefresh))
assertEquals(43L, coordinator.takeLatest()!!.value)
}
@Test fun repeatedPollDoesNotInvalidateEquivalentInFlightResult() {
val coordinator = LatestRequestCoordinator<String>()
assertTrue(coordinator.submit("same"))
val inFlight = coordinator.takeLatest()!!
assertFalse(coordinator.submit("same"))
assertTrue(coordinator.isLatest(inFlight))
assertEquals("same", coordinator.takeLatest()!!.value)
}
}
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -7,6 +8,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
@RunWith(RobolectricTestRunner::class)
@@ -59,4 +61,46 @@ class ProtonDefaultTest {
assertNull(ProtonDefault.chosen(ProtonDefault.parseState("{"), ProtonDefault.parseRequest("[]")))
assertNull(ProtonDefault.parseRequest("""{"seq": 0, "dir": "x"}"""))
}
@Test
fun perGameChoicesPreserveCustomizeAndInheritSemantics() {
val games = ProtonDefault.parseGames(
"""{"version":1,"games":{"42":{"valve":false,"dir":"GE-Proton11-7"},"43":null,"0":null,"bad":{},"44":{"valve":"false","dir":"GE-Proton9-1"}}}""",
)
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton11-7"), games["42"])
assertTrue(games.containsKey("43"))
assertNull(games["43"])
assertFalse(games.containsKey("0"))
assertFalse(games.containsKey("bad"))
assertFalse(games.containsKey("44"))
assertTrue(ProtonDefault.parseGames("""{"version":2,"games":{"42":null}}""").isEmpty())
}
@Test
fun perGameRequestsPersistCustomizeThenInherit() {
val context = RuntimeEnvironment.getApplication()
val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json")
file.parentFile?.deleteRecursively()
val selected = proton("GE-Proton11-7", false)
ProtonDefault.requestGame(context, "42", selected)
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton11-7"), ProtonDefault.parseGames(file.readText())["42"])
ProtonDefault.requestGame(context, "42", null)
val inherited = ProtonDefault.parseGames(file.readText())
assertTrue(inherited.containsKey("42"))
assertNull(inherited["42"])
}
@Test
fun missingPerGameProtonIdentityRemainsStoredButUnresolved() {
val context = RuntimeEnvironment.getApplication()
val file = File(LinuxRuntime.rootDir(context), "root/.local/share/droiddeck-compat/games.json")
file.parentFile?.deleteRecursively()
ProtonDefault.requestGame(context, "42", proton("GE-Proton9-1", false))
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.gameChoice(context, "42"))
assertNull(ProtonDefault.gameSelectedId(context, "42", listOf(proton("GE-Proton11-7", false))))
assertEquals(ProtonDefault.GameChoice(false, "GE-Proton9-1"), ProtonDefault.parseGames(file.readText())["42"])
}
}
@@ -0,0 +1,128 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.frontend.Library
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class SelectedGameProfileTest {
private val proton = ComponentsManager.Proton("p1", "Proton 10", File("p1"), "/p1", "10", true)
private val snapshot = ComponentsManager.Snapshot(
protons = listOf(
ComponentsManager.ProtonView(
proton = proton,
components = mapOf(
"fex" to component("FEX General"),
"dxvk" to component("DXVK General"),
"vkd3d" to component("VKD3D General"),
),
originals = emptyList(),
inUseByGame = false,
),
),
packages = listOf(ComponentsManager.Package("dxvk.wcp", "dxvk", "DXVK Game", "", 1)),
)
private val game = Library.SteamGame(3180310, "Billies Wheelie", null, "internal")
@Test fun inheritedValuesUseGeneralProfile() {
val profile = resolve()
assertEquals("Billies Wheelie", profile!!.name)
assertEquals("PERFORMANCE_TSO", profile.fexPreset.value)
assertTrue(profile.fexPreset.inherited)
assertEquals("Proton 10", profile.proton.value)
assertTrue(profile.proton.inherited)
assertEquals("p1", profile.protonId)
assertEquals("DXVK General", profile.components.getValue("dxvk").value)
assertTrue(profile.components.getValue("dxvk").inherited)
assertEquals(emptyMap<String, String>(), profile.componentFiles)
assertEquals(0, profile.environmentOverrides)
assertEquals(emptyList<String>(), profile.windowsComponentNames)
assertFalse(profile.hasOverrides)
}
@Test fun explicitValuesOverrideGeneralProfile() {
val preset = GameEnvironment.withGameFexPreset(GameEnvironment.Config(), game.profileKey, "STABILITY")
val environment = preset.withEntries(game.profileKey, preset.entries(game.profileKey) + ("PROTON_LOG" to "1"))
val profile = resolve(
environment = environment,
gameChoice = ProtonDefault.GameChoice(true, "p1"),
gameComponents = mapOf("dxvk" to "dxvk.wcp"),
)
assertEquals("STABILITY", profile!!.fexPreset.value)
assertFalse(profile.fexPreset.inherited)
assertFalse(profile.proton.inherited)
assertEquals("DXVK Game", profile.components.getValue("dxvk").value)
assertFalse(profile.components.getValue("dxvk").inherited)
assertEquals("dxvk.wcp", profile.componentFiles.getValue("dxvk"))
assertEquals(1, profile.environmentOverrides)
assertTrue(profile.hasOverrides)
}
@Test fun windowsComponentsMakeProfileCustom() {
val profile = resolve(gameWindowsComponents = listOf("oalinst_dll", "vcredist2010_dll"))
assertEquals(listOf("OpenAL", "Visual C++ 2010"), profile!!.windowsComponentNames)
assertTrue(profile.hasOverrides)
}
@Test fun unknownSelectedAppIsIgnored() {
assertNull(resolve(selectedAppId = 42))
}
@Test fun switchingProfilesResolvesEachGamesFexPresetWithoutChangingSettings() {
val other = Library.SteamGame(42, "Other game", null, "internal")
val games = listOf(game, other)
val environment = GameEnvironment.withGameFexPreset(
GameEnvironment.Config(), game.profileKey, "STABILITY",
)
assertEquals(ProfileValue("STABILITY", false),
resolve(games = games, environment = environment)!!.fexPreset)
assertEquals(ProfileValue("PERFORMANCE_TSO", true),
resolve(selectedAppId = 42, games = games, environment = environment)!!.fexPreset)
assertEquals(ProfileValue("STABILITY", false),
resolve(games = games, environment = environment)!!.fexPreset)
assertNull(GameEnvironment.gameFexPreset(environment, other.profileKey))
}
@Test fun customAndExplicitDefaultFexPresetsAreNotShownAsInherited() {
val custom = GameEnvironment.Config(
games = mapOf(game.profileKey to mapOf("FEX_TSOENABLED" to "0")),
)
assertEquals(ProfileValue(GameEnvironment.CUSTOM_FEX_PRESET, false),
resolve(environment = custom)!!.fexPreset)
val defaults = GameEnvironment.withGameFexPreset(custom, game.profileKey, "")
assertEquals(ProfileValue("", false), resolve(environment = defaults)!!.fexPreset)
val inherited = GameEnvironment.withGameFexPreset(defaults, game.profileKey, null)
assertEquals(ProfileValue("PERFORMANCE_TSO", true), resolve(environment = inherited)!!.fexPreset)
}
private fun resolve(
selectedAppId: Long = 3180310,
games: List<Library.SteamGame> = listOf(game),
environment: GameEnvironment.Config = GameEnvironment.Config(),
gameChoice: ProtonDefault.GameChoice? = null,
gameComponents: Map<String, String> = emptyMap(),
gameWindowsComponents: List<String> = emptyList(),
) = SelectedGameProfile.resolve(
selectedAppId = selectedAppId,
games = games,
generalFexPreset = "PERFORMANCE_TSO",
environment = environment,
snapshot = snapshot,
generalProtonId = "p1",
gameProtonChoice = gameChoice,
gameProtonId = "p1",
gameComponents = gameComponents,
gameWindowsComponents = gameWindowsComponents,
)
private fun component(label: String) =
ComponentsManager.Component(label, "", "Changed outside Components", label, null, null)
}
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
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
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class TextureFilteringPrefsTest {
private lateinit var context: Context
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
File(context.filesDir, "game-environment.json").delete()
File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").delete()
}
@Test fun gameChoicesRemainSeparateFromDefaultAndCanInheritAgain() {
SessionPrefs.setTextureAnisotropy(context, 16)
SessionPrefs.setTextureLodBias(context, TextureFiltering.LOD_BIAS_AUTO)
SessionPrefs.setGameTextureAnisotropy(context, "42", 4)
SessionPrefs.setGameTextureLodBias(context, "42", "-0.5")
assertEquals(16, SessionPrefs.textureAnisotropy(context))
assertEquals(TextureFiltering.LOD_BIAS_AUTO, SessionPrefs.textureLodBias(context))
assertEquals(SessionPrefs.GameTextureFiltering(4, "-0.5"), SessionPrefs.gameTextureFiltering(context, "42"))
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "43"))
SessionPrefs.setGameTextureAnisotropy(context, "42", null)
SessionPrefs.setGameTextureLodBias(context, "42", null)
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "42"))
assertFalse(SessionPrefs.gameTextureFiltering(context).containsKey("42"))
}
@Test fun publishedDxvkOptionsUseGameOverridesWithoutChangingDefault() {
SessionPrefs.setTextureAnisotropy(context, 16)
SessionPrefs.setGameTextureAnisotropy(context, "42", 0)
SessionPrefs.setGameTextureLodBias(context, "42", TextureFiltering.LOD_BIAS_OFF)
val published = JSONObject(
File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json").readText(),
)
assertTrue(published.getString(GameEnvironmentStore.DXVK_CONFIG).contains("samplerAnisotropy = 16"))
assertEquals("", published.getJSONObject(GameEnvironmentStore.DXVK_CONFIG_GAMES).getString("42"))
assertNull(SessionPrefs.gameTextureFiltering(context, "43").anisotropy)
}
@Test fun clearingGameChoicesPreservesOtherProfiles() {
SessionPrefs.setGameTextureAnisotropy(context, "42", 4)
SessionPrefs.setGameTextureLodBias(context, "42", "-0.5")
SessionPrefs.setGameTextureAnisotropy(context, "43", 8)
SessionPrefs.clearGameTextureFiltering(context, "42")
assertEquals(SessionPrefs.GameTextureFiltering(), SessionPrefs.gameTextureFiltering(context, "42"))
assertEquals(8, SessionPrefs.gameTextureFiltering(context, "43").anisotropy)
}
}
@@ -0,0 +1,59 @@
package com.droiddeck.launcher.ui
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.droiddeck.launcher.R
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.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28])
class ComponentsPageProfileTest {
@get:Rule val compose = createRobolectricComposeRule()
@Test fun gameProfilesCoexistWithTheDefaultComponentStack() {
val context = RuntimeEnvironment.getApplication()
compose.setContent {
DroidDeckTheme {
ComponentsPage(
snapshot = null,
catalog = emptyList(),
catalogAt = 0,
protonId = null,
comp = PROTON_TAB,
checking = false,
busy = null,
downloads = emptyMap(),
onProton = {},
onComp = {},
onSwap = {},
onRestore = {},
onCancelQueued = {},
onDeletePackage = {},
onDeleteOriginal = {},
onDownload = {},
onRefresh = {},
onImport = {},
onBack = {},
games = listOf(Library.SteamGame(42, "Game A", null, "internal")),
requestInitialFocus = false,
)
}
}
val stackHeading = context.getString(R.string.comp_this_proton).uppercase()
compose.onNodeWithText(stackHeading).assertExists()
compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick()
compose.onNodeWithText("Game A").performClick()
compose.onNodeWithText(context.getString(R.string.comp_game_hint)).assertExists()
compose.onNodeWithText(stackHeading).assertDoesNotExist()
compose.onNodeWithText("Game A").performClick()
compose.onNodeWithText(context.getString(R.string.comp_scope_general)).performClick()
compose.onNodeWithText(stackHeading).assertExists()
}
}
@@ -0,0 +1,63 @@
package com.droiddeck.launcher.ui
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onLast
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.session.GameEnvironmentStore
import org.junit.Assert.assertEquals
import org.junit.Rule
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 = [28])
class GameEnvironmentEditorTest {
@get:Rule val compose = createRobolectricComposeRule()
@Test fun backgroundSelectionCannotRetargetAnOpenVariableEdit() {
val context: Context = RuntimeEnvironment.getApplication()
val variable = "MESA_SHADER_CACHE_MAX_SIZE"
GameEnvironmentStore.save(context, GameEnvironment.Config(games = mapOf(
"42" to mapOf(variable to "512M"),
"43" to mapOf(variable to "2G"),
)))
val selectedScope = mutableStateOf("42")
compose.setContent {
DroidDeckTheme { GameEnvironmentRow(initialScope = selectedScope.value) }
}
compose.onNodeWithText(context.getString(R.string.game_env_edit)).performClick()
compose.waitUntil(5_000) {
compose.onAllNodes(hasText("512M")).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("512M").performScrollTo().performClick()
compose.onNodeWithText(context.getString(R.string.game_env_change)).performClick()
compose.onAllNodesWithText("512M").onLast().performClick()
compose.onNodeWithText("4G").performClick()
compose.runOnIdle { selectedScope.value = "43" }
compose.onNodeWithText(context.getString(R.string.game_env_save)).performClick()
compose.waitUntil(5_000) {
GameEnvironmentStore.read(context).entries("42")[variable] == "4G"
}
assertEquals("2G", GameEnvironmentStore.read(context).entries("43")[variable])
compose.onNodeWithText("4G").assertExists()
compose.onNodeWithText(context.getString(R.string.game_env_done)).performScrollTo().performClick()
compose.waitForIdle()
compose.onNodeWithText(context.getString(R.string.game_env_edit)).performClick()
compose.waitUntil(5_000) {
compose.onAllNodes(hasText("2G")).fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("2G").assertExists()
}
}
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.ui
import android.content.ComponentName
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createComposeRule
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
fun createRobolectricComposeRule() = createComposeRule().also {
val context = RuntimeEnvironment.getApplication()
// Register only in Robolectric, without adding a host activity to either APK variant.
shadowOf(context.packageManager).addActivityIfNotPresent(
ComponentName(context, ComponentActivity::class.java),
)
}
+50 -10
View File
@@ -3,16 +3,52 @@
Open **Games → Game environment**, directly below **FEX preset**, in Steam
settings or the in-session drawer. Choose all Proton games, an installed game, or enter a Steam app ID. For a
non-Steam shortcut, use its numeric `compatdata` directory ID (unsigned 32-bit).
The drawer opens the editor on its selected game profile. That editing scope stays
fixed if Steam's selected game changes in the background; use the editor's own
profile picker to switch deliberately.
Changes apply on the next game launch, including when Steam is already running.
A running game must be restarted. Native Linux games and Steam itself are outside
this editor's scope.
The Components page also has a **Configuration** dropdown. **Default profile** owns the
default Proton and FEX preset. An installed game can inherit those defaults or
choose its own Proton and FEX preset. Proton choices are applied through Steam's
per-app compatibility mapping, including while Steam is running when its live
compatibility helper is attached. A newly installed Proton that the running
client has not discovered yet applies when Steam next starts.
Each game can also override the stored FEX, DXVK and VKD3D-Proton package used
by its selected Proton. The launch wrapper applies that game’s component files
immediately before Proton starts, removes managed files that are absent from the
selection, and restores Default component choices for the next game. **Use
Default** removes the game override. Component packages must first be downloaded
or imported on the Default Components page.
Before publishing game component choices, DroidDeck preserves each Proton build's
original components, including an explicitly empty bundle when a component is not
shipped. Those restore choices remain after the last override is cleared. A missing
or unreadable original is an error, not permission to delete the component's files.
If an original is missing after a build may already have used a game override,
DroidDeck refuses to capture those potentially modified files as originals.
Component selections and the launch table are saved with a recovery record.
Publishing the launch table commits the selection: if the app stops before its
settings file is updated, the next component read recovers the matching settings.
An interruption before publication leaves the previous selections in place.
Unused staged packages are removed only after both files have been saved.
**Reset game profile** removes all overrides owned by the selected game: Proton,
FEX preset, component packages, environment variables, texture filtering and
Windows components. The game then inherits the Default profile again.
The order is inherited process environment, selected FEX preset and built-in
settings, shared edits, then game-specific edits. Editor entries therefore win
over matching Steam launch-option variables. **Remove** explicitly unsets a
variable. **Restore inherited settings** removes the override. **Reset this
profile** clears only that profile's overrides. Values are literal strings, with
no shell expansion; quotes are only needed when the consuming program expects them.
variable. **Restore inherited settings** removes the override. The environment
editor's **Clear environment overrides** action clears only the selected
environment entries and is intentionally narrower than **Reset game profile**.
Values are literal strings, with no shell expansion; quotes are only needed when
the consuming program expects them.
## Defaults and available suggestions
@@ -71,14 +107,18 @@ wrapper and adopted third-party Proton wrappers execute `droiddeck-game-env`,
which reads that snapshot for each real game launch and uses `execvpe` to start
Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed
configuration falls back to the inherited environment without evaluating its
contents. Signed non-Steam prefix IDs are normalized to unsigned IDs.
contents. Both component and environment selection normalize signed non-Steam
prefix IDs to unsigned IDs.
The published file also carries a `dxvkConfig` string the app's own file never
has: the session menu's texture filtering (Effects page; `core/TextureFiltering`)
as `d3d9/d3d11.samplerAnisotropy` and `samplerLodBias` options. The launcher
appends it to `DXVK_CONFIG` after the profiles, so a user's own `DXVK_CONFIG`
entry keeps its options. "Auto" texture sharpness is `-log2(panel / session)`,
derived when the session is sized, and applies from the next launch.
The published file also carries generated `dxvkConfig` and `dxvkConfigGames`
values the app's own file never has. The session menu's texture filtering
(`core/TextureFiltering`) becomes `d3d9/d3d11.samplerAnisotropy` and
`samplerLodBias` options. Default choices apply to games without overrides; each
game can save either choice independently or return it to **Use Default**. The
launcher appends the selected options to `DXVK_CONFIG` after the environment
profile, so a user's own `DXVK_CONFIG` entry keeps its options. "Auto" texture
sharpness is `-log2(panel / session)`, derived when the session is sized, and
applies from the next launch.
Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-Work/vkd3d-proton/blob/master/libs/vkd3d/device.c),
[VKD3D options](https://github.com/HansKristian-Work/vkd3d-proton#environment-variables),
@@ -453,6 +453,34 @@ def cdp_eval(agent_dir, expression, target=None, await_promise=True, timeout=15)
ws.close()
def steam_selection(agent_dir, timeout=5):
result = cdp_eval(
agent_dir,
"""(() => {
let node = document.activeElement;
while (node) {
for (const name of ["data-id", "data-appid"]) {
const value = node.getAttribute?.(name);
if (/^[1-9][0-9]{0,9}$/.test(value || "")) {
const appId = Number(value);
if (Number.isSafeInteger(appId) && appId <= 4294967295) return value;
}
}
node = node.parentElement;
}
return null;
})()""",
target="Steam Big Picture Mode",
timeout=timeout,
)
value = result.get("value")
if value is None:
return {"appId": None}
if not isinstance(value, str) or not value.isdigit() or not 0 < int(value) <= 4294967295:
raise AgentError("INVALID_SELECTION", "Steam returned an invalid selected app id")
return {"appId": int(value)}
# --- Game processes ------------------------------------------------------------------------------
def game_pids(app_id):
@@ -624,6 +652,8 @@ def handle(agent_dir, req):
raise AgentError("INVALID_REQUEST", "cdp eval needs an expression")
return cdp_eval(agent_dir, expression, req.get("target"), bool(req.get("awaitPromise", True)),
request_timeout(req, 15))
if kind == "steam-selection":
return steam_selection(agent_dir, request_timeout(req, 5))
if kind == "quit":
return quit_game(agent_dir, req.get("appId"), request_timeout(req, 15))
raise AgentError("UNKNOWN_REQUEST", "Unknown agent request '%s'" % kind)
@@ -168,7 +168,10 @@ def apply_config(env, config, app_id, fixes=None):
raise ValueError("invalid environment value")
# The app's texture filtering (Display page), as DXVK options appended after the profiles so
# a DXVK_CONFIG entry of the user's keeps its own.
dxvk = config.get("dxvkConfig")
game_dxvk = config.get("dxvkConfigGames", {})
if not isinstance(game_dxvk, dict):
raise ValueError("invalid game dxvk configuration")
dxvk = game_dxvk.get(app_id, config.get("dxvkConfig"))
if dxvk is not None:
if not isinstance(dxvk, str) or "\0" in dxvk or len(dxvk) > 8192:
raise ValueError("invalid dxvk configuration")
@@ -447,10 +447,15 @@ class Helper:
self.installed = installed
picks = COMPAT["valve_picks"](self.steam_root)
apps = sorted(set(installed) | set(picks) | set(state.get("auto", {})), key=COMPAT["app_order"])
overrides = COMPAT["requested_games"](
COMPAT["game_requests"](), catalog, [t["name"] for t in live])
apps = sorted(set(apps) | set(overrides), key=COMPAT["app_order"])
current = dict(page.compat("many(%s)" % json.dumps(apps)) or {})
current["0"] = top["selected"]
native = COMPAT["native_targets"](self.steam_root, installed)
changes, auto = COMPAT["plan_mapping"](current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native, picks)
changes, auto = COMPAT["plan_mapping"](
current, installed, [t["name"] for t in live], default,
COMPAT["followers"](state, current), native, picks, overrides)
for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])):
page.compat("set(%d, %s)" % (int(app), json.dumps(tool)))
say("%s -> %s" % ("default" if app == "0" else "app %s" % app, tool))
@@ -489,7 +494,7 @@ class Helper:
if self.page.ws in ready:
due = any(called(e, BINDING) for e in self.drain())
if (watcher in ready or watcher.fileno() < 0) and watcher.changed():
due = self.installed != COMPAT["installed_apps"](self.steam_root) or self.request_waiting()
due = True
if due:
time.sleep(0.3)
if watcher.fileno() >= 0 and select.select([watcher], [], [], 0)[0]:
@@ -519,7 +524,8 @@ def main():
say("no devtools port for this session; the client's mapping is kept through config.vdf")
return 0
os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True)
watcher = Watcher([COMPAT["state_path"]("request.json")] + [os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")])
watcher = Watcher([COMPAT["state_path"]("request.json"), COMPAT["state_path"]("games.json")] +
[os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")])
# The client has its own SteamOS manager when started as SteamOS (droiddeck-session's Deck mode).
steamos = os.environ.get("BL_STEAMDECK") == "1" and os.environ.get("BL_STEAM_UI") != "desktop"
helper = Helper(steam_root, port, steamos)
@@ -92,10 +92,12 @@ TOOL_MANIFEST_VDF = """"manifest"
# relies on compatdata/0 itself. The launch-time check below is the protection instead.
# The Components page's choices, enforced in the one place every game launch passes: the app keeps
# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of
# each package in use. A file that no longer matches - whatever changed it - is put back before
# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build
# differs and the Proton's own new files are left alone until the user chooses again.
# desired.tsv (scope, tool dir, Proton build, component, file, sha256, source copy) and an unpacked
# copy of each package in use. A game's rows replace General rows for that component. A `-` source
# removes a managed file absent from the selected package, so one game's package cannot leak into
# the next game. A file that no longer matches - whatever changed it - is put back before Proton
# starts. Only the matching Proton build is changed; after a Steam update the build differs and the
# Proton's own new files are left alone until the user chooses again.
# A store game (GOG, Epic, Amazon) is started through the launcher .bat DroidDeck wrote beside it.
# An Epic game needs a fresh sign-in code, which only the app can mint: droiddeck-store-launch asks
# for it and the app writes it into the game's folder, so a launch from the client's own Play
@@ -149,21 +151,56 @@ bl_components() {
[ "${verb:-}" = waitforexitandrun ] || return 0
case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac
[ -f "$cm_list" ] || return 0
cm_app=${STEAM_COMPAT_DATA_PATH%/}
cm_app=${STEAM_COMPAT_DATA_PATH}
while [[ "$cm_app" == */ ]]; do cm_app=${cm_app%/}; done
cm_app=${cm_app##*/}
if [[ "$cm_app" =~ ^(-?)([0-9]+)$ ]]; then
cm_sign=${BASH_REMATCH[1]}
cm_digits=${BASH_REMATCH[2]}
while [[ ${#cm_digits} -gt 1 && "$cm_digits" == 0* ]]; do cm_digits=${cm_digits#0}; done
if [[ ${#cm_digits} -le 10 ]]; then
cm_app=$((10#$cm_digits))
[ "$cm_sign" = "-" ] && cm_app=$((-cm_app))
if (( cm_app >= -2147483648 && cm_app <= 4294967295 && cm_app != 0 )); then
(( cm_app < 0 )) && cm_app=$((cm_app + 4294967296))
else
cm_app=""
fi
else
cm_app=""
fi
else
cm_app=""
fi
if [ -z "$cm_app" ]; then
echo "droiddeck-proton: components: invalid game prefix, left as it is" >&2
return 0
fi
cm_tool=${base##*/}
cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null)
cm_fixed=0
cm_checked=0
cm_selected=$(awk -F '\\t' -v app="$cm_app" -v tool="$cm_tool" -v build="$cm_build" '
$2 == tool && $3 == build {
rows[++count] = $0
scopes[count] = $1
comps[count] = $4
if ($1 == app) specific[$4] = 1
}
END {
for (i = 1; i <= count; i++)
if (scopes[i] == app || (scopes[i] == "*" && !specific[comps[i]])) print rows[i]
}
' "$cm_list")
# One sha256sum for every file: each program started here pays a dozen traced library lookups
# under the game's search path before it does anything.
local cm_rows=() cm_files=() row h p
local -A cm_sums=()
while IFS=$'\\t' read -r t want comp rel sum src; do
[ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue
while IFS=$'\\t' read -r scope t want comp rel sum src; do
[ -n "$scope" ] || continue
cm_rows+=("$comp"$'\\t'"$rel"$'\\t'"$sum"$'\\t'"$src")
[ -f "$base/$rel" ] && cm_files+=("$base/$rel")
done < "$cm_list"
[ "$src" != "-" ] && [ -f "$base/$rel" ] && cm_files+=("$base/$rel")
done <<< "$cm_selected"
if [ ${#cm_files[@]} -gt 0 ]; then
while read -r h p; do cm_sums["$p"]=$h; done < <(sha256sum -- "${cm_files[@]}" 2>/dev/null)
fi
@@ -171,6 +208,10 @@ bl_components() {
IFS=$'\\t' read -r comp rel sum src <<< "$row"
cm_checked=$((cm_checked + 1))
f="$base/$rel"
if [ "$src" = "-" ]; then
if [ -e "$f" ]; then rm -f "$f" && cm_fixed=$((cm_fixed + 1)); fi
continue
fi
if [ -f "$f" ] && [ "${cm_sums[$f]:-}" = "$sum" ]; then continue; fi
if [ ! -f "$src" ]; then
echo "droiddeck-proton: components: $comp source missing ($src), left as it is" >&2
@@ -985,16 +1026,30 @@ def app_order(app):
return (len(app), app)
def plan_mapping(current, installed, runnable, default, auto, native=None, picks=()):
DEFERRED_GAME_TOOL = object()
def plan_mapping(current, installed, runnable, default, auto, native=None, picks=(), overrides=None):
runnable = {n.lower() for n in runnable}
installed = set(installed)
picks = set(picks)
native = native or {}
overrides = overrides or {}
changes, kept = {}, {}
if current.get("0", "") != default:
changes["0"] = default
for app in sorted((set(current) | installed | picks | set(auto)) - {"0"}, key=app_order):
for app in sorted((set(current) | installed | picks | set(auto) | set(overrides)) - {"0"}, key=app_order):
name = current.get(app, "")
if app in overrides:
target = overrides[app]
if target is DEFERRED_GAME_TOOL:
continue
target = target or default
if name != target:
changes[app] = target
if overrides[app] is None:
kept[app] = target
continue
mine = auto.get(app)
target = native.get(app, default)
stale = unrunnable(name, runnable) or (app in native and linux_runtime_tool(name))
@@ -1053,6 +1108,34 @@ def requested_tool(request, catalog):
return None
def game_requests(directory=None):
value = read_json(state_path("games.json", directory))
if value.get("version") != 1 or not isinstance(value.get("games"), dict):
return {}
return value["games"]
def requested_games(requests, catalog, runnable=None):
"""App id -> requested tool, None for inherit, or DEFERRED_GAME_TOOL while unavailable."""
runnable = None if runnable is None else set(runnable)
resolved = {}
for app, request in requests.items():
if not isinstance(app, str) or not re.fullmatch(r"[1-9][0-9]*", app) or int(app) > 0xFFFFFFFF:
continue
if request is None:
resolved[app] = None
continue
if (not isinstance(request, dict) or not isinstance(request.get("dir"), str) or
not request["dir"] or not isinstance(request.get("valve"), bool)):
continue
tool = requested_tool(request, catalog)
if tool is None or runnable is not None and tool not in runnable:
resolved[app] = DEFERRED_GAME_TOOL
else:
resolved[app] = tool
return resolved
def now_ms():
import time
return int(time.time() * 1000)
@@ -1174,7 +1257,9 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
else:
default = state.get("default") if state.get("default") in names else TOOL
steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path)))
changes, auto = plan_mapping(current, apps, names, default, followers(state, current), native_targets(steam_root, apps), picks)
overrides = requested_games(game_requests(directory), catalog)
changes, auto = plan_mapping(
current, apps, names, default, followers(state, current), native_targets(steam_root, apps), picks, overrides)
for app, tool in changes.items():
set_mapping(tokens, mapping, app, tool)
text = serialize(tokens)
+16
View File
@@ -208,6 +208,22 @@ class AgentTest(unittest.TestCase):
missing = self.ask({"kind": "cdp", "expression": "1", "target": "Nope"})
self.assertEqual("NO_TARGET", missing["error"]["code"])
def test_steam_selection(self):
with patch.object(agent, "cdp_eval", return_value={"value": "3180310"}) as evaluate:
answer = self.ask({"kind": "steam-selection"})
self.assertEqual(3180310, answer["appId"])
self.assertIn("document.activeElement", evaluate.call_args.args[1])
self.assertEqual("Steam Big Picture Mode", evaluate.call_args.kwargs["target"])
def test_steam_selection_allows_no_highlight(self):
with patch.object(agent, "cdp_eval", return_value={"value": None}):
self.assertIsNone(self.ask({"kind": "steam-selection"})["appId"])
def test_steam_selection_rejects_invalid_app_id(self):
with patch.object(agent, "cdp_eval", return_value={"value": "4294967296"}):
answer = self.ask({"kind": "steam-selection"})
self.assertEqual("INVALID_SELECTION", answer["error"]["code"])
def test_cdp_exception(self):
server = FakeDevTools({"result": {"result": {"type": "object"}, "exceptionDetails": {
"text": "Uncaught", "exception": {"description": "ReferenceError: SteamClient is not defined"}}}})
+134
View File
@@ -1,3 +1,4 @@
import hashlib
import json
import os
import shutil
@@ -52,6 +53,72 @@ class GameEnvironmentTest(unittest.TestCase):
self.assertEqual(env["REMOVE"], "inherited")
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
def test_component_profiles_override_general_and_restore_it_for_the_next_game(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
home = root / "home"
component_dir = home / ".local/share/droiddeck-components"
base = root / "proton"
target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll"
leftover = target.with_name("d3d8.dll")
component_dir.mkdir(parents=True)
target.parent.mkdir(parents=True)
(base / "version").write_text("1 build-1\n")
general = root / "general.dll"
game = root / "game.dll"
general.write_text("general")
game.write_text("game")
target.write_text("old")
leftover.write_text("old")
digest = lambda path: hashlib.sha256(path.read_bytes()).hexdigest()
(component_dir / "desired.tsv").write_text(
"*\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n"
"42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d11.dll\t%s\t%s\n"
"42\tproton\tbuild-1\tdxvk\tfiles/lib/wine/dxvk/aarch64-windows/d3d8.dll\t-\t-\n"
% (digest(general), general, digest(game), game)
)
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
env = dict(os.environ, HOME=str(home), STEAM_COMPAT_DATA_PATH="/steam/compatdata/42")
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
self.assertEqual(target.read_text(), "game")
self.assertFalse(leftover.exists())
env["STEAM_COMPAT_DATA_PATH"] = "/steam/compatdata/43"
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True)
self.assertEqual(target.read_text(), "general")
def test_component_scope_matches_environment_scope_for_shortcut_ids(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
component_dir = root / ".local/share/droiddeck-components"
base = root / "proton"
target = base / "files/lib/wine/dxvk/aarch64-windows/d3d11.dll"
component_dir.mkdir(parents=True)
target.parent.mkdir(parents=True)
(base / "version").write_text("1 build-1\n")
rows = []
for scope in ("*", "42", "4294967295", "2147483648"):
source = root / ("default" if scope == "*" else scope)
source.write_text(scope)
rows.append("%s\tproton\tbuild-1\tdxvk\t%s\t%s\t%s\n" % (
scope, target.relative_to(base),
hashlib.sha256(source.read_bytes()).hexdigest(), source,
))
(component_dir / "desired.tsv").write_text("".join(rows))
script = COMPAT["BL_COMPONENTS_SETUP"] + '\nverb=waitforexitandrun; bl_components "$1"\n'
for value in (
"42", "00042", "43", "-1", "-0001", "4294967295", "-2147483648",
"2147483648", "-1///", "0", "-0", "000", "-2147483649", "4294967296",
"999999999999999999999999999", "not-an-id", "1+1", "",
):
with self.subTest(prefix=value):
env = dict(os.environ, HOME=str(root), STEAM_COMPAT_DATA_PATH="/steam/compatdata/" + value)
target.write_text("untouched")
subprocess.run(["bash", "-c", script, "test", str(base)], env=env, check=True, capture_output=True)
scope = MODULE["game_id"](env)
expected = "untouched" if scope is None else scope if scope in {"42", "4294967295", "2147483648"} else "*"
self.assertEqual(target.read_text(), expected)
def test_engine_fixes_follow_the_games_files(self):
with tempfile.TemporaryDirectory() as tmp:
game = Path(tmp) / "Godot Game"
@@ -109,6 +176,22 @@ class GameEnvironmentTest(unittest.TestCase):
with self.assertRaises(ValueError):
MODULE["apply_config"]({}, {**config, "dxvkConfig": bad}, "42")
def test_game_texture_filtering_overrides_default_without_leaking(self):
default = "d3d9.samplerAnisotropy = 16"
game = "d3d9.samplerAnisotropy = 4"
config = {
"version": 1,
"shared": {},
"games": {},
"dxvkConfig": default,
"dxvkConfigGames": {"42": game, "43": ""},
}
self.assertEqual(MODULE["apply_config"]({}, config, "42")["DXVK_CONFIG"], game)
self.assertNotIn("DXVK_CONFIG", MODULE["apply_config"]({}, config, "43"))
self.assertEqual(MODULE["apply_config"]({}, config, "44")["DXVK_CONFIG"], default)
with self.assertRaises(ValueError):
MODULE["apply_config"]({}, {**config, "dxvkConfigGames": []}, "42")
def test_game_ids_and_probes(self):
for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"):
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
@@ -353,6 +436,57 @@ class ProtonDefaultTest(unittest.TestCase):
changes, auto = self.plan({"0": self.TOOL}, [], self.TOOL, {"10": self.TOOL})
self.assertEqual((changes, auto), ({}, {}))
def test_per_game_proton_override_and_inherit_follow_steam_mappings(self):
current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL}
overrides = {"10": "GE-Proton11-7", "11": None}
changes, auto = COMPAT["plan_mapping"](
current, ["10", "11", "12"], self.NAMES, COMPAT["TOOL_11"],
{"10": self.TOOL, "12": self.TOOL}, overrides=overrides)
self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": "GE-Proton11-7", "11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
self.assertEqual(auto, {"11": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]})
def test_per_game_requests_resolve_identity_and_defer_a_new_live_tool(self):
requests = {
"10": {"valve": False, "dir": "GE-Proton11-7"},
"11": None,
"12": {"valve": True, "dir": "Proton 11.0 (ARM64)"},
"0": None,
}
self.assertEqual(
COMPAT["requested_games"](requests, self.CATALOG),
{"10": "GE-Proton11-7", "11": None, "12": COMPAT["TOOL_11"]},
)
self.assertEqual(
COMPAT["requested_games"](requests, self.CATALOG, [self.TOOL, COMPAT["TOOL_11"]]),
{"10": COMPAT["DEFERRED_GAME_TOOL"], "11": None, "12": COMPAT["TOOL_11"]},
)
def test_missing_per_game_proton_is_preserved_during_default_registration(self):
with tempfile.TemporaryDirectory() as tmp:
config = Path(tmp) / "config.vdf"
config.write_text(config_text([
("0", self.TOOL),
("10", "GE-Proton9-1"),
("11", self.TOOL),
]))
(Path(tmp) / "games.json").write_text(json.dumps({
"version": 1,
"games": {
"10": {"valve": False, "dir": "GE-Proton9-1"},
},
}))
COMPAT["register_default"](
str(config), ["10", "11"], default=COMPAT["TOOL_11"],
catalog=self.CATALOG, directory=tmp,
)
self.assertEqual(mapping_of(config.read_text()), {
"0": COMPAT["TOOL_11"],
"10": "GE-Proton9-1",
"11": COMPAT["TOOL_11"],
})
def test_the_last_side_to_change_wins(self):
state = {"default": self.TOOL, "applied": 5}
default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 5, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=100)
+27
View File
@@ -208,6 +208,33 @@ class ReconcileTest(unittest.TestCase):
self.assertEqual((state["pending"], state.get("applied", 0)), (TOOL_11, 0))
self.assertTrue(self.helper.request_waiting())
def test_per_game_customize_and_inherit_apply_live(self):
page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL_11}, [TOOL, TOOL_11])
(self.state / "games.json").write_text(json.dumps({
"version": 1,
"games": {
"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"},
"11": None,
},
}))
state = self.reconcile(page)
self.assertEqual(page.sets, [("10", TOOL_11), ("11", TOOL)])
self.assertEqual(state["auto"], {"11": TOOL})
def test_missing_per_game_proton_stays_deferred_live(self):
page = FakePage({"0": TOOL, "10": "GE-Proton9-1", "11": TOOL_11}, [TOOL, TOOL_11])
(self.state / "games.json").write_text(json.dumps({
"version": 1,
"games": {
"10": {"valve": False, "dir": "GE-Proton9-1"},
"11": None,
},
}))
state = self.reconcile(page)
self.assertEqual(page.sets, [("11", TOOL)])
self.assertEqual(page.mapping["10"], "GE-Proton9-1")
self.assertEqual(state["auto"], {"11": TOOL})
def test_file_mode_defers_to_the_live_helper(self):
(self.state / "live.pid").write_text("%d\n" % os.getpid())
self.assertTrue(COMPAT["live_helper_running"]())