diff --git a/README.md b/README.md index fc862761f..84d94d122 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,24 @@ staged into the runtime at every launch like the session scripts, so a fix reach runtime without re-hosting it. Proven in Bannerlator's Linux session with voice in Steam's own tester; ⚠️ not yet exercised in this app. +## Cores + +**Performance** on the main screen carries the two core masks Bannerlator's Linux session has, kept +separate because they are wanted at the same time and suit different things: + +- **Steam client cores** — Steam pins its own interface renderer to a subset of cores it chooses (on + one device 5 of 8, leaving out both little cores and the fastest), which is reasonable while a + game runs and makes Big Picture sluggish when the client is all there is. Turning the override + on pins the client, its UI helper and gamescope to the cores you tick, re-applied every few + seconds because the UI keeps spawning helpers that inherit Steam's choice. Every core ticked is + the usual fix — and, unlike the game mask, it *is* sent when it names every core, since undoing + Steam's pin is the whole point. +- **Game cores** — applied by the Proton wrapper, which execs the game through `taskset` so every + thread inherits the mask from its first instruction. Leaving every core ticked sends nothing; + untick the small cores to keep a heavy game off them. + +Both apply at the next session start. + ## Compatibility tools **Compatibility tools** (main screen and drawer) installs GE-Proton or proton-cachyos — native diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 5f1537659..7507aae88 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -33,7 +33,10 @@ import com.steamdeck.launcher.session.ProtonExtras import com.steamdeck.launcher.session.SessionPrefs import com.steamdeck.launcher.ui.ProtonDialog import com.steamdeck.launcher.ui.ProtonRow +import com.steamdeck.launcher.core.CpuCores import com.steamdeck.launcher.ui.AudioDialog +import com.steamdeck.launcher.ui.CoreRow +import com.steamdeck.launcher.ui.PerformanceDialog import com.steamdeck.launcher.ui.DriverDialog import com.steamdeck.launcher.ui.DriverRow import com.steamdeck.launcher.ui.ConfirmDialog @@ -75,6 +78,10 @@ class MainActivity : ComponentActivity() { private var protonRows by mutableStateOf>(emptyList()) private var showDrivers by mutableStateOf(false) private var showAudio by mutableStateOf(false) + private var showPerformance by mutableStateOf(false) + private var clientOverride by mutableStateOf(false) + private var clientCores by mutableStateOf>(emptySet()) + private var gameCores by mutableStateOf>(emptySet()) private var directAudio by mutableStateOf(false) private var mic by mutableStateOf(false) private var linuxRows by mutableStateOf>(emptyList()) @@ -121,6 +128,7 @@ class MainActivity : ComponentActivity() { onProtons = { refreshProtons(); showProtons = true }, onDrivers = { refreshDrivers(); showDrivers = true }, onAudio = { directAudio = SessionPrefs.directAudio(this); mic = SessionPrefs.micEnabled(this); showAudio = true }, + onPerformance = { refreshCores(); showPerformance = true }, onCredits = { showCredits = true }, ) if (showApps) DesktopAppsDialog( @@ -161,6 +169,20 @@ class MainActivity : ComponentActivity() { }, onDismiss = { showAudio = false }, ) + if (showPerformance) PerformanceDialog( + cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) }, + clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, + onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, + onClientCore = { core, on -> + clientCores = if (on) clientCores + core else clientCores - core + SessionPrefs.setClientCpus(this, CpuCores.format(clientCores)) + }, + onGameCore = { core, on -> + gameCores = if (on) gameCores + core else gameCores - core + SessionPrefs.setGameCpus(this, CpuCores.format(gameCores)) + }, + onDismiss = { showPerformance = false }, + ) if (showRemove) ConfirmDialog( title = "Remove Linux runtime", text = "This deletes the runtime, the Steam client inside it, and every game installed there.", @@ -293,6 +315,13 @@ class MainActivity : ComponentActivity() { null } + /** The two masks as the dialog shows them; an empty stored list shows as every core ticked. */ + private fun refreshCores() { + clientOverride = SessionPrefs.clientCpusOverride(this) + clientCores = CpuCores.parse(SessionPrefs.clientCpus(this)).ifEmpty { CpuCores.all.toSet() } + gameCores = CpuCores.parse(SessionPrefs.gameCpus(this)).ifEmpty { CpuCores.all.toSet() } + } + private fun refreshProtons() { protonRows = ProtonExtras.tools.map { ProtonRow(it.id, it.name, ProtonExtras.installed(this, it), ProtonExtras.queued(this, it)) } } diff --git a/app/src/main/java/com/steamdeck/launcher/core/CpuCores.kt b/app/src/main/java/com/steamdeck/launcher/core/CpuCores.kt new file mode 100644 index 000000000..62b04a517 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/core/CpuCores.kt @@ -0,0 +1,41 @@ +package com.steamdeck.launcher.core + +import java.io.File + +/** + * The device's cores as taskset names them, for the two masks a Steam session can carry. + * + * A list is stored the way taskset's `-c` takes it - comma-separated core numbers - so what the + * dialog produces is what the session script and the Proton wrappers consume unchanged. + */ +object CpuCores { + /** Core numbers present on this device, in order. */ + val all: List by lazy { (0 until Runtime.getRuntime().availableProcessors()).toList() } + + /** The core's ceiling in GHz for the dialog's labels, or null where the kernel hides it. */ + fun maxGhz(core: Int): Double? = + FileUtils.readString(File("/sys/devices/system/cpu/cpu$core/cpufreq/cpuinfo_max_freq")) + ?.trim()?.toLongOrNull()?.let { it / 1_000_000.0 } + + fun parse(list: String): Set = + list.split(',').mapNotNull { it.trim().toIntOrNull() }.filter { it in all }.toSet() + + fun format(cores: Set): String = cores.sorted().joinToString(",") + + /** The stored list, or every core when nothing (or nothing valid) is stored. */ + fun listOrAll(list: String): String { + val cores = parse(list) + return format(if (cores.isEmpty()) all.toSet() else cores) + } + + /** + * The list only when it is a real restriction, else "". Empty, absent, or naming every core + * all mean "no preference": pinning a process to all cores is what the scheduler does + * unaided, and passing it would only put a meaningless line in the session log. Bannerlator's + * rule, kept for the game mask. + */ + fun restrictionOrEmpty(list: String): String { + val cores = parse(list) + return if (cores.isEmpty() || cores.size >= all.size) "" else format(cores) + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index ae989e1f6..79eb96fa2 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -80,6 +80,31 @@ object SessionPrefs { prefs(context).edit().putBoolean("mic", on).apply() } + /** + * Whether the client's own core pick is overridden. When on, BL_CLIENT_CPUS is sent even when + * it names every core - unlike a game mask, the point here is to undo a pin Steam applies to + * itself, and the scheduler's default is exactly what Steam's choice takes away. + */ + fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false) + + fun setClientCpusOverride(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("clientCpusOverride", on).apply() + } + + /** Comma-separated core list for the client (taskset -c syntax); "" = every core. */ + fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "") ?: "" + + fun setClientCpus(context: Context, list: String) { + prefs(context).edit().putString("clientCpus", list).apply() + } + + /** Comma-separated core list for games (taskset -c syntax); "" = every core = nothing sent. */ + fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "") ?: "" + + fun setGameCpus(context: Context, list: String) { + prefs(context).edit().putString("gameCpus", list).apply() + } + /** The Android driver the compositor loads: "" = pick by GPU, else a bundled or imported id. */ @JvmStatic fun androidDriver(context: Context): String = prefs(context).getString("androidDriver", "") ?: "" diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 493df896c..1dc0eacbd 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -21,6 +21,7 @@ import com.steamdeck.launcher.R import com.steamdeck.launcher.SessionActivity import com.steamdeck.launcher.audio.DirectAudioRelayComponent import com.steamdeck.launcher.audio.PulseAudioComponent +import com.steamdeck.launcher.core.CpuCores import com.steamdeck.launcher.core.EnvVars import com.steamdeck.launcher.core.EnvironmentComponent import com.steamdeck.launcher.core.FileUtils @@ -134,6 +135,17 @@ class SessionService : Service() { // imported driver from the A710/A720/A722 legs gets "sysmem" on its own, which is what both // its authors advise for those GPUs and what nothing else in the list needs. tuDebug(linuxDriverId)?.let { guest.add("TU_DEBUG=$it") } + // Core masks, Bannerlator's two (cfca3912). The client's is sent whenever the override is + // on, even naming every core: it exists to undo the pin Steam applies to its own interface + // renderer, and the scheduler's default is exactly what that pin takes away. A game's is + // sent only when it is a real restriction - a game has no pin of its own to undo. + if (SessionState.mode == MODE_STEAM) { + if (SessionPrefs.clientCpusOverride(this)) { + guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this))) + } + CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(this)) + .takeIf { it.isNotEmpty() }?.let { guest.add("BL_GAME_CPUS=$it") } + } // PulseAudio always: the client is a native Linux program and has no other way to make a // sound - its menus, its music and its voice chat all go through here. DirectAudio is not diff --git a/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt index 888b55c05..4ace9f59c 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/MainScreen.kt @@ -68,6 +68,7 @@ fun MainScreen( onProtons: () -> Unit, onDrivers: () -> Unit, onAudio: () -> Unit, + onPerformance: () -> Unit, onOffline: () -> Unit, onCredits: () -> Unit, ) { @@ -187,6 +188,10 @@ fun MainScreen( onClick = onAudio, modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k).height(buttonHeight + 5.dp * k), ) { Text("Audio", fontSize = 13.sp * k) } + OutlinedButton( + onClick = onPerformance, + modifier = Modifier.fillMaxWidth().padding(top = 5.dp * k).height(buttonHeight + 5.dp * k), + ) { Text("Performance", fontSize = 13.sp * k) } Spacer(Modifier.height(10.dp * k)) Text( "by The412Banner and maxjivi05 · credits", diff --git a/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt new file mode 100644 index 000000000..4bab672c9 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt @@ -0,0 +1,95 @@ +package com.steamdeck.launcher.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** One core as the dialog labels it: its number and, where known, its ceiling. */ +class CoreRow(val core: Int, val label: String) + +/** + * The two core masks a Steam session carries, kept separate because they are wanted at the same + * time and suit different things: the client's menus and a game each get their own. Both apply + * at the next session start; the game's is applied by the Proton wrapper at each launch. + */ +@Composable +fun PerformanceDialog( + cores: List, + clientOverride: Boolean, + clientCores: Set, + gameCores: Set, + onClientOverride: (Boolean) -> Unit, + onClientCore: (Int, Boolean) -> Unit, + onGameCore: (Int, Boolean) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Performance") }, + text = { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text("Steam client cores", style = MaterialTheme.typography.titleSmall) + Text( + "Steam pins its own interface to a subset of cores it chooses — on one device 5 of 8, leaving out the fastest — " + + "which suits a running game and makes the menus sluggish when the client is all there is. Turning this on " + + "pins the client, its UI helper and gamescope to the cores ticked below instead, re-applied every few seconds " + + "because the UI keeps spawning helpers that inherit Steam's choice. Every core ticked is the usual fix.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("Override Steam's own core choice", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Switch(checked = clientOverride, onCheckedChange = onClientOverride) + } + CoreGrid(cores, clientCores, enabled = clientOverride, onToggle = onClientCore) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(10.dp)) + + Text("Game cores", style = MaterialTheme.typography.titleSmall) + Text( + "The cores a game the client launches may run on, applied by exec'ing it through taskset so every thread " + + "inherits the mask from its first instruction. Leaving every core ticked sends nothing — that is what the " + + "scheduler does unaided. Untick the small cores to keep a heavy game off them.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + CoreGrid(cores, gameCores, enabled = true, onToggle = onGameCore) + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, + ) +} + +@Composable +private fun CoreGrid(cores: List, selected: Set, enabled: Boolean, onToggle: (Int, Boolean) -> Unit) { + for (row in cores.chunked(2)) { + Row(modifier = Modifier.fillMaxWidth()) { + for (c in row) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Checkbox(checked = c.core in selected, enabled = enabled, onCheckedChange = { onToggle(c.core, it) }) + Text(c.label, style = MaterialTheme.typography.bodySmall) + } + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } +} diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 01c4136fd..59cf5b3b6 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -186,6 +186,23 @@ case $mode in done < "$HOME/.bl-steam-urls" rm -f "$HOME/.bl-steam-urls" fi + # Cores for the client itself. Steam pins its own interface renderer to a subset - on the + # device it took 5 of 8, leaving out both little cores and the fastest one - which is a + # reasonable thing to do while a game is running and the wrong thing when the client is all + # there is. BL_CLIENT_CPUS overrides it. Re-applied on a beat because steamwebhelper spawns + # children continuously and each one inherits Steam's choice, not ours. + if [ -n "${BL_CLIENT_CPUS:-}" ]; then + step "client cores: $BL_CLIENT_CPUS" + ( + for _ in $(seq 1 720); do + for _p in $(pgrep -f 'steamwebhelper|steamrtarm64/steam|gamescope' 2>/dev/null); do + taskset -cp "$BL_CLIENT_CPUS" "$_p" >/dev/null 2>&1 || true + done + sleep 5 + done + ) & + fi + step "registering the app's game library with Steam" bannerlator-steam-library "$steam_root" || true step "setting up the ARM64 Proton compatibility tool"