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