From 80c362eb8abba38611d2094c028a71c167f7fa6c Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Thu, 2 Apr 2026 15:30:25 -0400 Subject: [PATCH 01/11] feat: add in-game quick menu fps limiter --- .../app/gamenative/ui/component/QuickMenu.kt | 67 +++++++++++++++++ .../ui/screen/xserver/XServerScreen.kt | 62 ++++++++++++++-- .../com/winlator/renderer/GLRenderer.java | 9 +++ .../java/com/winlator/widget/XServerView.java | 72 +++++++++++++++++++ app/src/main/res/values/strings.xml | 5 ++ 5 files changed, 210 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index bd0ea12a56..2d22efb1dc 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -210,6 +210,37 @@ private fun matchesPerformanceHudPreset( currentConfig.showGpuUsageGraph == presetConfig.showGpuUsageGraph } +private fun fpsLimiterSteps(maxFps: Int): List { + val sanitizedMax = maxFps.coerceAtLeast(30) + return buildList { + for (value in 15..sanitizedMax step 5) { + add(value) + } + if (isEmpty() || last() != sanitizedMax) { + add(sanitizedMax) + } + add(0) + }.distinct() +} + +private fun fpsLimiterProgress(currentValue: Int, maxFps: Int): Float { + val steps = fpsLimiterSteps(maxFps) + val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: (steps.lastIndex) + return if (steps.lastIndex <= 0) 1f else currentIndex.toFloat() / steps.lastIndex.toFloat() +} + +private fun nextFpsLimiterValue(currentValue: Int, maxFps: Int): Int { + val steps = fpsLimiterSteps(maxFps) + val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: steps.lastIndex + return steps[(currentIndex + 1).coerceAtMost(steps.lastIndex)] +} + +private fun previousFpsLimiterValue(currentValue: Int, maxFps: Int): Int { + val steps = fpsLimiterSteps(maxFps) + val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: steps.lastIndex + return steps[(currentIndex - 1).coerceAtLeast(0)] +} + @Composable fun QuickMenu( isVisible: Boolean, @@ -218,7 +249,10 @@ fun QuickMenu( renderer: GLRenderer? = null, isPerformanceHudEnabled: Boolean = false, performanceHudConfig: PerformanceHudConfig = PerformanceHudConfig(), + fpsLimiterValue: Int = 0, + fpsLimiterMax: Int = 60, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit = {}, + onFpsLimiterChanged: (Int) -> Unit = {}, hasPhysicalController: Boolean = false, activeToggleIds: Set = emptySet(), modifier: Modifier = Modifier, @@ -458,10 +492,13 @@ fun QuickMenu( PerformanceHudQuickMenuTab( isPerformanceHudEnabled = isPerformanceHudEnabled, performanceHudConfig = performanceHudConfig, + fpsLimiterValue = fpsLimiterValue, + fpsLimiterMax = fpsLimiterMax, onTogglePerformanceHud = { onItemSelected(QuickMenuAction.PERFORMANCE_HUD) }, onPerformanceHudConfigChanged = onPerformanceHudConfigChanged, + onFpsLimiterChanged = onFpsLimiterChanged, scrollState = hudScrollState, focusRequester = hudItemFocusRequester, modifier = Modifier.fillMaxSize(), @@ -541,8 +578,11 @@ fun QuickMenu( private fun PerformanceHudQuickMenuTab( isPerformanceHudEnabled: Boolean, performanceHudConfig: PerformanceHudConfig, + fpsLimiterValue: Int, + fpsLimiterMax: Int, onTogglePerformanceHud: () -> Unit, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit, + onFpsLimiterChanged: (Int) -> Unit, scrollState: ScrollState, focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, @@ -566,6 +606,33 @@ private fun PerformanceHudQuickMenuTab( Spacer(modifier = Modifier.height(8.dp)) + QuickMenuSectionHeader( + title = stringResource(R.string.performance_hud_fps_limiter), + subtitle = stringResource( + R.string.performance_hud_fps_limiter_description, + fpsLimiterMax, + ), + ) + + QuickMenuAdjustmentRow( + title = stringResource(R.string.performance_hud_fps_limiter_target), + valueText = if (fpsLimiterValue <= 0) { + stringResource(R.string.performance_hud_fps_limiter_off) + } else { + stringResource(R.string.performance_hud_fps_limiter_value, fpsLimiterValue) + }, + progress = fpsLimiterProgress(fpsLimiterValue, fpsLimiterMax), + onDecrease = { + onFpsLimiterChanged(previousFpsLimiterValue(fpsLimiterValue, fpsLimiterMax)) + }, + onIncrease = { + onFpsLimiterChanged(nextFpsLimiterValue(fpsLimiterValue, fpsLimiterMax)) + }, + accentColor = accentColor, + ) + + Spacer(modifier = Modifier.height(8.dp)) + QuickMenuSectionHeader( title = stringResource(R.string.performance_hud_presets), ) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index aa475f2a3f..8d64c6720a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -17,6 +17,7 @@ import android.view.WindowInsets import android.view.inputmethod.InputMethodManager import android.widget.FrameLayout import android.widget.LinearLayout +import android.hardware.display.DisplayManager import android.hardware.input.InputManager import android.view.InputDevice import androidx.activity.ComponentActivity @@ -39,6 +40,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -181,6 +183,7 @@ import java.util.Arrays import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import kotlin.io.path.name +import kotlin.math.roundToInt import kotlin.text.lowercase import com.winlator.PrefManager as WinlatorPrefManager @@ -194,6 +197,28 @@ private val isExiting = AtomicBoolean(false) private const val EXIT_PROCESS_TIMEOUT_MS = 30_000L private const val EXIT_PROCESS_POLL_INTERVAL_MS = 1_000L private const val EXIT_PROCESS_RESPONSE_TIMEOUT_MS = 2_000L +private const val DEFAULT_FPS_LIMITER_MAX_HZ = 60 + +private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { + val display = attachedView?.display + ?: context.display + ?: ContextCompat.getSystemService(context, DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY) + + val refreshRate = when { + display == null -> DEFAULT_FPS_LIMITER_MAX_HZ.toFloat() + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> { + val supportedMax = display.supportedModes.maxOfOrNull { it.refreshRate } ?: display.refreshRate + if (supportedMax.isFinite() && supportedMax > 0f) supportedMax else display.refreshRate + } + else -> display.refreshRate + } + + return refreshRate + .takeIf { it.isFinite() && it > 0f } + ?.roundToInt() + ?.coerceAtLeast(30) + ?: DEFAULT_FPS_LIMITER_MAX_HZ +} private data class XServerViewReleaseBinding( val xServerView: XServerView, @@ -379,6 +404,9 @@ fun XServerScreen( var hasInternalTouchpad by remember { mutableStateOf(false) } var hasUpdatedScreenGamepad by remember { mutableStateOf(false) } var isPerformanceHudEnabled by remember { mutableStateOf(PrefManager.showFps) } + val shouldTrackDisplayedFrames = remember { AtomicBoolean(false) } + var detectedMaxRefreshRateHz by remember { mutableIntStateOf(detectMaxRefreshRateHz(context, null)) } + var fpsLimiterValue by rememberSaveable { mutableIntStateOf(0) } fun loadPerformanceHudConfig(): PerformanceHudConfig { return PerformanceHudConfig( @@ -441,6 +469,22 @@ fun XServerScreen( performanceHudView?.setConfig(config) } + fun applyFpsLimiter(limit: Int) { + val sanitizedLimit = if (limit <= 0) 0 else limit.coerceAtMost(detectedMaxRefreshRateHz) + fpsLimiterValue = sanitizedLimit + xServerView?.setFrameRateLimit(sanitizedLimit) + } + + LaunchedEffect(xServerView) { + val detectedMax = detectMaxRefreshRateHz(context, xServerView) + detectedMaxRefreshRateHz = detectedMax + val clampedLimit = if (fpsLimiterValue <= 0) 0 else fpsLimiterValue.coerceAtMost(detectedMax) + if (clampedLimit != fpsLimiterValue) { + fpsLimiterValue = clampedLimit + } + xServerView?.setFrameRateLimit(clampedLimit) + } + fun restorePerformanceHudPosition() { val host = performanceHudHost ?: return val hud = performanceHudView ?: return @@ -1357,8 +1401,14 @@ fun XServerScreen( xServerToUse, ).apply { xServerView = this + setFrameRateLimit(fpsLimiterValue) val renderer = this.renderer renderer.isCursorVisible = false + renderer.setOnFrameRenderedListener { + if (shouldTrackDisplayedFrames.get()) { + frameRating?.update() + } + } getxServer().renderer = renderer PluviaApp.touchpadView = TouchpadView(context, getxServer(), PrefManager.getBoolean("capture_pointer_on_external_mouse", true)) frameLayout.addView(PluviaApp.touchpadView) @@ -1408,6 +1458,7 @@ fun XServerScreen( property.nameAsString().contains("_MESA_DRV") || container.containerVariant.equals(Container.GLIBC) && property.nameAsString().contains("_NET_WM_SURFACE"))) { frameRatingWindowId = window.id + shouldTrackDisplayedFrames.set(true) (context as? Activity)?.runOnUiThread { frameRating?.visibility = View.VISIBLE } @@ -1415,6 +1466,7 @@ fun XServerScreen( } } else if (frameRatingWindowId != -1) { frameRatingWindowId = -1 + shouldTrackDisplayedFrames.set(false) (context as? Activity)?.runOnUiThread { frameRating?.visibility = View.GONE } @@ -1425,11 +1477,6 @@ fun XServerScreen( if (!container.isDisableMouseInput && !container.isTouchscreenMode) renderer?.setCursorVisible(true) xServerState.value.winStarted = true } - if (window.id == frameRatingWindowId) { - (context as? Activity)?.runOnUiThread { - frameRating?.update() - } - } } override fun onModifyWindowProperty(window: Window, property: Property) { @@ -1846,10 +1893,12 @@ fun XServerScreen( gameRoot = null removePerformanceHud() performanceHudHost = null + shouldTrackDisplayedFrames.set(false) val releaseBinding = view.tag as? XServerViewReleaseBinding releaseBinding?.let { binding -> // Remove the WindowManager listener associated with the released AndroidView. + binding.xServerView.renderer.setOnFrameRenderedListener(null) binding.xServerView.getxServer().windowManager.removeOnWindowModificationListener(binding.windowModificationListener) if (PluviaApp.xServerView === binding.xServerView) { PluviaApp.xServerView = null @@ -1979,7 +2028,10 @@ fun XServerScreen( renderer = xServerView?.renderer, isPerformanceHudEnabled = isPerformanceHudEnabled, performanceHudConfig = performanceHudConfig, + fpsLimiterValue = fpsLimiterValue, + fpsLimiterMax = detectedMaxRefreshRateHz, onPerformanceHudConfigChanged = ::applyPerformanceHudConfig, + onFpsLimiterChanged = ::applyFpsLimiter, hasPhysicalController = hasPhysicalController, activeToggleIds = buildSet { if (areControlsVisible) add(QuickMenuAction.INPUT_CONTROLS) diff --git a/app/src/main/java/com/winlator/renderer/GLRenderer.java b/app/src/main/java/com/winlator/renderer/GLRenderer.java index ad31d36d0b..9cb5fb036e 100644 --- a/app/src/main/java/com/winlator/renderer/GLRenderer.java +++ b/app/src/main/java/com/winlator/renderer/GLRenderer.java @@ -33,6 +33,7 @@ public class GLRenderer implements GLSurfaceView.Renderer, WindowManager.OnWindowModificationListener, Pointer.OnPointerMotionListener { public final XServerView xServerView; private final XServer xServer; + private Runnable onFrameRenderedListener; private final VertexAttribute quadVertices = new VertexAttribute("position", 2); private final float[] tmpXForm1 = XForm.getInstance(); private final float[] tmpXForm2 = XForm.getInstance(); @@ -131,6 +132,10 @@ public void onDrawFrame(GL10 gl) { else { drawScene(); } + + if (onFrameRenderedListener != null) { + onFrameRenderedListener.run(); + } } void drawScene() { @@ -301,6 +306,10 @@ public void toggleFullscreen() { xServerView.requestRender(); } + public void setOnFrameRenderedListener(Runnable onFrameRenderedListener) { + this.onFrameRenderedListener = onFrameRenderedListener; + } + private Drawable createRootCursorDrawable() { Context context = xServerView.getContext(); BitmapFactory.Options options = new BitmapFactory.Options(); diff --git a/app/src/main/java/com/winlator/widget/XServerView.java b/app/src/main/java/com/winlator/widget/XServerView.java index 415edade71..d76645bacd 100644 --- a/app/src/main/java/com/winlator/widget/XServerView.java +++ b/app/src/main/java/com/winlator/widget/XServerView.java @@ -4,6 +4,9 @@ import android.content.Context; import android.graphics.Rect; import android.opengl.GLSurfaceView; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import android.view.MotionEvent; import android.view.View; @@ -24,6 +27,19 @@ public class XServerView extends GLSurfaceView { private final GLRenderer renderer; // private final ArrayList> mouseEventCallbacks = new ArrayList<>(); private final XServer xServer; + private final Object renderThrottleLock = new Object(); + private final Handler renderThrottleHandler = new Handler(Looper.getMainLooper()); + private int frameRateLimit = 0; + private long minRenderIntervalMs = 0L; + private long lastRenderRequestUptimeMs = 0L; + private boolean renderRequestScheduled = false; + private final Runnable throttledRenderRunnable = () -> { + synchronized (renderThrottleLock) { + renderRequestScheduled = false; + lastRenderRequestUptimeMs = SystemClock.uptimeMillis(); + } + XServerView.super.requestRender(); + }; public XServerView(Context context, XServer xServer) { super(context); @@ -54,6 +70,62 @@ public GLRenderer getRenderer() { return renderer; } + public int getFrameRateLimit() { + synchronized (renderThrottleLock) { + return frameRateLimit; + } + } + + public void setFrameRateLimit(int frameRateLimit) { + final boolean shouldKickRender; + synchronized (renderThrottleLock) { + this.frameRateLimit = Math.max(0, frameRateLimit); + minRenderIntervalMs = this.frameRateLimit > 0 + ? Math.max(1L, Math.round(1000f / (float) this.frameRateLimit)) + : 0L; + + if (this.frameRateLimit == 0 && renderRequestScheduled) { + renderThrottleHandler.removeCallbacks(throttledRenderRunnable); + renderRequestScheduled = false; + } + shouldKickRender = this.frameRateLimit == 0; + } + + if (shouldKickRender) { + super.requestRender(); + } + } + + @Override + public void requestRender() { + long delayMs = 0L; + + synchronized (renderThrottleLock) { + if (frameRateLimit <= 0) { + super.requestRender(); + return; + } + + long now = SystemClock.uptimeMillis(); + long remainingDelay = minRenderIntervalMs - (now - lastRenderRequestUptimeMs); + if (!renderRequestScheduled && remainingDelay <= 0L) { + lastRenderRequestUptimeMs = now; + } else { + if (renderRequestScheduled) { + return; + } + renderRequestScheduled = true; + delayMs = Math.max(1L, remainingDelay); + } + } + + if (delayMs == 0L) { + super.requestRender(); + } else { + renderThrottleHandler.postDelayed(throttledRenderRunnable, delayMs); + } + } + // public void addPointerEventListener(Callback listener) { // mouseEventCallbacks.add(listener); // } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index feb1d794b1..997f47cd4a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,6 +265,11 @@ Controller Performance HUD Show or hide the in-game overlay. + FPS limiter + Caps the displayed frame rate while in-game. Max: %1$d Hz. + Target FPS + Unlimited + %1$d FPS Presets Quickly switch between common HUD layouts. 1 From fa246de23554645b327f41b5d3f9f378792488d8 Mon Sep 17 00:00:00 2001 From: xXJsonDeruloXx Date: Sun, 12 Apr 2026 13:54:42 -0400 Subject: [PATCH 02/11] fix: localize fps limiter strings --- app/src/main/res/values-da/strings.xml | 5 +++++ app/src/main/res/values-de/strings.xml | 5 +++++ app/src/main/res/values-es/strings.xml | 5 +++++ app/src/main/res/values-fr/strings.xml | 5 +++++ app/src/main/res/values-it/strings.xml | 5 +++++ app/src/main/res/values-ko/strings.xml | 5 +++++ app/src/main/res/values-pl/strings.xml | 5 +++++ app/src/main/res/values-pt-rBR/strings.xml | 5 +++++ app/src/main/res/values-ro/strings.xml | 5 +++++ app/src/main/res/values-ru/strings.xml | 5 +++++ app/src/main/res/values-uk/strings.xml | 5 +++++ app/src/main/res/values-zh-rCN/strings.xml | 5 +++++ app/src/main/res/values-zh-rTW/strings.xml | 5 +++++ app/src/main/res/values/strings.xml | 2 +- 14 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index af305dd3fd..9d28470bad 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -226,6 +226,11 @@ Controller Ydelses-HUD Vis eller skjul overlayet i spillet. + FPS-begrænser + Begrænser billedhastigheden i spillet. Maks.: %1$d Hz. + Mål-FPS + Ubegrænset + %1$d FPS Forudindstillinger Skift hurtigt mellem almindelige HUD-layouts. 1 diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index af5057ffbe..a2d0b71182 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -291,6 +291,11 @@ Controller Leistungs-HUD Leistungsoverlay im Spiel ein- oder ausblenden. + FPS-Begrenzer + Begrenzt die Bildrate im Spiel. Max.: %1$d Hz. + Ziel-FPS + Unbegrenzt + %1$d FPS Voreinstellungen Schnell zwischen gängigen HUD-Layouts wechseln. 1 diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d8ee4df584..c228c05c6a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -313,6 +313,11 @@ Controles HUD de rendimiento Mostrar u ocultar la superposición en el juego. + Limitador de FPS + Limita la tasa de fotogramas en el juego. Máx.: %1$d Hz. + FPS objetivo + Ilimitado + %1$d FPS Preajustes Cambia rápidamente entre diseños de HUD comunes. 1 diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1b63a8bc9d..74ef4e62e8 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -298,6 +298,11 @@ Contrôles HUD de performance Afficher ou masquer la surcouche en jeu. + Limiteur de FPS + Limite la fréquence d’images en jeu. Max. : %1$d Hz. + FPS cible + Illimité + %1$d FPS Préréglages Basculer rapidement entre des dispositions HUD courantes. 1 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9d6be35376..369fbac729 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -302,6 +302,11 @@ Controller HUD prestazioni Mostra o nascondi l’overlay di gioco. + Limitatore FPS + Limita il frame rate in gioco. Max: %1$d Hz. + FPS obiettivo + Illimitato + %1$d FPS Predefiniti Passa rapidamente tra layout HUD comuni. 1 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index f9eff06fc9..537e788887 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -311,6 +311,11 @@ 컨트롤러 성능 HUD 게임 내 오버레이를 표시하거나 숨깁니다. + FPS 제한기 + 게임 내 프레임 속도를 제한합니다. 최대: %1$d Hz. + 목표 FPS + 무제한 + %1$d FPS 프리셋 일반적인 HUD 레이아웃으로 빠르게 전환합니다. 1 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d3c4748801..cd9d52ecca 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -310,6 +310,11 @@ Kontroler HUD wydajności Pokaż lub ukryj nakładkę w grze. + Ogranicznik FPS + Ogranicza liczbę klatek na sekundę w grze. Maks.: %1$d Hz. + Docelowy FPS + Bez limitu + %1$d FPS Presety Szybko przełączaj między typowymi układami HUD. 1 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 80545536e2..c00d5d04fa 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -226,6 +226,11 @@ Controle HUD de desempenho Mostrar ou ocultar a sobreposição no jogo. + Limitador de FPS + Limita a taxa de quadros no jogo. Máx.: %1$d Hz. + FPS alvo + Ilimitado + %1$d FPS Predefinições Alterne rapidamente entre layouts comuns de HUD. 1 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index c484ef78e5..bdecb3f133 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -300,6 +300,11 @@ Controller HUD de performanță Afișează sau ascunde suprapunerea din joc. + Limitator FPS + Limitează rata de cadre în joc. Max.: %1$d Hz. + FPS țintă + Nelimitat + %1$d FPS Presetări Comută rapid între aspecte HUD comune. 1 diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index c1ed94e4b2..26647929d8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -841,6 +841,11 @@ https://gamenative.app Контроллер HUD производительности Показывать или скрывать игровое наложение. + Ограничитель FPS + Ограничивает частоту кадров в игре. Макс.: %1$d Гц. + Целевой FPS + Без ограничений + %1$d FPS Предустановки Быстро переключаться между типовыми макетами HUD. 1 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 5b0890c4d8..ef63d0624c 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -293,6 +293,11 @@ Контролер HUD продуктивності Показувати або приховувати ігрове накладання. + Обмежувач FPS + Обмежує частоту кадрів у грі. Макс.: %1$d Гц. + Цільовий FPS + Без обмежень + %1$d FPS Набори Швидко перемикайтеся між типовими макетами HUD. 1 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 09b469cbbc..248cb796b9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -296,6 +296,11 @@ 控制器 性能 HUD 显示或隐藏游戏内叠加层。 + FPS 限制器 + 限制游戏内帧率。最高:%1$d Hz。 + 目标 FPS + 无限制 + %1$d FPS 预设 快速切换常用 HUD 布局。 1 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8d5d6fc539..ff7313113c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -296,6 +296,11 @@ 控制器 效能 HUD 顯示或隱藏遊戲內疊加層。 + FPS 限制器 + 限制遊戲內幀率。最高:%1$d Hz。 + 目標 FPS + 無限制 + %1$d FPS 預設 快速切換常用 HUD 版面配置。 1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 997f47cd4a..19e6924984 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -266,7 +266,7 @@ Performance HUD Show or hide the in-game overlay. FPS limiter - Caps the displayed frame rate while in-game. Max: %1$d Hz. + Caps the in-game frame rate. Max: %1$d Hz. Target FPS Unlimited %1$d FPS From 924f2d5b189897cdc6491fa22ce84716b15c2e09 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 2 Apr 2026 16:20:52 -0400 Subject: [PATCH 03/11] fix: fps limiter now throttles game render loop via Present extension back-pressure The previous implementation only throttled XServerView.requestRender() which controls Android GL compositing, not the game's actual render loop. Games are paced by PresentIdleNotify/PresentCompleteNotify events from the X Present extension - since these fired immediately, games rendered at full speed regardless. Fix: PresentExtension.presentPixmap() now schedules IdleNotify/CompleteNotify at the target frame interval using a monotonic watermark (nextScheduledUst). Pixels are still copied immediately so the drawable stays current, but the completion signals that unblock the game's render loop are delayed to match the configured FPS limit. This creates real back-pressure that reduces CPU/GPU usage. Also wires setFrameRateLimit() through to PresentExtension in both applyFpsLimiter() and the LaunchedEffect(xServerView) reattach path. --- .../ui/screen/xserver/XServerScreen.kt | 9 ++ .../xserver/extensions/PresentExtension.java | 86 +++++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index be54fa0828..168abc0ae9 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -161,6 +161,7 @@ import com.winlator.xserver.ScreenInfo import com.winlator.xserver.Window import com.winlator.xserver.WindowManager import com.winlator.xserver.XServer +import com.winlator.xserver.extensions.PresentExtension import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -473,6 +474,11 @@ fun XServerScreen( val sanitizedLimit = if (limit <= 0) 0 else limit.coerceAtMost(detectedMaxRefreshRateHz) fpsLimiterValue = sanitizedLimit xServerView?.setFrameRateLimit(sanitizedLimit) + // Also throttle the X Present extension so the game's own render loop + // receives back-pressure and actually reduces CPU/GPU usage. + xServerView?.getxServer() + ?.getExtension(PresentExtension.MAJOR_OPCODE.toInt()) + ?.setFrameRateLimit(sanitizedLimit) } LaunchedEffect(xServerView) { @@ -483,6 +489,9 @@ fun XServerScreen( fpsLimiterValue = clampedLimit } xServerView?.setFrameRateLimit(clampedLimit) + xServerView?.getxServer() + ?.getExtension(PresentExtension.MAJOR_OPCODE.toInt()) + ?.setFrameRateLimit(clampedLimit) } fun restorePerformanceHudPosition() { diff --git a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java index 1455fc4011..296c5e15f5 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java @@ -28,15 +28,54 @@ import java.io.IOException; import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; public class PresentExtension implements Extension { public static final byte MAJOR_OPCODE = -103; - private static final int FAKE_INTERVAL = 1000000 / 60; + private static final int FAKE_INTERVAL_DEFAULT_US = 1_000_000 / 60; public enum Kind {PIXMAP, MSC_NOTIFY} public enum Mode {COPY, FLIP, SKIP} private final SparseArray events = new SparseArray<>(); private SyncExtension syncExtension; + // FPS limiter: delays PresentIdleNotify/PresentCompleteNotify to create + // back-pressure on the game's render loop. Without this the game ignores the + // Android-side display throttle and renders at full speed regardless. + private volatile int frameRateLimit = 0; + private volatile long targetIntervalUs = 0L; + private long lastScheduledUst = 0L; // guarded by scheduleLock + private final Object scheduleLock = new Object(); + private final ScheduledExecutorService presentScheduler = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "PresentExt-FpsLimiter"); + t.setDaemon(true); + return t; + }); + + public void setFrameRateLimit(int limit) { + frameRateLimit = Math.max(0, limit); + if (frameRateLimit > 0) { + targetIntervalUs = 1_000_000L / frameRateLimit; + } else { + targetIntervalUs = 0L; + synchronized (scheduleLock) { + lastScheduledUst = 0L; + } + } + } + + /** Returns the UST (microseconds) at which this present should be signalled, + * advancing the internal watermark by targetIntervalUs each call. */ + private long nextScheduledUst(long nowUst) { + synchronized (scheduleLock) { + long next = Math.max(nowUst, lastScheduledUst) + targetIntervalUs; + lastScheduledUst = next; + return next; + } + } + private static abstract class ClientOpcodes { private static final byte QUERY_VERSION = 0; private static final byte PRESENT_PIXMAP = 1; @@ -128,13 +167,50 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre Drawable content = window.getContent(); if (content.visual.depth != pixmap.drawable.visual.depth) throw new BadMatch(); - long ust = System.nanoTime() / 1000; - long msc = ust / FAKE_INTERVAL; - + // Copy pixels immediately so the game's buffer is up-to-date on the XServer side. synchronized (content.renderLock) { content.copyArea((short)0, (short)0, xOff, yOff, pixmap.drawable.width, pixmap.drawable.height, pixmap.drawable); + } + + // PresentIdleNotify / PresentCompleteNotify are what actually pace the game's + // render loop. Delaying them here creates real back-pressure: the game must wait + // for IdleNotify before it can reuse a pixmap buffer, so it will naturally render + // no faster than the configured limit regardless of how many swapchain images it has. + long targetInterval = this.targetIntervalUs; + long nowUst = System.nanoTime() / 1000; + + if (targetInterval <= 0L) { + // No limit — fire immediately as before. + long msc = nowUst / FAKE_INTERVAL_DEFAULT_US; sendIdleNotify(window, pixmap, serial, idleFence); - sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.COPY, ust, msc); + sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.COPY, nowUst, msc); + } else { + long scheduledUst = nextScheduledUst(nowUst); + long delayUs = scheduledUst - nowUst; + + if (delayUs <= 1_000L) { + // Already within 1 ms of the target — send immediately. + long msc = scheduledUst / targetInterval; + sendIdleNotify(window, pixmap, serial, idleFence); + sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.COPY, scheduledUst, msc); + } else { + final Window finalWindow = window; + final Pixmap finalPixmap = pixmap; + final int finalSerial = serial; + final int finalIdleFence = idleFence; + final long finalScheduledUst = scheduledUst; + final long finalInterval = targetInterval; + presentScheduler.schedule(() -> { + try { + long msc = finalScheduledUst / finalInterval; + sendIdleNotify(finalWindow, finalPixmap, finalSerial, finalIdleFence); + sendCompleteNotify(finalWindow, finalSerial, Kind.PIXMAP, Mode.COPY, + finalScheduledUst, msc); + } catch (Exception ignored) { + // Client may have disconnected before the scheduled notify fired. + } + }, delayUs / 1_000L, TimeUnit.MILLISECONDS); + } } } From 5a31c3c266873e3a92b237fcc5c98ed3d823806a Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 2 Apr 2026 16:24:55 -0400 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20fps=20limiter=20redesign=20?= =?UTF-8?q?=E2=80=94=20toggle=20+=20remembered=20target,=20steps=205?= =?UTF-8?q?=E2=80=93maxHz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FPS limiter is now a toggle at the TOP of the HUD tab (above Perf HUD toggle). When OFF: game renders unlimited, slider is hidden. When ON: target slider appears via AnimatedVisibility. Toggling OFF and back ON restores the last-set target (e.g. 25 FPS stays 25). - Slider steps are 5, 10, 15, … up to display max Hz (no more arbitrary 15 floor or the confusing 0/Unlimited end-step — on/off is the toggle's job now). - State split: fpsLimiterEnabled (toggle) + fpsLimiterTarget (last target, default 30). applyFpsLimiterEnabled() / applyFpsLimiterTarget() replace the old applyFpsLimiter(). Both push the resolved limit (0 when disabled) to XServerView and PresentExtension. --- .../app/gamenative/ui/component/QuickMenu.kt | 86 ++++++++++++------- .../ui/screen/xserver/XServerScreen.kt | 45 ++++++---- 2 files changed, 83 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index 2d22efb1dc..fb8940809a 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -6,8 +6,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.BorderStroke @@ -211,15 +213,16 @@ private fun matchesPerformanceHudPreset( } private fun fpsLimiterSteps(maxFps: Int): List { - val sanitizedMax = maxFps.coerceAtLeast(30) + val sanitizedMax = maxFps.coerceAtLeast(5) return buildList { - for (value in 15..sanitizedMax step 5) { + var value = 5 + while (value <= sanitizedMax) { add(value) + value += 5 } - if (isEmpty() || last() != sanitizedMax) { + if (last() != sanitizedMax) { add(sanitizedMax) } - add(0) }.distinct() } @@ -249,9 +252,11 @@ fun QuickMenu( renderer: GLRenderer? = null, isPerformanceHudEnabled: Boolean = false, performanceHudConfig: PerformanceHudConfig = PerformanceHudConfig(), - fpsLimiterValue: Int = 0, + fpsLimiterEnabled: Boolean = false, + fpsLimiterTarget: Int = 30, fpsLimiterMax: Int = 60, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit = {}, + onFpsLimiterEnabledChanged: (Boolean) -> Unit = {}, onFpsLimiterChanged: (Int) -> Unit = {}, hasPhysicalController: Boolean = false, activeToggleIds: Set = emptySet(), @@ -492,12 +497,14 @@ fun QuickMenu( PerformanceHudQuickMenuTab( isPerformanceHudEnabled = isPerformanceHudEnabled, performanceHudConfig = performanceHudConfig, - fpsLimiterValue = fpsLimiterValue, + fpsLimiterEnabled = fpsLimiterEnabled, + fpsLimiterTarget = fpsLimiterTarget, fpsLimiterMax = fpsLimiterMax, onTogglePerformanceHud = { onItemSelected(QuickMenuAction.PERFORMANCE_HUD) }, onPerformanceHudConfigChanged = onPerformanceHudConfigChanged, + onFpsLimiterEnabledChanged = onFpsLimiterEnabledChanged, onFpsLimiterChanged = onFpsLimiterChanged, scrollState = hudScrollState, focusRequester = hudItemFocusRequester, @@ -578,10 +585,12 @@ fun QuickMenu( private fun PerformanceHudQuickMenuTab( isPerformanceHudEnabled: Boolean, performanceHudConfig: PerformanceHudConfig, - fpsLimiterValue: Int, + fpsLimiterEnabled: Boolean, + fpsLimiterTarget: Int, fpsLimiterMax: Int, onTogglePerformanceHud: () -> Unit, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit, + onFpsLimiterEnabledChanged: (Boolean) -> Unit, onFpsLimiterChanged: (Int) -> Unit, scrollState: ScrollState, focusRequester: FocusRequester? = null, @@ -595,39 +604,52 @@ private fun PerformanceHudQuickMenuTab( .focusGroup(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { + // ── FPS Limiter (topmost) ──────────────────────────────────────── QuickMenuToggleRow( - title = stringResource(R.string.performance_hud), - subtitle = stringResource(R.string.performance_hud_description), - enabled = isPerformanceHudEnabled, - onToggle = onTogglePerformanceHud, - accentColor = accentColor, - focusRequester = focusRequester, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - QuickMenuSectionHeader( title = stringResource(R.string.performance_hud_fps_limiter), subtitle = stringResource( R.string.performance_hud_fps_limiter_description, fpsLimiterMax, ), + enabled = fpsLimiterEnabled, + onToggle = { onFpsLimiterEnabledChanged(!fpsLimiterEnabled) }, + accentColor = accentColor, + focusRequester = focusRequester, ) - QuickMenuAdjustmentRow( - title = stringResource(R.string.performance_hud_fps_limiter_target), - valueText = if (fpsLimiterValue <= 0) { - stringResource(R.string.performance_hud_fps_limiter_off) - } else { - stringResource(R.string.performance_hud_fps_limiter_value, fpsLimiterValue) - }, - progress = fpsLimiterProgress(fpsLimiterValue, fpsLimiterMax), - onDecrease = { - onFpsLimiterChanged(previousFpsLimiterValue(fpsLimiterValue, fpsLimiterMax)) - }, - onIncrease = { - onFpsLimiterChanged(nextFpsLimiterValue(fpsLimiterValue, fpsLimiterMax)) - }, + AnimatedVisibility( + visible = fpsLimiterEnabled, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + Spacer(modifier = Modifier.height(4.dp)) + QuickMenuAdjustmentRow( + title = stringResource(R.string.performance_hud_fps_limiter_target), + valueText = stringResource( + R.string.performance_hud_fps_limiter_value, + fpsLimiterTarget, + ), + progress = fpsLimiterProgress(fpsLimiterTarget, fpsLimiterMax), + onDecrease = { + onFpsLimiterChanged(previousFpsLimiterValue(fpsLimiterTarget, fpsLimiterMax)) + }, + onIncrease = { + onFpsLimiterChanged(nextFpsLimiterValue(fpsLimiterTarget, fpsLimiterMax)) + }, + accentColor = accentColor, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // ── Performance HUD ────────────────────────────────────────────── + QuickMenuToggleRow( + title = stringResource(R.string.performance_hud), + subtitle = stringResource(R.string.performance_hud_description), + enabled = isPerformanceHudEnabled, + onToggle = onTogglePerformanceHud, accentColor = accentColor, ) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 168abc0ae9..28c78d53f6 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -407,7 +407,8 @@ fun XServerScreen( var isPerformanceHudEnabled by remember { mutableStateOf(PrefManager.showFps) } val shouldTrackDisplayedFrames = remember { AtomicBoolean(false) } var detectedMaxRefreshRateHz by remember { mutableIntStateOf(detectMaxRefreshRateHz(context, null)) } - var fpsLimiterValue by rememberSaveable { mutableIntStateOf(0) } + var fpsLimiterEnabled by rememberSaveable { mutableStateOf(false) } + var fpsLimiterTarget by rememberSaveable { mutableIntStateOf(30) } fun loadPerformanceHudConfig(): PerformanceHudConfig { return PerformanceHudConfig( @@ -470,28 +471,38 @@ fun XServerScreen( performanceHudView?.setConfig(config) } - fun applyFpsLimiter(limit: Int) { - val sanitizedLimit = if (limit <= 0) 0 else limit.coerceAtMost(detectedMaxRefreshRateHz) - fpsLimiterValue = sanitizedLimit - xServerView?.setFrameRateLimit(sanitizedLimit) - // Also throttle the X Present extension so the game's own render loop - // receives back-pressure and actually reduces CPU/GPU usage. + fun applyFpsLimiterToEngines(limit: Int) { + xServerView?.setFrameRateLimit(limit) xServerView?.getxServer() ?.getExtension(PresentExtension.MAJOR_OPCODE.toInt()) - ?.setFrameRateLimit(sanitizedLimit) + ?.setFrameRateLimit(limit) + } + + fun applyFpsLimiterEnabled(enabled: Boolean) { + fpsLimiterEnabled = enabled + applyFpsLimiterToEngines(if (enabled) fpsLimiterTarget else 0) + } + + fun applyFpsLimiterTarget(target: Int) { + val sanitized = target.coerceAtLeast(5).coerceAtMost(detectedMaxRefreshRateHz) + fpsLimiterTarget = sanitized + if (fpsLimiterEnabled) { + applyFpsLimiterToEngines(sanitized) + } } LaunchedEffect(xServerView) { val detectedMax = detectMaxRefreshRateHz(context, xServerView) detectedMaxRefreshRateHz = detectedMax - val clampedLimit = if (fpsLimiterValue <= 0) 0 else fpsLimiterValue.coerceAtMost(detectedMax) - if (clampedLimit != fpsLimiterValue) { - fpsLimiterValue = clampedLimit + val clampedTarget = fpsLimiterTarget.coerceAtMost(detectedMax).coerceAtLeast(5) + if (clampedTarget != fpsLimiterTarget) { + fpsLimiterTarget = clampedTarget } - xServerView?.setFrameRateLimit(clampedLimit) + val appliedLimit = if (fpsLimiterEnabled) clampedTarget else 0 + xServerView?.setFrameRateLimit(appliedLimit) xServerView?.getxServer() ?.getExtension(PresentExtension.MAJOR_OPCODE.toInt()) - ?.setFrameRateLimit(clampedLimit) + ?.setFrameRateLimit(appliedLimit) } fun restorePerformanceHudPosition() { @@ -1414,7 +1425,7 @@ fun XServerScreen( xServerToUse, ).apply { xServerView = this - setFrameRateLimit(fpsLimiterValue) + setFrameRateLimit(if (fpsLimiterEnabled) fpsLimiterTarget else 0) val renderer = this.renderer renderer.isCursorVisible = false renderer.setOnFrameRenderedListener { @@ -2094,10 +2105,12 @@ fun XServerScreen( renderer = xServerView?.renderer, isPerformanceHudEnabled = isPerformanceHudEnabled, performanceHudConfig = performanceHudConfig, - fpsLimiterValue = fpsLimiterValue, + fpsLimiterEnabled = fpsLimiterEnabled, + fpsLimiterTarget = fpsLimiterTarget, fpsLimiterMax = detectedMaxRefreshRateHz, onPerformanceHudConfigChanged = ::applyPerformanceHudConfig, - onFpsLimiterChanged = ::applyFpsLimiter, + onFpsLimiterEnabledChanged = ::applyFpsLimiterEnabled, + onFpsLimiterChanged = ::applyFpsLimiterTarget, hasPhysicalController = hasPhysicalController, activeToggleIds = buildSet { if (areControlsVisible) add(QuickMenuAction.INPUT_CONTROLS) From c745597bee426f1099a14a54ce35547a43c3e2bb Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 2 Apr 2026 17:16:44 -0400 Subject: [PATCH 05/11] feat: remove fps limiter toggle subtitle/description --- app/src/main/java/app/gamenative/ui/component/QuickMenu.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index fb8940809a..557b070671 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -607,10 +607,6 @@ private fun PerformanceHudQuickMenuTab( // ── FPS Limiter (topmost) ──────────────────────────────────────── QuickMenuToggleRow( title = stringResource(R.string.performance_hud_fps_limiter), - subtitle = stringResource( - R.string.performance_hud_fps_limiter_description, - fpsLimiterMax, - ), enabled = fpsLimiterEnabled, onToggle = { onFpsLimiterEnabledChanged(!fpsLimiterEnabled) }, accentColor = accentColor, From 01d5d6cd851e70c28d254df0d6304d53b1c6e6d4 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 2 Apr 2026 21:26:33 -0400 Subject: [PATCH 06/11] fix: fps limiter steps floor to multiples of 5, preserve display max as top step --- .../main/java/app/gamenative/ui/component/QuickMenu.kt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index 557b070671..3f7106519f 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -214,16 +214,15 @@ private fun matchesPerformanceHudPreset( private fun fpsLimiterSteps(maxFps: Int): List { val sanitizedMax = maxFps.coerceAtLeast(5) + val flooredMax = (sanitizedMax / 5) * 5 return buildList { var value = 5 - while (value <= sanitizedMax) { + while (value <= flooredMax) { add(value) value += 5 } - if (last() != sanitizedMax) { - add(sanitizedMax) - } - }.distinct() + if (sanitizedMax != flooredMax) add(sanitizedMax) + } } private fun fpsLimiterProgress(currentValue: Int, maxFps: Int): Float { From abbf87a43ef298cffa5ff40b97990b52db9fe5a3 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Thu, 2 Apr 2026 21:40:40 -0400 Subject: [PATCH 07/11] fix: fps limiter review bugs and unit tests --- .../ui/component/FpsLimiterUtils.kt | 41 ++++ .../app/gamenative/ui/component/QuickMenu.kt | 32 +--- .../ui/screen/xserver/XServerScreen.kt | 12 +- .../java/com/winlator/widget/XServerView.java | 11 +- .../xserver/extensions/Extension.java | 2 + .../xserver/extensions/PresentExtension.java | 45 +++-- .../ui/component/FpsLimiterUtilsTest.kt | 180 ++++++++++++++++++ 7 files changed, 268 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt create mode 100644 app/src/test/java/app/gamenative/ui/component/FpsLimiterUtilsTest.kt diff --git a/app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt b/app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt new file mode 100644 index 0000000000..8a0bdc692e --- /dev/null +++ b/app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt @@ -0,0 +1,41 @@ +package app.gamenative.ui.component + +internal fun fpsLimiterSteps(maxFps: Int): List { + val sanitizedMax = maxFps.coerceAtLeast(5) + val flooredMax = (sanitizedMax / 5) * 5 + return buildList { + var value = 5 + while (value <= flooredMax) { + add(value) + value += 5 + } + if (sanitizedMax != flooredMax) add(sanitizedMax) + } +} + +/** + * Returns the index of the step that is the floor of [currentValue] — i.e. the highest + * step that is still ≤ currentValue. Falls back to 0 if currentValue is below the + * first step, so navigation never goes in the wrong direction on a restored value that + * isn't an exact multiple of 5. + */ +internal fun fpsLimiterCurrentIndex(steps: List, currentValue: Int): Int = + steps.indexOfLast { it <= currentValue }.coerceAtLeast(0) + +internal fun fpsLimiterProgress(currentValue: Int, maxFps: Int): Float { + val steps = fpsLimiterSteps(maxFps) + val currentIndex = fpsLimiterCurrentIndex(steps, currentValue) + return if (steps.lastIndex <= 0) 1f else currentIndex.toFloat() / steps.lastIndex.toFloat() +} + +internal fun nextFpsLimiterValue(currentValue: Int, maxFps: Int): Int { + val steps = fpsLimiterSteps(maxFps) + val currentIndex = fpsLimiterCurrentIndex(steps, currentValue) + return steps[(currentIndex + 1).coerceAtMost(steps.lastIndex)] +} + +internal fun previousFpsLimiterValue(currentValue: Int, maxFps: Int): Int { + val steps = fpsLimiterSteps(maxFps) + val currentIndex = fpsLimiterCurrentIndex(steps, currentValue) + return steps[(currentIndex - 1).coerceAtLeast(0)] +} diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index 3f7106519f..c4357f2475 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -212,36 +212,8 @@ private fun matchesPerformanceHudPreset( currentConfig.showGpuUsageGraph == presetConfig.showGpuUsageGraph } -private fun fpsLimiterSteps(maxFps: Int): List { - val sanitizedMax = maxFps.coerceAtLeast(5) - val flooredMax = (sanitizedMax / 5) * 5 - return buildList { - var value = 5 - while (value <= flooredMax) { - add(value) - value += 5 - } - if (sanitizedMax != flooredMax) add(sanitizedMax) - } -} - -private fun fpsLimiterProgress(currentValue: Int, maxFps: Int): Float { - val steps = fpsLimiterSteps(maxFps) - val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: (steps.lastIndex) - return if (steps.lastIndex <= 0) 1f else currentIndex.toFloat() / steps.lastIndex.toFloat() -} - -private fun nextFpsLimiterValue(currentValue: Int, maxFps: Int): Int { - val steps = fpsLimiterSteps(maxFps) - val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: steps.lastIndex - return steps[(currentIndex + 1).coerceAtMost(steps.lastIndex)] -} - -private fun previousFpsLimiterValue(currentValue: Int, maxFps: Int): Int { - val steps = fpsLimiterSteps(maxFps) - val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: steps.lastIndex - return steps[(currentIndex - 1).coerceAtLeast(0)] -} +// fpsLimiterSteps / fpsLimiterCurrentIndex / fpsLimiterProgress / +// nextFpsLimiterValue / previousFpsLimiterValue live in FpsLimiterUtils.kt @Composable fun QuickMenu( diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 28c78d53f6..3bffdc693c 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -499,10 +499,7 @@ fun XServerScreen( fpsLimiterTarget = clampedTarget } val appliedLimit = if (fpsLimiterEnabled) clampedTarget else 0 - xServerView?.setFrameRateLimit(appliedLimit) - xServerView?.getxServer() - ?.getExtension(PresentExtension.MAJOR_OPCODE.toInt()) - ?.setFrameRateLimit(appliedLimit) + applyFpsLimiterToEngines(appliedLimit) } fun restorePerformanceHudPosition() { @@ -1430,7 +1427,9 @@ fun XServerScreen( renderer.isCursorVisible = false renderer.setOnFrameRenderedListener { if (shouldTrackDisplayedFrames.get()) { - frameRating?.update() + (context as? Activity)?.runOnUiThread { + frameRating?.update() + } } } getxServer().renderer = renderer @@ -1977,6 +1976,9 @@ fun XServerScreen( // Remove the WindowManager listener associated with the released AndroidView. binding.xServerView.renderer.setOnFrameRenderedListener(null) binding.xServerView.getxServer().windowManager.removeOnWindowModificationListener(binding.windowModificationListener) + binding.xServerView.getxServer() + .getExtension(PresentExtension.MAJOR_OPCODE.toInt()) + ?.close() if (PluviaApp.xServerView === binding.xServerView) { PluviaApp.xServerView = null } diff --git a/app/src/main/java/com/winlator/widget/XServerView.java b/app/src/main/java/com/winlator/widget/XServerView.java index d76645bacd..1436282b18 100644 --- a/app/src/main/java/com/winlator/widget/XServerView.java +++ b/app/src/main/java/com/winlator/widget/XServerView.java @@ -77,22 +77,23 @@ public int getFrameRateLimit() { } public void setFrameRateLimit(int frameRateLimit) { - final boolean shouldKickRender; + final boolean shouldRecomputeRender; synchronized (renderThrottleLock) { + boolean hadScheduledRender = renderRequestScheduled; this.frameRateLimit = Math.max(0, frameRateLimit); minRenderIntervalMs = this.frameRateLimit > 0 ? Math.max(1L, Math.round(1000f / (float) this.frameRateLimit)) : 0L; - if (this.frameRateLimit == 0 && renderRequestScheduled) { + if (hadScheduledRender) { renderThrottleHandler.removeCallbacks(throttledRenderRunnable); renderRequestScheduled = false; } - shouldKickRender = this.frameRateLimit == 0; + shouldRecomputeRender = this.frameRateLimit == 0 || hadScheduledRender; } - if (shouldKickRender) { - super.requestRender(); + if (shouldRecomputeRender) { + requestRender(); } } diff --git a/app/src/main/java/com/winlator/xserver/extensions/Extension.java b/app/src/main/java/com/winlator/xserver/extensions/Extension.java index abdcc778f0..fb1096d80f 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/Extension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/Extension.java @@ -17,4 +17,6 @@ public interface Extension { byte getFirstEventId(); void handleRequest(XClient client, XInputStream inputStream, XOutputStream outputStream) throws IOException, XRequestError; + + default void close() {} } diff --git a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java index 296c5e15f5..0e42b55402 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java @@ -31,6 +31,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; public class PresentExtension implements Extension { public static final byte MAJOR_OPCODE = -103; @@ -47,6 +48,9 @@ public enum Mode {COPY, FLIP, SKIP} private volatile long targetIntervalUs = 0L; private long lastScheduledUst = 0L; // guarded by scheduleLock private final Object scheduleLock = new Object(); + // Incremented on every setFrameRateLimit call so in-flight lambdas can detect + // that the limit changed and fire immediately instead of stalling the game. + private final AtomicInteger limitGeneration = new AtomicInteger(0); private final ScheduledExecutorService presentScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "PresentExt-FpsLimiter"); @@ -55,22 +59,23 @@ public enum Mode {COPY, FLIP, SKIP} }); public void setFrameRateLimit(int limit) { - frameRateLimit = Math.max(0, limit); - if (frameRateLimit > 0) { - targetIntervalUs = 1_000_000L / frameRateLimit; - } else { - targetIntervalUs = 0L; - synchronized (scheduleLock) { - lastScheduledUst = 0L; - } + synchronized (scheduleLock) { + frameRateLimit = Math.max(0, limit); + targetIntervalUs = frameRateLimit > 0 ? 1_000_000L / frameRateLimit : 0L; + lastScheduledUst = 0L; // reset pacing watermark on every change } + limitGeneration.incrementAndGet(); // invalidate any in-flight scheduled notifies + } + + public void close() { + presentScheduler.shutdownNow(); } - /** Returns the UST (microseconds) at which this present should be signalled, - * advancing the internal watermark by targetIntervalUs each call. */ private long nextScheduledUst(long nowUst) { synchronized (scheduleLock) { - long next = Math.max(nowUst, lastScheduledUst) + targetIntervalUs; + // Use the later of (last watermark + interval) or now so that already-late + // frames are not penalised by an extra full interval of delay. + long next = Math.max(lastScheduledUst + targetIntervalUs, nowUst); lastScheduledUst = next; return next; } @@ -200,12 +205,22 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre final int finalIdleFence = idleFence; final long finalScheduledUst = scheduledUst; final long finalInterval = targetInterval; + final int capturedGen = limitGeneration.get(); presentScheduler.schedule(() -> { try { - long msc = finalScheduledUst / finalInterval; - sendIdleNotify(finalWindow, finalPixmap, finalSerial, finalIdleFence); - sendCompleteNotify(finalWindow, finalSerial, Kind.PIXMAP, Mode.COPY, - finalScheduledUst, msc); + if (limitGeneration.get() == capturedGen) { + long msc = finalScheduledUst / finalInterval; + sendIdleNotify(finalWindow, finalPixmap, finalSerial, finalIdleFence); + sendCompleteNotify(finalWindow, finalSerial, Kind.PIXMAP, Mode.COPY, + finalScheduledUst, msc); + } else { + // Limit changed while this frame was queued — fire immediately + // so the game is not stalled at the old cadence. + long ustNow = System.nanoTime() / 1000; + sendIdleNotify(finalWindow, finalPixmap, finalSerial, finalIdleFence); + sendCompleteNotify(finalWindow, finalSerial, Kind.PIXMAP, Mode.COPY, + ustNow, ustNow / FAKE_INTERVAL_DEFAULT_US); + } } catch (Exception ignored) { // Client may have disconnected before the scheduled notify fired. } diff --git a/app/src/test/java/app/gamenative/ui/component/FpsLimiterUtilsTest.kt b/app/src/test/java/app/gamenative/ui/component/FpsLimiterUtilsTest.kt new file mode 100644 index 0000000000..050f7d05d0 --- /dev/null +++ b/app/src/test/java/app/gamenative/ui/component/FpsLimiterUtilsTest.kt @@ -0,0 +1,180 @@ +package app.gamenative.ui.component + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FpsLimiterUtilsTest { + + // ── fpsLimiterSteps ────────────────────────────────────────────────────── + + @Test + fun `steps for 60Hz are multiples of 5 from 5 to 60`() { + assertEquals((5..60 step 5).toList(), fpsLimiterSteps(60)) + } + + @Test + fun `steps for 90Hz are multiples of 5 from 5 to 90`() { + assertEquals((5..90 step 5).toList(), fpsLimiterSteps(90)) + } + + @Test + fun `steps for 120Hz are multiples of 5 from 5 to 120`() { + assertEquals((5..120 step 5).toList(), fpsLimiterSteps(120)) + } + + @Test + fun `steps for 144Hz have clean multiples then raw max appended`() { + val steps = fpsLimiterSteps(144) + assertEquals(5, steps.first()) + assertEquals(140, steps[steps.size - 2]) + assertEquals(144, steps.last()) + // no duplicates + assertEquals(steps.size, steps.distinct().size) + } + + @Test + fun `steps for exact multiple of 5 do not duplicate the max`() { + val steps = fpsLimiterSteps(60) + assertEquals(60, steps.last()) + assertEquals(steps.size, steps.distinct().size) + } + + @Test + fun `sanitizes max below 5 to give a single step of 5`() { + assertEquals(listOf(5), fpsLimiterSteps(3)) + } + + @Test + fun `all steps are in ascending order`() { + listOf(60, 90, 120, 144).forEach { max -> + val steps = fpsLimiterSteps(max) + for (i in 1 until steps.size) { + assertTrue("steps not ascending at index $i for max=$max", steps[i] > steps[i - 1]) + } + } + } + + // ── fpsLimiterCurrentIndex ──────────────────────────────────────────────── + + @Test + fun `currentIndex for exact step match returns that index`() { + val steps = fpsLimiterSteps(60) + assertEquals(steps.indexOf(30), fpsLimiterCurrentIndex(steps, 30)) + } + + @Test + fun `currentIndex floors to nearest step below for non-step value`() { + val steps = fpsLimiterSteps(60) // …40, 45, 50… + // 47 is between 45 and 50 — floor to 45 + assertEquals(steps.indexOf(45), fpsLimiterCurrentIndex(steps, 47)) + } + + @Test + fun `currentIndex clamps to 0 for value below minimum step`() { + val steps = fpsLimiterSteps(60) + assertEquals(0, fpsLimiterCurrentIndex(steps, 1)) + } + + @Test + fun `currentIndex returns lastIndex for value at max`() { + val steps = fpsLimiterSteps(60) + assertEquals(steps.lastIndex, fpsLimiterCurrentIndex(steps, 60)) + } + + @Test + fun `currentIndex floors correctly for 144Hz non-multiple value`() { + val steps = fpsLimiterSteps(144) // …135, 140, 144 + // 143 should floor to 140, not 144 + assertEquals(steps.indexOf(140), fpsLimiterCurrentIndex(steps, 143)) + } + + // ── nextFpsLimiterValue ─────────────────────────────────────────────────── + + @Test + fun `next from 30 is 35 with 60Hz max`() { + assertEquals(35, nextFpsLimiterValue(30, 60)) + } + + @Test + fun `next clamps at display max`() { + assertEquals(60, nextFpsLimiterValue(60, 60)) + } + + @Test + fun `next from non-step value floors then steps up`() { + // 47 floors to 45, next is 50 + assertEquals(50, nextFpsLimiterValue(47, 60)) + } + + @Test + fun `next from 140 on 144Hz display reaches 144`() { + assertEquals(144, nextFpsLimiterValue(140, 144)) + } + + @Test + fun `next clamps at 144 when already at 144Hz max`() { + assertEquals(144, nextFpsLimiterValue(144, 144)) + } + + // ── previousFpsLimiterValue ─────────────────────────────────────────────── + + @Test + fun `prev from 30 is 25 with 60Hz max`() { + assertEquals(25, previousFpsLimiterValue(30, 60)) + } + + @Test + fun `prev clamps at 5`() { + assertEquals(5, previousFpsLimiterValue(5, 60)) + } + + @Test + fun `prev from non-step value floors to that step`() { + // 47 floors to 45, prev is 45 (the floor itself, not one below) + // fpsLimiterCurrentIndex(steps, 47) = indexOf(45) + // prev = steps[(indexOf(45) - 1)] = 40 + assertEquals(40, previousFpsLimiterValue(47, 60)) + } + + @Test + fun `prev from 144 on 144Hz display is 140`() { + assertEquals(140, previousFpsLimiterValue(144, 144)) + } + + // ── fpsLimiterProgress ──────────────────────────────────────────────────── + + @Test + fun `progress at minimum step is 0`() { + assertEquals(0f, fpsLimiterProgress(5, 60), 0.001f) + } + + @Test + fun `progress at display max is 1`() { + assertEquals(1f, fpsLimiterProgress(60, 60), 0.001f) + } + + @Test + fun `progress at midpoint is approximately 0·5`() { + // steps 5..60 step 5 → 12 steps, index 0..11; 30fps = index 5 → 5/11 ≈ 0.454 + val steps = fpsLimiterSteps(60) + val idx = steps.indexOf(30).toFloat() + val expected = idx / steps.lastIndex.toFloat() + assertEquals(expected, fpsLimiterProgress(30, 60), 0.001f) + } + + @Test + fun `progress for 144Hz top step is 1`() { + assertEquals(1f, fpsLimiterProgress(144, 144), 0.001f) + } + + @Test + fun `progress for non-step value uses floor index`() { + // 47 → floor to 45, same progress as 45 + assertEquals( + fpsLimiterProgress(45, 60), + fpsLimiterProgress(47, 60), + 0.001f, + ) + } +} From c7f6f223dad2c32ec441920079d0d662fa54ef39 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 3 Apr 2026 09:07:09 -0400 Subject: [PATCH 08/11] fix: remove close() from onRelease, msc monotonicity, display min floor --- .../java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 5 +---- .../com/winlator/xserver/extensions/PresentExtension.java | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 3bffdc693c..d03014d68e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -217,7 +217,7 @@ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { return refreshRate .takeIf { it.isFinite() && it > 0f } ?.roundToInt() - ?.coerceAtLeast(30) + ?.coerceAtLeast(5) ?: DEFAULT_FPS_LIMITER_MAX_HZ } @@ -1976,9 +1976,6 @@ fun XServerScreen( // Remove the WindowManager listener associated with the released AndroidView. binding.xServerView.renderer.setOnFrameRenderedListener(null) binding.xServerView.getxServer().windowManager.removeOnWindowModificationListener(binding.windowModificationListener) - binding.xServerView.getxServer() - .getExtension(PresentExtension.MAJOR_OPCODE.toInt()) - ?.close() if (PluviaApp.xServerView === binding.xServerView) { PluviaApp.xServerView = null } diff --git a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java index 0e42b55402..7365a715d1 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java @@ -195,7 +195,7 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre if (delayUs <= 1_000L) { // Already within 1 ms of the target — send immediately. - long msc = scheduledUst / targetInterval; + long msc = scheduledUst / FAKE_INTERVAL_DEFAULT_US; sendIdleNotify(window, pixmap, serial, idleFence); sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.COPY, scheduledUst, msc); } else { @@ -209,7 +209,7 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre presentScheduler.schedule(() -> { try { if (limitGeneration.get() == capturedGen) { - long msc = finalScheduledUst / finalInterval; + long msc = finalScheduledUst / FAKE_INTERVAL_DEFAULT_US; sendIdleNotify(finalWindow, finalPixmap, finalSerial, finalIdleFence); sendCompleteNotify(finalWindow, finalSerial, Kind.PIXMAP, Mode.COPY, finalScheduledUst, msc); From bdc649b40124eade8f0c346eccdd7f5328dcd488 Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Fri, 3 Apr 2026 09:18:00 -0400 Subject: [PATCH 09/11] fix: sendEvent stream lock, capturedGen ordering --- app/src/main/java/com/winlator/xserver/XClient.java | 3 ++- .../com/winlator/xserver/extensions/PresentExtension.java | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/winlator/xserver/XClient.java b/app/src/main/java/com/winlator/xserver/XClient.java index 2e5a33c1d9..28d000f067 100644 --- a/app/src/main/java/com/winlator/xserver/XClient.java +++ b/app/src/main/java/com/winlator/xserver/XClient.java @@ -4,6 +4,7 @@ import com.winlator.xconnector.XInputStream; import com.winlator.xconnector.XOutputStream; +import com.winlator.xconnector.XStreamLock; import com.winlator.xserver.events.Event; import java.io.IOException; @@ -50,7 +51,7 @@ public void setEventListenerForWindow(Window window, Bitmask eventMask) { } public void sendEvent(Event event) { - try { + try (XStreamLock ignored = outputStream.lock()) { event.send(sequenceNumber, outputStream); } catch (IOException e) { diff --git a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java index 7365a715d1..836a2358fc 100644 --- a/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java +++ b/app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java @@ -190,11 +190,11 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre sendIdleNotify(window, pixmap, serial, idleFence); sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.COPY, nowUst, msc); } else { + final int capturedGen = limitGeneration.get(); long scheduledUst = nextScheduledUst(nowUst); long delayUs = scheduledUst - nowUst; if (delayUs <= 1_000L) { - // Already within 1 ms of the target — send immediately. long msc = scheduledUst / FAKE_INTERVAL_DEFAULT_US; sendIdleNotify(window, pixmap, serial, idleFence); sendCompleteNotify(window, serial, Kind.PIXMAP, Mode.COPY, scheduledUst, msc); @@ -204,8 +204,6 @@ private void presentPixmap(XClient client, XInputStream inputStream, XOutputStre final int finalSerial = serial; final int finalIdleFence = idleFence; final long finalScheduledUst = scheduledUst; - final long finalInterval = targetInterval; - final int capturedGen = limitGeneration.get(); presentScheduler.schedule(() -> { try { if (limitGeneration.get() == capturedGen) { From ae0733f4d67cf2e4fc0d8182e9bd6b2c5da7bc6d Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 12 Apr 2026 22:55:15 -0400 Subject: [PATCH 10/11] refactor(fps-limiter): preserve legacy DXVK defaults in new limiter --- .../app/gamenative/ui/component/QuickMenu.kt | 4 +- .../ui/screen/xserver/XServerScreen.kt | 39 ++++++++++++++++++- .../com/winlator/container/Container.java | 2 +- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt index c4357f2475..048765ecaa 100644 --- a/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt +++ b/app/src/main/java/app/gamenative/ui/component/QuickMenu.kt @@ -223,8 +223,8 @@ fun QuickMenu( renderer: GLRenderer? = null, isPerformanceHudEnabled: Boolean = false, performanceHudConfig: PerformanceHudConfig = PerformanceHudConfig(), - fpsLimiterEnabled: Boolean = false, - fpsLimiterTarget: Int = 30, + fpsLimiterEnabled: Boolean = true, + fpsLimiterTarget: Int = 60, fpsLimiterMax: Int = 60, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit = {}, onFpsLimiterEnabledChanged: (Boolean) -> Unit = {}, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index d03014d68e..88b40d4042 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -199,6 +199,33 @@ private const val EXIT_PROCESS_TIMEOUT_MS = 30_000L private const val EXIT_PROCESS_POLL_INTERVAL_MS = 1_000L private const val EXIT_PROCESS_RESPONSE_TIMEOUT_MS = 2_000L private const val DEFAULT_FPS_LIMITER_MAX_HZ = 60 +private const val DEFAULT_FPS_LIMITER_TARGET_HZ = 60 +private const val FPS_LIMITER_ENABLED_EXTRA = "fpsLimiterEnabled" +private const val FPS_LIMITER_TARGET_EXTRA = "fpsLimiterTarget" + +private fun parsePositiveFpsLimit(value: String): Int? = value.toIntOrNull()?.takeIf { it > 0 } + +private fun hasLegacyDxvkFrameRate(container: Container): Boolean = EnvVars(container.envVars).has("DXVK_FRAME_RATE") + +private fun legacyDxvkFrameRate(container: Container): Int? = + parsePositiveFpsLimit(EnvVars(container.envVars).get("DXVK_FRAME_RATE")) + +private fun parseBooleanExtra(value: String): Boolean? = + when (value.trim().lowercase(Locale.US)) { + "true" -> true + "false" -> false + else -> null + } + +private fun initialFpsLimiterEnabled(container: Container): Boolean { + parseBooleanExtra(container.getExtra(FPS_LIMITER_ENABLED_EXTRA))?.let { return it } + return !hasLegacyDxvkFrameRate(container) +} + +private fun initialFpsLimiterTarget(container: Container): Int = + parsePositiveFpsLimit(container.getExtra(FPS_LIMITER_TARGET_EXTRA)) + ?: legacyDxvkFrameRate(container) + ?: DEFAULT_FPS_LIMITER_TARGET_HZ private fun detectMaxRefreshRateHz(context: Context, attachedView: View?): Int { val display = attachedView?.display @@ -407,8 +434,14 @@ fun XServerScreen( var isPerformanceHudEnabled by remember { mutableStateOf(PrefManager.showFps) } val shouldTrackDisplayedFrames = remember { AtomicBoolean(false) } var detectedMaxRefreshRateHz by remember { mutableIntStateOf(detectMaxRefreshRateHz(context, null)) } - var fpsLimiterEnabled by rememberSaveable { mutableStateOf(false) } - var fpsLimiterTarget by rememberSaveable { mutableIntStateOf(30) } + var fpsLimiterEnabled by rememberSaveable(container.id) { mutableStateOf(initialFpsLimiterEnabled(container)) } + var fpsLimiterTarget by rememberSaveable(container.id) { mutableIntStateOf(initialFpsLimiterTarget(container)) } + + fun persistFpsLimiterState() { + container.putExtra(FPS_LIMITER_ENABLED_EXTRA, fpsLimiterEnabled) + container.putExtra(FPS_LIMITER_TARGET_EXTRA, fpsLimiterTarget) + container.saveData() + } fun loadPerformanceHudConfig(): PerformanceHudConfig { return PerformanceHudConfig( @@ -481,6 +514,7 @@ fun XServerScreen( fun applyFpsLimiterEnabled(enabled: Boolean) { fpsLimiterEnabled = enabled applyFpsLimiterToEngines(if (enabled) fpsLimiterTarget else 0) + persistFpsLimiterState() } fun applyFpsLimiterTarget(target: Int) { @@ -489,6 +523,7 @@ fun XServerScreen( if (fpsLimiterEnabled) { applyFpsLimiterToEngines(sanitized) } + persistFpsLimiterState() } LaunchedEffect(xServerView) { diff --git a/app/src/main/java/com/winlator/container/Container.java b/app/src/main/java/com/winlator/container/Container.java index 8be5478700..d11d68adca 100644 --- a/app/src/main/java/com/winlator/container/Container.java +++ b/app/src/main/java/com/winlator/container/Container.java @@ -34,7 +34,7 @@ public enum XrControllerMapping { public static final String EXTERNAL_DISPLAY_MODE_HYBRID = "hybrid"; public static final String DEFAULT_EXTERNAL_DISPLAY_MODE = EXTERNAL_DISPLAY_MODE_OFF; - public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform,deck_emu DXVK_FRAME_RATE=60 VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144"; + public static final String DEFAULT_ENV_VARS = "WRAPPER_MAX_IMAGE_COUNT=0 ZINK_DESCRIPTORS=lazy ZINK_DEBUG=compact,deck_emu MESA_SHADER_CACHE_DISABLE=false MESA_SHADER_CACHE_MAX_SIZE=512MB mesa_glthread=true WINEESYNC=1 MESA_VK_WSI_PRESENT_MODE=mailbox TU_DEBUG=noconform,deck_emu VKD3D_SHADER_MODEL=6_0 PULSE_LATENCY_MSEC=144"; public static final String DEFAULT_SCREEN_SIZE = "1280x720"; public static final String DEFAULT_GRAPHICS_DRIVER = DefaultVersion.DEFAULT_GRAPHICS_DRIVER; public static final String DEFAULT_AUDIO_DRIVER = "pulseaudio"; From a9438b899f3e8aa7157283097928be6d9548121e Mon Sep 17 00:00:00 2001 From: xXJSONDeruloXx Date: Sun, 12 Apr 2026 22:55:20 -0400 Subject: [PATCH 11/11] fix(perf-hud): restore frame rating updates for tracked window --- .../java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 88b40d4042..72b82ea4c8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -1581,6 +1581,11 @@ fun XServerScreen( if (!container.isDisableMouseInput && !container.isTouchscreenMode) renderer?.setCursorVisible(true) xServerState.value.winStarted = true } + if (window.id == frameRatingWindowId) { + (context as? Activity)?.runOnUiThread { + frameRating?.update() + } + } } override fun onModifyWindowProperty(window: Window, property: Property) {