fix: Steam's UI draws at an even share of the refresh near 60 fps instead of the panel's full rate (lag and heat since 0.6.7) (#1809)

This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-10-30 02:16:25 -04:00
1 parent b0a8c2065d
commit f69e2d0171
9 files changed
+92 -12

No files matched your search

@@ -211,6 +211,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var touchscreenGestures: TouchscreenGestures
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var steamUiFocused = false
private var gestureSettingsOpen by mutableStateOf(false)
private var steamImportAsk by mutableStateOf<com.droiddeck.launcher.session.SteamImportPrompt.Ask?>(null)
private var steamImportAnswered: String? = null
@@ -829,6 +830,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.focusRevision) {
val steamUi = SessionState.mode == SessionService.MODE_STEAM && kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
AgentGuest.focus(this@SessionActivity)?.optLong("focusedApp")?.let { it <= 0 || it == 769L } ?: true
}
if (steamUi != steamUiFocused) {
steamUiFocused = steamUi
applySteamUiPacing()
}
}
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
@@ -2060,7 +2070,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
CompositorHost.startOrAttach(
this, holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(),
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(), steamUiLimit(refreshHz()),
)
onOutputAttached(holder)
if (SessionState.running && size != previous) {
@@ -2200,12 +2210,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun outputFpsLimit(): Int = SessionPrefs.compositorFpsLimit(mirrorRefreshLimit())
private fun steamUiLimit(hz: Float): Int =
if (steamUiFocused && SessionPrefs.steamUiPacing(this)) SessionPrefs.steamUiFpsLimit(hz) else 0
private fun applySteamUiPacing() {
if (!CompositorHost.isStarted) return
val hz = refreshHz()
val limit = steamUiLimit(hz)
CompositorHost.updatePacing(hz, outputFpsLimit(), limit)
Log.i(TAG, "pacing: " + if (limit > 0) "Steam UI at $limit fps on a ${Math.round(hz)} Hz screen" else "uncapped")
}
private fun applyOutputPacing() {
Log.i(TAG, "perf: " + PerfMode.apply(this, outputFpsLimit(), mirrorRefreshLimit()))
if (CompositorHost.isStarted) {
val hz = refreshHz()
val changed = kotlin.math.abs(SessionState.refreshHz - hz) > 0.1f
CompositorHost.updatePacing(hz, outputFpsLimit())
CompositorHost.updatePacing(hz, outputFpsLimit(), steamUiLimit(hz))
pushCardboard(activeCardboard)
if (changed && ::surfaceView.isInitialized) applyFrameGen()
}
@@ -1094,6 +1094,15 @@ object SessionPrefs {
fun compositorFpsLimit(mirrorRefreshLimit: Int): Int =
if (mirrorRefreshLimit <= 0) FPS_UNPACED else mirrorRefreshLimit
private const val STEAM_UI_FPS = 60f
fun steamUiPacing(context: Context): Boolean = prefs(context).getBoolean("steamUiPacing", true)
fun steamUiFpsLimit(refreshHz: Float): Int {
val steps = ((refreshHz + 0.5f) / STEAM_UI_FPS).toInt()
return if (steps < 2) 0 else (refreshHz / steps + 0.01f).toInt()
}
val upscalerChoices = listOf(
0 to "Linear", 2 to "Nearest", 4 to "AMD FSR 1", 3 to "Snapdragon GSR",
8 to "Snapdragon GSR (quality)", 7 to "NVIDIA NIS", 6 to "Sharpen only",
@@ -535,10 +535,6 @@ class SessionService : Service() {
// gamescope's WSI layer and winex11 reuse their per-present window checks for this long.
guest.add("DROIDDECK_WSI_BYPASS_CHECK_MS=100")
guest.add("WINE_OFFSCREEN_CHECK_MS=100")
// gamescope turns buffers around slower than an uncapped game draws; more images keep it drawing.
// The WSI layer goes back to its default count while Steam's Frame Limit holds the game.
guest.add("GAMESCOPE_WSI_MIN_IMAGE_COUNT=8")
guest.add("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1")
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
guest.add("BL_LOG=" + sessionLog.path)
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
@@ -51,10 +51,11 @@ object CompositorHost {
outputHeight: Int,
refreshHz: Float,
fpsLimit: Int,
uiLimit: Int = 0,
): Boolean {
ownVsyncThread = SessionPrefs.vsyncThread(context)
WaylandCompositor.nativeSetRenderOnCommit(SessionPrefs.renderOnCommit(context))
pace(refreshHz, fpsLimit)
pace(refreshHz, fpsLimit, uiLimit)
if (!SessionState.running) WaylandCompositor.nativeSetOutputSize(outputWidth, outputHeight)
if (started) {
attached = surface
@@ -72,16 +73,16 @@ object CompositorHost {
return true
}
private fun pace(refreshHz: Float, fpsLimit: Int) {
WaylandCompositor.nativeSetFpsLimit(fpsLimit)
private fun pace(refreshHz: Float, fpsLimit: Int, uiLimit: Int = 0) {
WaylandCompositor.nativeSetFpsLimit(if (fpsLimit == SessionPrefs.FPS_UNPACED && uiLimit > 0) uiLimit else fpsLimit)
WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz)
WaylandCompositor.nativeSetOutputRefreshRate(refreshHz)
SessionState.refreshHz = refreshHz
}
@Synchronized
fun updatePacing(refreshHz: Float, fpsLimit: Int) {
if (started) pace(refreshHz, fpsLimit)
fun updatePacing(refreshHz: Float, fpsLimit: Int, uiLimit: Int = 0) {
if (started) pace(refreshHz, fpsLimit, uiLimit)
}
@Synchronized
@@ -0,0 +1,22 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Test
class SteamUiPacingTest {
@Test fun aSixtyOrNinetyHertzScreenIsLeftUncapped() {
assertEquals(0, SessionPrefs.steamUiFpsLimit(60f))
assertEquals(0, SessionPrefs.steamUiFpsLimit(59.94f))
assertEquals(0, SessionPrefs.steamUiFpsLimit(90f))
}
@Test fun fasterScreensGetAnEvenDivisorOfTheirRefresh() {
assertEquals(60, SessionPrefs.steamUiFpsLimit(120f))
assertEquals(60, SessionPrefs.steamUiFpsLimit(119.99f))
assertEquals(60, SessionPrefs.steamUiFpsLimit(120.00001f))
assertEquals(72, SessionPrefs.steamUiFpsLimit(144.00002f))
assertEquals(82, SessionPrefs.steamUiFpsLimit(165f))
assertEquals(61, SessionPrefs.steamUiFpsLimit(185.00002f))
assertEquals(60, SessionPrefs.steamUiFpsLimit(240f))
}
}
+8
View File
@@ -105,6 +105,14 @@ gamescope's WSI layer presents every swapchain in FIFO at the limit. A
`vblank_mode` from the launch options or the environment editor is kept, and
**Remove** in the editor leaves Mesa's own default.
A game launch (`waitforexitandrun`) also gets
`GAMESCOPE_WSI_MIN_IMAGE_COUNT=8` and `GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT=1`,
so an uncapped game keeps drawing while gamescope turns buffers around. The
session itself no longer sets them: the Steam client's UI renders through the
same WSI layer, and with eight images and no frame limit it drew flat out
(about 240 frames a second at 120 Hz) and heated the device. Values set in the
launch options or the environment editor are kept.
## Defaults and available suggestions
The initial configuration enables Mesa shader caching with
@@ -448,6 +448,14 @@ def gl_vsync(env):
return True
def game_images(env):
if game_id(env) is None:
return False
env.setdefault("GAMESCOPE_WSI_MIN_IMAGE_COUNT", "8")
env.setdefault("GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT", "1")
return True
def apply_config(env, config, app_id, fixes=None):
if config.get("version") != 1:
raise ValueError("unsupported environment configuration")
@@ -747,6 +755,8 @@ def main():
if app_id:
gl_default = gl_vsync(env)
waiting = len(command) > 1 and command[1] == "waitforexitandrun"
if waiting:
game_images(env)
fixes = {}
if waiting:
command = skip_red_launcher(command)
+2 -1
View File
@@ -1086,7 +1086,8 @@ class LauncherTest(SyncTestCase):
(str(tool / "proton"), command)])
self.assertEqual((calls[0][2]["WINEESYNC"], calls[0][2]["PROTON_NO_ESYNC"], calls[0][2]["BL_SYNC_PACK"]), ("0", "1", pack["id"]))
self.assertEqual(calls[1][2], self.env(STEAM_COMPAT_APP_ID="2630", PROTON_NO_NTSYNC="1", BL_SYNC="0", WINEESYNC="1",
vblank_mode="0"))
vblank_mode="0", GAMESCOPE_WSI_MIN_IMAGE_COUNT="8",
GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT="1"))
self.assertIn("could not start (", said.getvalue())
def test_generated_launchers_run_the_dist_proton(self):
+12
View File
@@ -605,6 +605,18 @@ class GameEnvironmentTest(unittest.TestCase):
self.assertEqual(env.get("WINE_CPU_TOPOLOGY"), expected, env)
self.assertEqual(env.get("PROTON_CPU_TOPOLOGY"), proton, env)
def test_only_games_get_the_larger_swapchain(self):
game = {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/945360"}
self.assertTrue(MODULE["game_images"](game))
self.assertEqual(game["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "8")
self.assertEqual(game["GAMESCOPE_WSI_ENSURE_MIN_IMAGE_COUNT"], "1")
own = {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/945360", "GAMESCOPE_WSI_MIN_IMAGE_COUNT": "3"}
MODULE["game_images"](own)
self.assertEqual(own["GAMESCOPE_WSI_MIN_IMAGE_COUNT"], "3")
for env in ({}, {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/0"}):
self.assertFalse(MODULE["game_images"](env))
self.assertNotIn("GAMESCOPE_WSI_MIN_IMAGE_COUNT", env)
def test_opengl_games_do_not_wait_for_the_screen_refresh(self):
cases = [
({}, "0"),