Fix/variables (#60)

* Add editable per-game environment settings below FEX presets

* Add typed preset variable pickers and default D3D12 capability levels

---------

Co-authored-by: MaxsTechReview <[email protected]>
This commit is contained in:
MaxsTechReviewandMaxsTechReview authored and GitHub committed 2026-09-28 15:17:57 -04:00
1 parent 7b74eebce2
commit 240f1194c0
18 files changed
+862 -40

No files matched your search

+3
View File
@@ -307,6 +307,9 @@ jobs:
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Game environment launch tests
run: python3 -m unittest discover -s tools/tests -p test_game_environment.py
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
@@ -1,42 +1,33 @@
package com.droiddeck.launcher.core
/**
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
* session's environment; the games under FEX inherit them from the client. The default is FEX's
* own defaults - no variables at all - which is what every session has run on so far.
*
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
*/
import com.droiddeck.launcher.R
object FexPreset {
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
)
val all: List<Preset> = listOf(
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
)
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
/** Game-launch variables in KEY=VALUE form. */
fun env(id: String): List<String> = byId(id).env
}
@@ -0,0 +1,38 @@
package com.droiddeck.launcher.core
object GameEnvironment {
data class Config(
val shared: Map<String, String?> = emptyMap(),
val games: Map<String, Map<String, String?>> = emptyMap(),
) {
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
fun withEntries(scope: String, entries: Map<String, String?>): Config =
if (scope.isEmpty()) copy(shared = entries.toMap())
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
}
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val unused = setOf(
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
)
fun validName(name: String) = namePattern.matches(name)
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -0,0 +1,43 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.R
object GameEnvironmentOptions {
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
val all = listOf(
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
)
fun find(name: String): Option? = all.firstOrNull { it.name == name }
fun toggle(value: String, choice: String): String {
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
if (!selected.remove(choice)) selected.add(choice)
return selected.joinToString(",")
}
}
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
object GameEnvironmentStore {
private const val SETTINGS = "game-environment.json"
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
val file = AtomicFile(File(context.filesDir, SETTINGS))
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
}
fun decode(json: JSONObject): GameEnvironment.Config {
require(json.getInt("version") == 1)
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
value?.keys()?.forEach { name ->
require(GameEnvironment.validName(name))
val entry = value.get(name)
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
put(name, if (entry == JSONObject.NULL) null else entry as String)
}
}
val games = json.optJSONObject("games")
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
games?.keys()?.forEach { id ->
require(id.isNotEmpty() && GameEnvironment.validScope(id))
put(id, entries(games.getJSONObject(id)))
}
})
}
fun encode(config: GameEnvironment.Config): JSONObject {
fun entries(values: Map<String, String?>) = JSONObject().apply {
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
}
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
config.games.forEach { (id, values) -> put(id, entries(values)) }
})
}
@Synchronized
fun save(context: Context, config: GameEnvironment.Config) {
val json = encode(config)
decode(json)
write(File(context.filesDir, SETTINGS), json.toString())
publish(context, config)
}
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
}
private fun write(file: File, text: String) {
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
val atomic = AtomicFile(file)
val output = atomic.startWrite()
try {
output.write(text.toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
throw error
}
}
}
@@ -24,7 +24,9 @@ object SessionFiles {
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
GameEnvironmentStore.publish(context)
val files = arrayOf(
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
@@ -298,6 +298,8 @@ object SessionPrefs {
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
@@ -528,15 +528,6 @@ class SessionService : Service() {
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (steamHere) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -0,0 +1,234 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.GameEnvironmentOptions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
var open by remember { mutableStateOf(false) }
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
TextButton(onClick = { open = true }, modifier = modifier.controllerConfirm { open = true }) {
Text(stringResource(R.string.game_env_edit))
}
}
if (open) GameEnvironmentEditor { open = false }
}
@Composable
private fun GameEnvironmentEditor(onClose: () -> Unit) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
var scope by remember { mutableStateOf("") }
var appId by remember { mutableStateOf("") }
var scopeMenu by remember { mutableStateOf(false) }
var addMenu by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
val preset = SessionPrefs.fexPreset(context)
LaunchedEffect(Unit) {
runCatching {
withContext(Dispatchers.IO) {
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
}
}.onSuccess { (settings, installed) -> config = settings; games = installed }
.onFailure { error = true }
}
fun save(next: GameEnvironment.Config) {
busy = true
coroutine.launch {
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
result.onSuccess { config = next; error = false }.onFailure { error = true }
busy = false
}
}
AlertDialog(
onDismissRequest = { if (!busy) onClose() },
title = { Text(stringResource(R.string.game_env_title)) },
confirmButton = { TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.game_env_close)) } },
text = {
Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.game_env_hint))
Text(stringResource(R.string.game_env_defaults), style = MaterialTheme.typography.bodySmall)
if (error) Text(stringResource(R.string.game_env_error), color = MaterialTheme.colorScheme.error)
if (config == null && !error) Text(stringResource(R.string.game_env_loading))
val current = config
if (current != null) {
val profileName = if (scope.isEmpty()) stringResource(R.string.game_env_shared)
else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope)
Text(stringResource(R.string.game_env_scope), style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { scopeMenu = true }, enabled = !busy) { Text(profileName) }
DropdownMenu(expanded = scopeMenu, onDismissRequest = { scopeMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_shared)) }, onClick = { scope = ""; scopeMenu = false })
val profiles = (games + current.games.keys.filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first }
for ((id, name) in profiles) DropdownMenuItem(text = { Text(name) }, onClick = { scope = id; scopeMenu = false })
}
}
OutlinedTextField(value = appId, onValueChange = { appId = it }, singleLine = true, enabled = !busy,
label = { Text(stringResource(R.string.game_env_app_id)) }, modifier = Modifier.fillMaxWidth())
TextButton(onClick = { scope = appId.toLong().toString(); appId = "" }, enabled = !busy && appId.isNotEmpty() && GameEnvironment.validScope(appId)) {
Text(stringResource(R.string.game_env_open))
}
Text(stringResource(R.string.game_env_overrides), style = MaterialTheme.typography.bodySmall)
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
if (entries.isEmpty()) Text(stringResource(R.string.game_env_empty))
for ((name, value) in entries) {
HorizontalDivider()
Text(name, style = MaterialTheme.typography.titleSmall)
Text(value ?: stringResource(R.string.game_env_unset))
if (!current.entries(scope).containsKey(name)) Text(stringResource(R.string.game_env_inherited), style = MaterialTheme.typography.labelSmall)
Row {
TextButton(onClick = { editing = name to (value ?: "") }, enabled = !busy) { Text(stringResource(R.string.game_env_edit)) }
if (value != null) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) + (name to null))) }, enabled = !busy) {
Text(stringResource(R.string.game_env_remove))
}
}
if (current.entries(scope).containsKey(name)) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) - name)) }, enabled = !busy) {
Text(stringResource(R.string.game_env_restore))
}
}
Box {
TextButton(onClick = { addMenu = true }, enabled = !busy) { Text(stringResource(R.string.game_env_add)) }
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { editing = "" to ""; addMenu = false })
for (option in GameEnvironmentOptions.all) DropdownMenuItem(text = { Text(option.name) }, onClick = { editing = option.name to option.value; addMenu = false })
}
}
TextButton(onClick = { save(current.withEntries(scope, emptyMap())) }, enabled = !busy && current.entries(scope).isNotEmpty()) {
Text(stringResource(R.string.game_env_reset))
}
}
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
}
},
)
editing?.let { (name, value) ->
VariableDialog(name, value, onDismiss = { editing = null }) { key, content ->
config?.let {
val entries = it.entries(scope).toMutableMap()
if (name.isNotEmpty() && name != key) entries[name] = null
entries[key] = content
save(it.withEntries(scope, entries))
}
editing = null
}
}
}
@Composable
private fun VariableDialog(initialName: String, initialValue: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
var name by remember { mutableStateOf(initialName) }
var value by remember { mutableStateOf(initialValue) }
val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.game_env_edit)) },
confirmButton = { TextButton(onClick = { onSave(name, value) }, enabled = valid) { Text(stringResource(R.string.game_env_save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.game_env_cancel)) } },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
VariableNamePicker(name) { selected ->
name = selected
value = GameEnvironmentOptions.find(selected)?.value.orEmpty()
}
VariableValueEditor(name, value) { value = it }
GameEnvironmentOptions.find(name)?.takeIf { it.detail != 0 }?.let {
Text(stringResource(it.detail), style = MaterialTheme.typography.bodySmall)
}
if (!GameEnvironment.supported(name)) Text(stringResource(R.string.game_env_unused))
if (!valid && name.isNotEmpty()) Text(stringResource(R.string.game_env_invalid), color = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
private fun VariableNamePicker(name: String, onChange: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) }
Text(stringResource(R.string.game_env_name), style = MaterialTheme.typography.labelMedium)
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(if (custom) stringResource(R.string.game_env_custom) else name)
}
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = {
custom = true; onChange(""); expanded = false
})
for (option in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }) {
DropdownMenuItem(text = { Text(option.name) }, onClick = {
custom = false; onChange(option.name); expanded = false
})
}
}
}
if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true,
label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth())
}
@Composable
private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) {
val option = GameEnvironmentOptions.find(name)
var expanded by remember(name) { mutableStateOf(false) }
var custom by remember(name) { mutableStateOf(option == null || value !in option.choices && option.type != GameEnvironmentOptions.Type.MULTIPLE) }
when {
option?.type == GameEnvironmentOptions.Type.TOGGLE -> Row(
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(stringResource(R.string.game_env_value))
Switch(checked = value == option.choices.last(), onCheckedChange = {
onChange(if (it) option.choices.last() else option.choices.first())
})
}
option != null && option.choices.isNotEmpty() -> {
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) })
}
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom_value)) }, onClick = { custom = true; expanded = false })
for (choice in option.choices) {
val selected = choice in value.split(',')
DropdownMenuItem(text = { Text(choice) }, leadingIcon = {
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) Checkbox(checked = selected, onCheckedChange = null)
}, onClick = {
custom = false
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) {
onChange(GameEnvironmentOptions.toggle(value, choice))
} else {
onChange(choice); expanded = false
}
})
}
}
}
if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
}
else -> OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Text),
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
}
}
@@ -20,6 +20,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionPrefs
@@ -368,12 +370,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
if (steam && s.fexPreset != null) SettingsGroup("Games") {
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(
host, "fex", "FEX preset", "Applies on next game launch.",
FexPreset.all.map { it.id to it.label }, s.fexPreset,
note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
GameEnvironmentRow()
if (s.forceFullscreen != null) ToggleRow(
host, "fill", "Stretch games to fill the screen",
"Keeps games that resize their own window (FlatOut) full screen. Turn it off if a game shows up small in a corner (Quake 3). Applies next session.",
@@ -89,6 +89,8 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.session.SessionPrefs
@@ -405,9 +407,12 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
ChoiceRow(host, "shape", "Screen ratio", null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
if (a.steam) ChoiceRow(host, "fex", "FEX preset", null,
FexPreset.all.map { it.id to it.label }, a.fexPreset,
}
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
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"))
}
SettingsGroup("Support") {
SettingsRow("Session logs", "Send this session's logs with a bug report") {
+64
View File
@@ -19,4 +19,68 @@
<string name="resume_session">Resume</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</string>
<string name="game_env_title">Game environment</string>
<string name="game_env_hint">Shared defaults and per-game overrides for games using Proton. Applies on the next game launch.</string>
<string name="game_env_edit">Edit variables</string>
<string name="game_env_shared">All Proton games</string>
<string name="game_env_scope">Game profile</string>
<string name="game_env_app_id">Steam app ID or non-Steam compatibility ID</string>
<string name="game_env_open">Open profile</string>
<string name="game_env_profile">Game %1$s</string>
<string name="game_env_add">Add variable</string>
<string name="game_env_name">Variable name</string>
<string name="game_env_value">Value</string>
<string name="game_env_save">Save</string>
<string name="game_env_cancel">Cancel</string>
<string name="game_env_close">Close</string>
<string name="game_env_remove">Remove</string>
<string name="game_env_restore">Restore inherited settings</string>
<string name="game_env_reset">Reset this profile</string>
<string name="game_env_unset">Unset for this profile</string>
<string name="game_env_empty">No variables set. Driver and Proton defaults apply.</string>
<string name="game_env_inherited">Inherited</string>
<string name="game_env_custom">Custom variable</string>
<string name="game_env_defaults">D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game.</string>
<string name="game_env_error">Could not load or apply the settings. Check available storage and try again.</string>
<string name="game_env_invalid">Use a valid variable name and a value without NUL characters (up to 8192 characters).</string>
<string name="game_env_unused">The standard Linux Proton runtime does not use this variable; it may require a custom component.</string>
<string name="game_env_loading">Loading…</string>
<string name="game_env_overrides">Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value.</string>
<string name="game_env_feature">D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection.</string>
<string name="game_env_shader">D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection.</string>
<string name="game_env_cache">Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching.</string>
<string name="game_env_cache_size">Maximum Mesa shader-cache size, such as 1G. Larger caches use more storage.</string>
<string name="game_env_vkd3d_config">VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it.</string>
<string name="game_env_proton_log">Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage.</string>
<string name="game_env_wined3d">Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing.</string>
<string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string>
<string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string>
<string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string>
<string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string>
<string name="game_env_glthread">Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game.</string>
<string name="fex_preset_title">FEX preset</string>
<string name="fex_next_launch">Applies on the next game launch.</string>
<string name="game_settings_title">Games</string>
<string name="fex_default_label">FEX defaults</string>
<string name="fex_default_detail">Uses FEX defaults.</string>
<string name="fex_stability_label">Stability</string>
<string name="fex_stability_detail">Safest, slowest settings.</string>
<string name="fex_compatibility_label">Compatibility</string>
<string name="fex_compatibility_detail">Full TSO with multiblock.</string>
<string name="fex_intermediate_label">Intermediate</string>
<string name="fex_intermediate_detail">Balanced TSO with reduced x87 precision.</string>
<string name="fex_performance_label">Performance</string>
<string name="fex_performance_detail">All TSO disabled; multiblock.</string>
<string name="fex_performance_tso_label">Performance + TSO</string>
<string name="fex_performance_tso_detail">Performance settings with main TSO enabled.</string>
<string name="fex_extreme_label">Extreme</string>
<string name="fex_extreme_detail">Disables self-modifying-code checks. JIT games and DRM may break.</string>
<string name="fex_extreme_tso_label">Extreme + TSO</string>
<string name="fex_extreme_tso_detail">Extreme settings with main TSO enabled.</string>
<string name="fex_extreme_gn_label">Extreme-gn</string>
<string name="fex_extreme_gn_detail">Performance with TSC and metadata hints. Try before Extreme.</string>
<string name="fex_denuvo_label">Denuvo</string>
<string name="fex_denuvo_detail">Performance with full code checks and the hypervisor bit hidden.</string>
<string name="game_env_custom_value">Custom value</string>
<string name="game_env_select_value">Select value</string>
</resources>
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.core
import org.junit.Assert.*
import org.junit.Test
class GameEnvironmentTest {
@Test fun defaultsIncludeRequestedGraphicsLevelsAndKeepCaching() {
val values = GameEnvironment.defaults("")
assertEquals("false", values["MESA_SHADER_CACHE_DISABLE"])
assertEquals("12_2", values["VKD3D_FEATURE_LEVEL"])
assertEquals("6_9", values["VKD3D_SHADER_MODEL"])
}
@Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() {
val config = GameEnvironment.Config(
shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"),
games = mapOf("42" to mapOf("FEX_MULTIBLOCK" to "1", "VKD3D_FEATURE_LEVEL" to null)),
)
val game = GameEnvironment.effective(config, "COMPATIBILITY", "42")
assertEquals("1", game["FEX_MULTIBLOCK"])
assertTrue(game.containsKey("VKD3D_FEATURE_LEVEL"))
assertNull(game["VKD3D_FEATURE_LEVEL"])
val other = GameEnvironment.effective(config, "COMPATIBILITY", "43")
assertEquals("0", other["FEX_MULTIBLOCK"])
assertEquals("12_2", other["VKD3D_FEATURE_LEVEL"])
}
@Test fun graphicsDefaultsRespectExistingEditsAndRemovals() {
val config = GameEnvironment.Config(shared = mapOf("VKD3D_FEATURE_LEVEL" to "12_0"))
.withEntries("42", mapOf("VKD3D_SHADER_MODEL" to null))
val game = GameEnvironment.effective(config, "", "42")
assertEquals("12_0", game["VKD3D_FEATURE_LEVEL"])
assertTrue(game.containsKey("VKD3D_SHADER_MODEL"))
assertNull(game["VKD3D_SHADER_MODEL"])
val restored = GameEnvironment.effective(config.withEntries("42", emptyMap()), "", "42")
assertEquals("6_9", restored["VKD3D_SHADER_MODEL"])
}
@Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() {
assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes"))
assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps"))
assertEquals("", GameEnvironmentOptions.toggle("fps", "fps"))
assertEquals("fps", GameEnvironmentOptions.toggle("", "fps"))
}
@Test fun switchingPresetDropsOldPresetOnlyVariables() {
val config = GameEnvironment.Config()
assertEquals("none", GameEnvironment.effective(config, "EXTREME", "")["FEX_SMCCHECKS"])
assertFalse(GameEnvironment.effective(config, "COMPATIBILITY", "").containsKey("FEX_SMCCHECKS"))
assertFalse(GameEnvironment.effective(config, "", "").containsKey("FEX_MULTIBLOCK"))
}
@Test fun resettingOneProfilePreservesOtherProfiles() {
val config = GameEnvironment.Config(shared = mapOf("CUSTOM" to "shared"))
.withEntries("42", mapOf("CUSTOM" to "game"))
.withEntries("43", mapOf("CUSTOM" to "other"))
.withEntries("42", emptyMap())
assertEquals("shared", GameEnvironment.effective(config, "", "42")["CUSTOM"])
assertEquals("other", GameEnvironment.effective(config, "", "43")["CUSTOM"])
assertFalse(config.games.containsKey("42"))
}
@Test fun validationPreservesLiteralValuesAndRejectsInvalidKeys() {
assertTrue(GameEnvironment.validName("mesa_glthread"))
assertFalse(GameEnvironment.validName("X=Y"))
assertFalse(GameEnvironment.validName("1VAR"))
assertFalse(GameEnvironment.validName("VAR\n"))
assertTrue(GameEnvironment.validValue("a=b 'quote' $(touch file) ; $"))
assertTrue(GameEnvironment.validValue(""))
assertFalse(GameEnvironment.validValue("bad\u0000value"))
assertFalse(GameEnvironment.validValue("x".repeat(8193)))
assertTrue(GameEnvironment.validScope("4294967295"))
for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope))
}
}
@@ -0,0 +1,64 @@
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.*
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 GameEnvironmentStoreTest {
private lateinit var context: Context
private lateinit var guest: File
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json")
File(context.filesDir, "game-environment.json").delete()
}
@Test fun savedSettingsAndPublishedSettingsPreserveUnsetsAndLiteralValues() {
val config = GameEnvironment.Config(
shared = mapOf("LITERAL" to "a=b 'quoted' $(echo test)", "EMPTY" to ""),
games = mapOf("42" to mapOf("LITERAL" to null, "VKD3D_SHADER_MODEL" to "6_9")),
)
GameEnvironmentStore.save(context, config)
assertEquals(config, GameEnvironmentStore.read(context))
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
assertEquals("false", published.shared["MESA_SHADER_CACHE_DISABLE"])
assertEquals("12_2", published.shared["VKD3D_FEATURE_LEVEL"])
assertEquals("6_9", published.shared["VKD3D_SHADER_MODEL"])
assertEquals(config.games, published.games)
assertEquals(config.shared["LITERAL"], published.shared["LITERAL"])
assertEquals("", published.shared["EMPTY"])
}
@Test fun changingFexPresetPublishesForNextGameLaunch() {
GameEnvironmentStore.save(context, GameEnvironment.Config())
SessionPrefs.setFexPreset(context, "EXTREME")
assertEquals("none", JSONObject(guest.readText()).getJSONObject("shared").getString("FEX_SMCCHECKS"))
SessionPrefs.setFexPreset(context, "")
assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS"))
}
@Test fun invalidDataCannotReplaceSavedConfiguration() {
val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok"))
GameEnvironmentStore.save(context, original)
assertTrue(runCatching { GameEnvironmentStore.save(context, GameEnvironment.Config(shared = mapOf("BAD=NAME" to "x"))) }.isFailure)
assertEquals(original, GameEnvironmentStore.read(context))
}
@Test fun customComponentVariablesRemainEditable() {
val config = GameEnvironment.Config(shared = mapOf("DXVK_ASYNC" to "1"))
GameEnvironmentStore.save(context, config)
assertEquals(config, GameEnvironmentStore.read(context))
}
}
+81
View File
@@ -0,0 +1,81 @@
# Steam game environment
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).
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 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.
## Defaults and available suggestions
The initial configuration enables Mesa shader caching with
`MESA_SHADER_CACHE_DISABLE=false`, sets `VKD3D_FEATURE_LEVEL=12_2` and
`VKD3D_SHADER_MODEL=6_9`, and preserves the selected FEX preset. Existing edits
and explicit removals take precedence over these defaults. FEX's own default
remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray
tracing and diagnostic logging otherwise retain the runtime's defaults.
The variable-name picker offers 24 predefined variables and a Custom entry. Like
WinNative, known variables use toggles, value dropdowns, multi-select lists, or
numeric/text fields. Feature-level and shader-model dropdowns also accept custom
values. Custom variables have editable names and literal values.
| Variable | Use |
| --- | --- |
| `VKD3D_FEATURE_LEVEL` | D3D12 capability override; defaults to `12_2`. |
| `VKD3D_SHADER_MODEL` | Shader-model override; defaults to `6_9`. |
| `VKD3D_CONFIG` | Per-game workarounds such as `nodxr`; not a universal performance preset. |
| `MESA_SHADER_CACHE_MAX_SIZE` | Storage budget for Mesa's shader cache, for example `1G`. |
| `mesa_glthread` | OpenGL threading; test with the affected game. |
| `DXVK_CONFIG` | DXVK configuration options; recent builds accept `dxvk.maxFrameRate = 60`. |
| `VKD3D_FRAME_RATE` | D3D12 frame-rate limit. |
| `DXVK_HUD`, `PROTON_LOG` | Optional diagnostics; leave unset for ordinary play. |
| `PROTON_USE_WINED3D` | D3D9–11 OpenGL fallback for compatibility testing. |
| `PROTON_USE_XALIA` | Proton's gamepad-navigation helper toggle. |
Forcing `12_2` or `6_9` changes reported capabilities; it cannot implement a missing
Vulkan feature. Remove either entry to use automatic detection for that capability.
Other picker suggestions are editable starting values and are enabled only when
added and saved. Older or
custom components can support a different set of options. Android Wine wrapper,
ALSA-server, Box64 and patched async-DXVK options are identified in the editor as
requiring a different/custom component; custom entries remain allowed. The old
`DXVK_FRAME_RATE` environment variable was removed from current DXVK, so it is
not offered as a suggestion. Prefer the session frame limiter for ordinary play.
## Launch path and references
WinNative's Linux session filters Android-only options, merges user variables
over the FEX preset, and passes the result to `env -i` before starting Steam. Its
Windows/Wine graphics helper also maps the selected feature level to
`VKD3D_FEATURE_LEVEL`. Reviewed source: [WinNative Linux session](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/runtime/display/XServerDisplayActivity.java),
[variable editor](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/shared/ui/widget/EnvVarsView.java),
[graphics configuration](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/feature/settings/drivers/DXVKConfigUtils.java).
DroidDeck instead publishes an atomic JSON snapshot at
`/root/.config/droiddeck/game-environment.json`. Both its Valve ARM64 Proton
wrapper and adopted third-party Proton wrappers execute `bannerlator-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.
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),
[Proton runtime options](https://github.com/ValveSoftware/Proton/tree/proton_11.0#runtime-config-options),
[Mesa variables](https://docs.mesa3d.org/envvars.html),
[DXVK variables](https://github.com/doitsujin/dxvk#environment-variables),
[DXVK frame-limiter changes](https://github.com/doitsujin/dxvk/releases).
Validation: `./gradlew testDebugUnitTest` and
`python3 -m unittest discover -s tools/tests -p test_game_environment.py`.
Actual game compatibility depends on the installed Proton, VKD3D and Vulkan driver;
these checks do not establish that every device supports feature level 12_2 or SM 6_9.
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/python3
import json
import os
import re
import sys
from pathlib import Path
def game_id(env):
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
if not prefix:
return None
value = prefix.rsplit("/", 1)[-1]
if not re.fullmatch(r"-?[0-9]+", value):
return None
number = int(value)
if not -(2**31) <= number < 2**32 or number == 0:
return None
return str(number % 2**32)
def apply_config(env, config, app_id):
if config.get("version") != 1:
raise ValueError("unsupported environment configuration")
result = env.copy()
entries = dict(config.get("shared", {}))
entries.update(config.get("games", {}).get(app_id, {}))
for name, value in entries.items():
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise ValueError("invalid environment name")
if value is None:
result.pop(name, None)
elif isinstance(value, str) and "\0" not in value and len(value) <= 8192:
result[name] = value
else:
raise ValueError("invalid environment value")
return result
def main():
command = sys.argv[1:]
if not command:
return 2
env = os.environ.copy()
app_id = game_id(env)
if len(command) > 1 and command[1] == "waitforexitandrun" and app_id:
path = Path.home() / ".config/droiddeck/game-environment.json"
try:
with path.open(encoding="utf-8") as source:
env = apply_config(env, json.load(source), app_id)
except FileNotFoundError:
pass
except (OSError, ValueError, TypeError, AttributeError):
print("bannerlator-game-env: invalid configuration; using inherited environment", file=sys.stderr)
os.execvpe(command[0], command, env)
if __name__ == "__main__":
sys.exit(main())
@@ -233,7 +233,7 @@ for name in %s; do
bl_directaudio "$depot"
bl_game_cpus
bl_idtech3 "$@"
exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
fi
done
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
@@ -282,7 +282,7 @@ bl_game_cpus
bl_idtech3 "$@"
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
# started from its own tree.
exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
"""
EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools"
{
+92
View File
@@ -0,0 +1,92 @@
import json
import os
from pathlib import Path
import runpy
import subprocess
import sys
import tempfile
import unittest
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
MODULE = runpy.run_path(str(BIN / "bannerlator-game-env"))
COMPAT = runpy.run_path(str(BIN / "bannerlator-steam-compat"))
class GameEnvironmentTest(unittest.TestCase):
def test_profile_precedence_and_unset(self):
env = {"KEEP": "inherited", "REMOVE": "inherited", "CUSTOM": "launch option"}
config = {"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"REMOVE": None, "CUSTOM": "game", "EMPTY": ""}}}
self.assertEqual(MODULE["apply_config"](env, config, "42"), {"KEEP": "inherited", "CUSTOM": "game", "EMPTY": ""})
self.assertEqual(env["REMOVE"], "inherited")
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
def test_invalid_configuration_is_atomic(self):
env = {"ORIGINAL": "unchanged"}
for entries in ({"A": "ok", "BAD=KEY": "x"}, {"A": "bad\0value"}, {"A": 1}):
with self.assertRaises(ValueError):
MODULE["apply_config"](env, {"version": 1, "shared": entries}, "42")
self.assertEqual(env, {"ORIGINAL": "unchanged"})
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}))
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42")
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
config = home / ".config/droiddeck/game-environment.json"
config.parent.mkdir(parents=True)
probe = home / "fake-proton"
probe.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ.get('CUSTOM'), sys.argv[1:]]))\n")
probe.chmod(0o755)
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"):
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}}))
result = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True)
self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]])
self.assertFalse((home / "injected").exists())
for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")):
output = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True)
self.assertEqual(json.loads(output)[0], "original")
config.write_text("{broken")
result = subprocess.run([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True)
self.assertEqual(json.loads(result.stdout)[0], "original")
def test_generated_valve_and_third_party_launchers_apply_configuration(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
steam = home / "Steam"
tools = steam / "compatibilitytools.d"
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
(depot / "files/bin-arm64").mkdir(parents=True)
extra = tools / "custom-proton"
extra.mkdir(parents=True)
for base in (depot, extra):
proton = base / "proton"
proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n")
proton.chmod(0o755)
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]))
COMPAT["adopt_extras"](str(tools))
config = home / ".config/droiddeck/game-environment.json"
config.parent.mkdir(parents=True)
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}}))
wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"])
for wrapper in wrappers:
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/bannerlator-game-env", str(BIN / "bannerlator-game-env")))
result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"],
env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)},
text=True, capture_output=True)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]])
def test_both_proton_wrappers_call_environment_launcher(self):
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
self.assertIn('exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env', script)
if __name__ == "__main__":
unittest.main()