diff --git a/app/build.gradle b/app/build.gradle index 6752f032..659397ab 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 187c7f54..3c3c6b54 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -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) }, diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 97c408b8..883492d5 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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(null) + private var drawerGameProfileId by mutableStateOf(null) + private val drawerGameInventory = GameProfileInventoryCache() + 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>, + val selectedAppId: Long?, + val profile: SelectedGameProfile?, + val error: Exception? = null, + ) + private var drawerGameProfileRefresh by mutableStateOf(GameProfileRefreshState()) + private val drawerGameProfileRefreshes = LatestRequestCoordinator() 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(null) + private var drawerTextureLodBias by mutableStateOf(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, + ): 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 diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt index fbf3f84e..e0c0aed2 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentGuest.kt @@ -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? { + 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? = + focusedAppId?.takeIf { it in installedAppIds } + + internal fun activeInstalledSteamAppId( + focusedAppId: Long?, + focusableAppIds: List, + installedAppIds: Set, + baselayerAppIds: List = 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? = selectedAppId?.takeIf { it in installedAppIds } + ?: activeAppId?.takeIf { it in installedAppIds } + + internal fun selectedOrActiveInstalledSteamAppId( + selectedAppId: Result, + activeAppId: Long?, + installedAppIds: Set, + ): 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) diff --git a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt index f0c0feb3..832c9d03 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt @@ -30,4 +30,11 @@ object FexPreset { /** Game-launch variables in KEY=VALUE form. */ fun env(id: String): List = byId(id).env + + val environmentKeys: Set by lazy { + all.flatMap { it.env }.mapTo(linkedSetOf()) { it.substringBefore('=') } + } + + fun environment(id: String): Map = + env(id).associate { it.substringBefore('=') to it.substringAfter('=') } } diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt index ffc9f772..e92da2e1 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -46,4 +46,37 @@ object GameEnvironment { fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map = 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__" } diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 93246077..f985b2e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -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" diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt index 0e7045a7..3266f90e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -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 = 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, 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) -> 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 { 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) + /** - * 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() - 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 = 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? { + 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() diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt index affc00dc..fa0aa507 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -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()) } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt new file mode 100644 index 00000000..77e9f67b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileFollowState.kt @@ -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 + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt new file mode 100644 index 00000000..1574c7d3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileInventoryCache.kt @@ -0,0 +1,16 @@ +package com.droiddeck.launcher.session + +internal class GameProfileInventoryCache { + private val lock = Any() + + private var revision: Long? = null + private var value: List? = null + + // Called only by the refresh worker; the UI invalidates through request revisions. + fun get(requestedRevision: Long, load: () -> List): List = synchronized(lock) { + value?.takeIf { revision == requestedRevision } ?: load().toList().also { + value = it + revision = requestedRevision + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt new file mode 100644 index 00000000..ed1d357b --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileManager.kt @@ -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>() + 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) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt b/app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt new file mode 100644 index 00000000..721953ab --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameProfileRefreshState.kt @@ -0,0 +1,13 @@ +package com.droiddeck.launcher.session + +internal data class GameProfileRefreshState( + 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) +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt b/app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt new file mode 100644 index 00000000..611c17de --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/LatestRequestCoordinator.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.session + +internal class LatestRequestCoordinator { + data class Request(val generation: Long, val value: T) + + private val lock = Any() + private var generation = 0L + private var running = false + private var latest: Request? = null + private var pending: Request? = 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? = synchronized(lock) { + pending.also { + pending = null + if (it == null) running = false + } + } + + fun isLatest(request: Request): Boolean = synchronized(lock) { + request.generation == latest?.generation + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt index 12a517e0..ff3214cf 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt @@ -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) + 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 { + 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): 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 + } + } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt b/app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt new file mode 100644 index 00000000..ddb21c69 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/SelectedGameProfile.kt @@ -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, + val componentFiles: Map, + val environmentOverrides: Int, + val windowsComponentNames: List, +) { + 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, + generalFexPreset: String, + environment: GameEnvironment.Config, + snapshot: ComponentsManager.Snapshot, + generalProtonId: String?, + gameProtonChoice: ProtonDefault.GameChoice?, + gameProtonId: String?, + gameComponents: Map, + gameWindowsComponents: List, + ): 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), + ) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 5e1387ca..1596c2fe 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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 { + 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) { + 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) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 7eb70811..7c5b6c9a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -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, +) + 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 = 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(null) } + var fexMenu by remember { mutableStateOf(false) } + var gameProtonMenu by remember { mutableStateOf(false) } + var gameProtonId by remember { mutableStateOf(null) } + var gameProtonChoice by remember { mutableStateOf(null) } + var gameComponents by remember { mutableStateOf>(emptyMap()) } + var gameComponentMenu by remember { mutableStateOf(null) } + var environmentEditor by remember { mutableStateOf(false) } + var winComponentsOpen by remember { mutableStateOf(null) } + var winComponentsRevision by remember { mutableStateOf(0) } + var environment by remember { mutableStateOf(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(null) } + var confirm by remember { mutableStateOf 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, + protonId: String?, + protonChoice: ProtonDefault.GameChoice?, + packages: List, + componentChoices: Map, + 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, + 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 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index dfab93aa..fd6cfec3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -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/, 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(null) } val wincompPicks = remember(wincompKey, wincompOpen) { wincompKey?.let { com.droiddeck.launcher.session.WinComponents.picks(context, it) }.orEmpty() } val cards = buildList<@Composable (Modifier) -> Unit> { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt index 3ca96d6c..51c8fdf5 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -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(null) } var games by remember { mutableStateOf(emptyList>()) } - var scope by remember { mutableStateOf("") } + var scope by remember { mutableStateOf(initialScope) } var otherId by remember { mutableStateOf(null) } var menu by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(false) } - var editing by remember { mutableStateOf?>(null) } + var editing by remember { mutableStateOf(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) = diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 893bc3cd..678e6cbe 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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, 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> = 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:" 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> = + (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> = + (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> = 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> = + 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(null) } val view = snap.protons.firstOrNull { it.proton.id == pick } ?: running.firstOrNull() ?: snap.protons.firstOrNull() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d621d831..20f88d1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,7 +58,7 @@ Save Remove Inherit again - Reset this profile + Clear environment overrides Unset for this profile No variables set. Inherited @@ -413,6 +413,41 @@ Check the Nightlies for new packages Proton 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. + Configuration + Configure profile + Profile + Default profile + Used automatically by games without overrides. + Saved settings used automatically when %1$s launches. + Choose only the settings this game needs. Everything else continues to use the Default profile. + FEX preset + Using the Default profile preset. + Custom preset saved for this game. + Custom FEX variables + Use Default + Game compatibility + Proton, DXVK, VKD3D and synchronization environment options for this game. + Using the Default profile Proton. + Custom Proton saved for this game through Steam compatibility mapping. + No Proton + DXVK + VKD3D-Proton + Uses the Default profile package from %1$s unless this game selects another package. + Uses the Default profile component package. + Uses %1$s only for this game. + Runtime options + Other per-game Proton, DXVK, VKD3D-Proton and synchronization environment options. + %1$s\'s profile is saved for the next game launch. + %1$s\'s Proton choice is saved and available in Steam now. + %1$s\'s Proton choice is saved. Restart Steam before launching the game. + %1$s cannot use the selected Proton on this device. + Could not load %1$s\'s profile: %2$s + The game profile could not be saved: %1$s + Reset game profile + Reset %1$s\'s profile? + Remove this game\'s Proton, FEX, component package, environment, texture filtering, and Windows component overrides? It will use the Default profile. + %1$s now uses the Default profile. + The game profile could not be reset completely: %1$s %1$s now runs every game that has not picked its own Proton in Steam. %1$s will run every game that has not picked its own Proton, from the next time Steam starts. %1$s cannot start games here, so it cannot be the default. @@ -576,6 +611,19 @@ Stop session? Running now No game is running on a Proton + Game profile + Profile details are not available yet. + Couldn\'t load game profiles. Retrying automatically. + Couldn\'t refresh game profiles. Showing the last available data; retrying automatically. + View and edit the saved settings used on the next launch. + Using Default profile + Custom profile + Using %1$s from the Default profile. + Changes are saved to %1$s\'s profile and apply the next time the game launches. + + %1$d game override + %1$d game overrides + No Proton is installed yet. game running re-applied at launch %1$s @@ -1257,6 +1305,7 @@ direct Swap failed: %1$s + Profile save failed: %1$s The desktop is paused %1$s is paused diff --git a/app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt b/app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt new file mode 100644 index 00000000..7b5ee948 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/agent/AgentGuestSelectionTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt index 82d5f3f6..cf428d87 100644 --- a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt @@ -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")) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt new file mode 100644 index 00000000..f1a37e5f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsProcessTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt index 1ce0fc14..3f5e6e8e 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ForcedPackageTest.kt @@ -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) { diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt index 07cbc6ef..d499437e 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -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") diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt new file mode 100644 index 00000000..c24379fa --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileFollowStateTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt new file mode 100644 index 00000000..914a7e09 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileInventoryCacheTest.kt @@ -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() + 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() + 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() + 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() + + assertEquals(emptyList(), cache.get(0) { emptyList() }) + assertEquals(emptyList(), 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() + val coordinator = LatestRequestCoordinator() + 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> { + 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> { + 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)) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt new file mode 100644 index 00000000..77b2895c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileManagerTest.kt @@ -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(), 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(), 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(), + ) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt new file mode 100644 index 00000000..49d073e8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameProfileRefreshStateTest.kt @@ -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().failed() + + assertTrue(state.failed) + assertFalse(state.loaded) + assertNull(state.data) + } + + @Test + fun failureAfterSuccessPreservesCachedData() { + val state = GameProfileRefreshState().succeeded("cached profiles").failed() + + assertTrue(state.failed) + assertTrue(state.loaded) + assertEquals("cached profiles", state.data) + } + + @Test + fun successAfterFailureReplacesCachedDataAndClearsWarning() { + val state = GameProfileRefreshState() + .succeeded("old profiles") + .failed() + .succeeded("new profiles") + + assertFalse(state.failed) + assertTrue(state.loaded) + assertEquals("new profiles", state.data) + } + + @Test + fun retryingClearsWarningWithoutDiscardingCachedData() { + val state = GameProfileRefreshState().succeeded("cached profiles").failed().retrying() + + assertFalse(state.failed) + assertTrue(state.loaded) + assertEquals("cached profiles", state.data) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt b/app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt new file mode 100644 index 00000000..12e7397b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/LatestRequestCoordinatorTest.kt @@ -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() + + 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() + + assertTrue(coordinator.submit("first")) + coordinator.takeLatest() + assertNull(coordinator.takeLatest()) + + assertTrue(coordinator.submit("second")) + assertEquals("second", coordinator.takeLatest()!!.value) + } + + @Test fun newerRequestInvalidatesCompletedResultBeforeItIsApplied() { + val coordinator = LatestRequestCoordinator() + + 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() + + 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() + + assertTrue(coordinator.submit("same")) + val inFlight = coordinator.takeLatest()!! + assertFalse(coordinator.submit("same")) + + assertTrue(coordinator.isLatest(inFlight)) + assertEquals("same", coordinator.takeLatest()!!.value) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt index ce830cca..fcf85ddb 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt @@ -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"]) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt new file mode 100644 index 00000000..99209674 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SelectedGameProfileTest.kt @@ -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(), profile.componentFiles) + assertEquals(0, profile.environmentOverrides) + assertEquals(emptyList(), 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 = listOf(game), + environment: GameEnvironment.Config = GameEnvironment.Config(), + gameChoice: ProtonDefault.GameChoice? = null, + gameComponents: Map = emptyMap(), + gameWindowsComponents: List = 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) +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt new file mode 100644 index 00000000..c59cd52f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/TextureFilteringPrefsTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt new file mode 100644 index 00000000..e01d884d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt @@ -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() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt new file mode 100644 index 00000000..14bd5afa --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GameEnvironmentEditorTest.kt @@ -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() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt b/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt new file mode 100644 index 00000000..12c382f5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/RobolectricComposeRule.kt @@ -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), + ) +} diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index 223915ad..2bbf28c7 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -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), diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent index 0df6d536..b0e8efcf 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-agent @@ -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) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 792a7676..260b6f93 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -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") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index 88f66e1e..31c06358 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -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) diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 18dc6bb1..ab205ce2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -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) diff --git a/tools/tests/test_droiddeck_agent.py b/tools/tests/test_droiddeck_agent.py index c04f2eeb..8cc103e3 100644 --- a/tools/tests/test_droiddeck_agent.py +++ b/tools/tests/test_droiddeck_agent.py @@ -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"}}}}) diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 817038c7..d6a2ee74 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -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) diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index a20ffba2..dfe76ce6 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -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"]())