diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index aac2d6c99..5bd783ce4 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -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(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() } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index c4eaa7896..e8480def0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 682b86c98..3bfdd2d53 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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) diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index ea06879fa..e1e54f770 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -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 diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamUiPacingTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamUiPacingTest.kt new file mode 100644 index 000000000..239d52e98 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SteamUiPacingTest.kt @@ -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)) + } +} diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index 060a7fbae..ba435af2c 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index fe0d794bc..696a95581 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -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) diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py index b6fff3578..95ba8ede3 100644 --- a/tools/tests/test_esync_packs.py +++ b/tools/tests/test_esync_packs.py @@ -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): diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index 9010e1295..1f178c0f8 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -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"),