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 6cc91dad55..e9be608c0c 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 @@ -210,6 +212,9 @@ private fun matchesPerformanceHudPreset( currentConfig.showGpuUsageGraph == presetConfig.showGpuUsageGraph } +// fpsLimiterSteps / fpsLimiterCurrentIndex / fpsLimiterProgress / +// nextFpsLimiterValue / previousFpsLimiterValue live in FpsLimiterUtils.kt + @Composable fun QuickMenu( isVisible: Boolean, @@ -218,7 +223,12 @@ fun QuickMenu( renderer: GLRenderer? = null, isPerformanceHudEnabled: Boolean = false, performanceHudConfig: PerformanceHudConfig = PerformanceHudConfig(), + fpsLimiterEnabled: Boolean = true, + fpsLimiterTarget: Int = 60, + fpsLimiterMax: Int = 60, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit = {}, + onFpsLimiterEnabledChanged: (Boolean) -> Unit = {}, + onFpsLimiterChanged: (Int) -> Unit = {}, hasPhysicalController: Boolean = false, activeToggleIds: Set = emptySet(), modifier: Modifier = Modifier, @@ -458,10 +468,15 @@ fun QuickMenu( PerformanceHudQuickMenuTab( isPerformanceHudEnabled = isPerformanceHudEnabled, performanceHudConfig = performanceHudConfig, + fpsLimiterEnabled = fpsLimiterEnabled, + fpsLimiterTarget = fpsLimiterTarget, + fpsLimiterMax = fpsLimiterMax, onTogglePerformanceHud = { onItemSelected(QuickMenuAction.PERFORMANCE_HUD) }, onPerformanceHudConfigChanged = onPerformanceHudConfigChanged, + onFpsLimiterEnabledChanged = onFpsLimiterEnabledChanged, + onFpsLimiterChanged = onFpsLimiterChanged, scrollState = hudScrollState, focusRequester = hudItemFocusRequester, modifier = Modifier.fillMaxSize(), @@ -541,8 +556,13 @@ fun QuickMenu( private fun PerformanceHudQuickMenuTab( isPerformanceHudEnabled: Boolean, performanceHudConfig: PerformanceHudConfig, + fpsLimiterEnabled: Boolean, + fpsLimiterTarget: Int, + fpsLimiterMax: Int, onTogglePerformanceHud: () -> Unit, onPerformanceHudConfigChanged: (PerformanceHudConfig) -> Unit, + onFpsLimiterEnabledChanged: (Boolean) -> Unit, + onFpsLimiterChanged: (Int) -> Unit, scrollState: ScrollState, focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, @@ -555,13 +575,49 @@ private fun PerformanceHudQuickMenuTab( .focusGroup(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { + // ── FPS Limiter (topmost) ──────────────────────────────────────── + QuickMenuToggleRow( + title = stringResource(R.string.performance_hud_fps_limiter), + enabled = fpsLimiterEnabled, + onToggle = { onFpsLimiterEnabledChanged(!fpsLimiterEnabled) }, + accentColor = accentColor, + focusRequester = focusRequester, + ) + + 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, - focusRequester = focusRequester, ) Spacer(modifier = Modifier.height(8.dp)) 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 e8aca665a2..051ae60d65 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 @@ -159,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 @@ -181,6 +184,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 +198,55 @@ 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 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 + ?: 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(5) + ?: DEFAULT_FPS_LIMITER_MAX_HZ +} private data class XServerViewReleaseBinding( val xServerView: XServerView, @@ -379,6 +432,15 @@ fun XServerScreen( var hasInternalTouchpad by remember { mutableStateOf(false) } var hasUpdatedScreenGamepad by remember { mutableStateOf(false) } var isPerformanceHudEnabled by remember { mutableStateOf(PrefManager.showFps) } + var detectedMaxRefreshRateHz by remember { mutableIntStateOf(detectMaxRefreshRateHz(context, null)) } + 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( @@ -441,6 +503,39 @@ fun XServerScreen( performanceHudView?.setConfig(config) } + fun applyFpsLimiterToEngines(limit: Int) { + xServerView?.setFrameRateLimit(limit) + xServerView?.getxServer() + ?.getExtension(PresentExtension.MAJOR_OPCODE.toInt()) + ?.setFrameRateLimit(limit) + } + + fun applyFpsLimiterEnabled(enabled: Boolean) { + fpsLimiterEnabled = enabled + applyFpsLimiterToEngines(if (enabled) fpsLimiterTarget else 0) + persistFpsLimiterState() + } + + fun applyFpsLimiterTarget(target: Int) { + val sanitized = target.coerceAtLeast(5).coerceAtMost(detectedMaxRefreshRateHz) + fpsLimiterTarget = sanitized + if (fpsLimiterEnabled) { + applyFpsLimiterToEngines(sanitized) + } + persistFpsLimiterState() + } + + LaunchedEffect(xServerView) { + val detectedMax = detectMaxRefreshRateHz(context, xServerView) + detectedMaxRefreshRateHz = detectedMax + val clampedTarget = fpsLimiterTarget.coerceAtMost(detectedMax).coerceAtLeast(5) + if (clampedTarget != fpsLimiterTarget) { + fpsLimiterTarget = clampedTarget + } + val appliedLimit = if (fpsLimiterEnabled) clampedTarget else 0 + applyFpsLimiterToEngines(appliedLimit) + } + fun restorePerformanceHudPosition() { val host = performanceHudHost ?: return val hud = performanceHudView ?: return @@ -1360,6 +1455,7 @@ fun XServerScreen( xServerToUse, ).apply { xServerView = this + setFrameRateLimit(if (fpsLimiterEnabled) fpsLimiterTarget else 0) val renderer = this.renderer renderer.isCursorVisible = false getxServer().renderer = renderer @@ -2022,7 +2118,12 @@ fun XServerScreen( renderer = xServerView?.renderer, isPerformanceHudEnabled = isPerformanceHudEnabled, performanceHudConfig = performanceHudConfig, + fpsLimiterEnabled = fpsLimiterEnabled, + fpsLimiterTarget = fpsLimiterTarget, + fpsLimiterMax = detectedMaxRefreshRateHz, onPerformanceHudConfigChanged = ::applyPerformanceHudConfig, + onFpsLimiterEnabledChanged = ::applyFpsLimiterEnabled, + onFpsLimiterChanged = ::applyFpsLimiterTarget, hasPhysicalController = hasPhysicalController, activeToggleIds = buildSet { if (areControlsVisible) add(QuickMenuAction.INPUT_CONTROLS) 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"; diff --git a/app/src/main/java/com/winlator/widget/XServerView.java b/app/src/main/java/com/winlator/widget/XServerView.java index 415edade71..1436282b18 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,63 @@ public GLRenderer getRenderer() { return renderer; } + public int getFrameRateLimit() { + synchronized (renderThrottleLock) { + return frameRateLimit; + } + } + + public void setFrameRateLimit(int frameRateLimit) { + 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 (hadScheduledRender) { + renderThrottleHandler.removeCallbacks(throttledRenderRunnable); + renderRequestScheduled = false; + } + shouldRecomputeRender = this.frameRateLimit == 0 || hadScheduledRender; + } + + if (shouldRecomputeRender) { + 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/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/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 1455fc4011..836a2358fc 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,59 @@ import java.io.IOException; import java.util.Objects; +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; - 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(); + // 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"); + t.setDaemon(true); + return t; + }); + + public void setFrameRateLimit(int limit) { + 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(); + } + + private long nextScheduledUst(long nowUst) { + synchronized (scheduleLock) { + // 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; + } + } + private static abstract class ClientOpcodes { private static final byte QUERY_VERSION = 0; private static final byte PRESENT_PIXMAP = 1; @@ -128,13 +172,58 @@ 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 { + final int capturedGen = limitGeneration.get(); + long scheduledUst = nextScheduledUst(nowUst); + long delayUs = scheduledUst - nowUst; + + if (delayUs <= 1_000L) { + long msc = scheduledUst / FAKE_INTERVAL_DEFAULT_US; + 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; + presentScheduler.schedule(() -> { + try { + if (limitGeneration.get() == capturedGen) { + long msc = finalScheduledUst / FAKE_INTERVAL_DEFAULT_US; + 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. + } + }, delayUs / 1_000L, TimeUnit.MILLISECONDS); + } } } diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4f94dc76e7..a25d9e7d3d 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 0eae33a122..ec00b2763b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -288,6 +288,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 8f8e9ce554..c3d9641976 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -310,6 +310,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 0cb58a144b..e623005dc2 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -295,6 +295,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 8f7b518fa4..80052c8f62 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -299,6 +299,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 d6ad3fb7a4..fe7010d16b 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -308,6 +308,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 c6db415c9f..481b80e5b8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -307,6 +307,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 c7d1088386..7cd14f81ed 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 e3077c203b..4edc912ab9 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -297,6 +297,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 349b2efd26..9fdd32f68f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -843,6 +843,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 5f752c6d53..4071678a7c 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -294,6 +294,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 28c449fb44..970bd09488 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -293,6 +293,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 08a1152f40..4321a757f4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -293,6 +293,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 41e8174521..d730486740 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -281,6 +281,11 @@ Controller Performance HUD Show or hide the in-game overlay. + FPS limiter + Caps the in-game frame rate. Max: %1$d Hz. + Target FPS + Unlimited + %1$d FPS Presets Quickly switch between common HUD layouts. 1 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, + ) + } +}