Skip to content

feat: in-game quick menu fps cap #1081

Open
xXJSONDeruloXx wants to merge 14 commits intoutkarshdalal:masterfrom
xXJSONDeruloXx:feat/quick-menu-fps-clamp
Open

feat: in-game quick menu fps cap #1081
xXJSONDeruloXx wants to merge 14 commits intoutkarshdalal:masterfrom
xXJSONDeruloXx:feat/quick-menu-fps-clamp

Conversation

@xXJSONDeruloXx
Copy link
Copy Markdown
Contributor

@xXJSONDeruloXx xXJSONDeruloXx commented Apr 2, 2026

Description

added an in game fps clamp which properly frame limits the running game in real time without requiring a game restart.

this properly reduces CPU and GPU usage as well as you can see in the demo video, so you can throttle the FPS and get more predictable frame pacing, especially for games without their own limits, or without having to restart the game every time you want to make a tweak.

also, made this reflect the way steam deck does it, so we have a global toggle, then a remembered setting for the clamp, when restored, if was 25 fps, will then be 25 on re-toggle. toggling off removes the clamp entirely.

(I was considering removing the dxvd var in container settings that defaults to lock 60, but decided against it, may follow up with that one later)

This is a big QoL feature especially for those without active cooling. and for games that dont need a lot of motion, dialing down to 5-10 fps for things like visual novels will really help battery and heat

Recording

https://youtu.be/4w3rTZXZvY0

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Adds an in-game FPS limiter in the Quick Menu that caps the game’s frame rate live to reduce CPU/GPU load and smooth pacing. Uses Present back-pressure plus XServerView throttling, applies instantly, clamps to the detected max display refresh, and persists per-container (respects legacy DXVK_FRAME_RATE if set).

  • New Features

    • FPS limiter toggle at the top of the Performance HUD tab; remembers the last target and hides the slider when off.
    • 5‑FPS steps from 5…max Hz; includes the raw max if it isn’t a multiple of 5.
    • Auto-detects max refresh via DisplayManager/attached display; limit is applied to both XServerView and PresentExtension for real pacing.
    • Seeds from DXVK_FRAME_RATE when present and persists per-container state (enabled by default unless a legacy DXVK cap exists).
  • Bug Fixes

    • PresentExtension now delays Idle/Complete notifications to back-pressure the game’s render loop; fixed MSC monotonicity and X stream locking.
    • Localized FPS limiter UI strings and removed the default DXVK_FRAME_RATE=60 from container env to avoid unintended caps.

Written for commit 44db81d. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • FPS limiter added to the Performance HUD quick menu: enable/disable toggle, discrete target selector, and animated reveal of controls.
    • Automatic detection of display refresh rate and applied frame-rate throttling so rendering respects the configured limit across the rendering path and presentation timing.
    • Updated Performance HUD UI strings and safer shutdown of pacing logic.
  • Tests

    • Unit tests covering FPS step generation, progress mapping, and next/previous stepping behavior.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an end-to-end FPS limiter: UI controls in QuickMenu, Compose state and wiring in XServerScreen, renderer post-render hook in GLRenderer, render throttling in XServerView, delayed present notify scheduling in PresentExtension, FPS helper utils and tests, plus localization strings. (34 words)

Changes

Cohort / File(s) Summary
UI / QuickMenu & strings
app/src/main/java/app/gamenative/ui/component/QuickMenu.kt, app/src/main/res/values/strings.xml
Extended QuickMenu and PerformanceHudQuickMenuTab signatures with FPS limiter state/callbacks; added FPS limiter toggle and target control (AnimatedVisibility); adjusted focus behavior; added new strings.
FPS helper & tests
app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt, app/src/test/java/app/gamenative/ui/component/FpsLimiterUtilsTest.kt
New internal utilities to generate FPS steps, compute progress, next/previous values; comprehensive unit tests validating step generation, indexing, navigation, and progress mapping.
Screen wiring
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
Added refresh-rate detection state, Compose state for limiter (enabled/target/max), LaunchedEffect to clamp/apply limits, wired limiter props/callbacks into QuickMenu, applied initial setFrameRateLimit, and switched frame-rating updates to use renderer post-render listener gating.
Renderer hook
app/src/main/java/com/winlator/renderer/GLRenderer.java
Added onFrameRenderedListener field and setOnFrameRenderedListener(Runnable), invoked after each onDrawFrame.
View throttling
app/src/main/java/com/winlator/widget/XServerView.java
Added configurable frameRateLimit, computed minRenderIntervalMs, scheduled/coalesced delayed renders via Handler, and exposed getFrameRateLimit()/setFrameRateLimit().
Present scheduling
app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java
Added frame-rate limiting flow: target interval pacing, single-thread scheduler for delayed PresentIdle/Complete notifies, setFrameRateLimit() and close(); queued tasks check generation to avoid stale pacing.
Extension lifecycle
app/src/main/java/com/winlator/xserver/extensions/Extension.java
Added default close() lifecycle method to the Extension interface for optional cleanup.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as QuickMenu
    participant Screen as XServerScreen
    participant View as XServerView
    participant Renderer as GLRenderer
    participant Present as PresentExtension

    User->>UI: Toggle/Adjust FPS limiter
    UI->>Screen: onFpsLimiterEnabledChanged / onFpsLimiterChanged
    Screen->>View: setFrameRateLimit(target or 0)
    Screen->>Present: setFrameRateLimit(target or 0)

    loop Render cycle
      User->>View: events trigger requestRender()
      View->>View: requestRender() (throttled/coalesced)
      View->>Renderer: super.requestRender()
      Renderer->>Renderer: onDrawFrame()
      Renderer->>Renderer: run onFrameRenderedListener
      Renderer->>Present: frame-rendered callback
      Present->>Present: compute/schedule notify (maybe delayed)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I hopped through code with nimble feet,
Tamed wild frames to keep the beat,
A toggle, steps, a gentle pause,
Pixels breathe within new laws,
Hooray! Smooth frames, concise and sweet. 🎮🐇

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature added: an in-game FPS cap accessible via the quick menu.
Description check ✅ Passed The PR description provides a clear summary of the feature, includes a demo video link, and covers most required sections. However, the Recording checkbox is marked with [x] but only a YouTube link is provided without an embedded or attached recording.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@xXJSONDeruloXx xXJSONDeruloXx changed the title Feat/quick menu fps clamp feat: in-game quick menu fps cap Apr 2, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 6 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java">

<violation number="1" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:73">
P1: FPS limiter incorrectly delays already-late frames, causing output FPS to drop below the configured cap.</violation>

<violation number="2" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:203">
P2: Disabling the FPS clamp doesn’t cancel already scheduled Present* notifies, so the limiter can keep stalling frames after the user toggles it off. Track and cancel pending ScheduledFutures (or reschedule immediately) when limit becomes 0 so “off” takes effect right away.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)

494-506: Consider reusing applyFpsLimiterToEngines to reduce duplication.

Lines 502-505 duplicate the logic from applyFpsLimiterToEngines (lines 475-478). Since this LaunchedEffect runs after xServerView is set, the helper function should work correctly.

♻️ Suggested refactor
     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
-        xServerView?.setFrameRateLimit(appliedLimit)
-        xServerView?.getxServer()
-            ?.getExtension<PresentExtension>(PresentExtension.MAJOR_OPCODE.toInt())
-            ?.setFrameRateLimit(appliedLimit)
+        applyFpsLimiterToEngines(if (fpsLimiterEnabled) clampedTarget else 0)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 494 - 506, The LaunchedEffect duplicates the frame-rate application logic
found in applyFpsLimiterToEngines; after computing detectedMaxRefreshRateHz and
adjusting fpsLimiterTarget you should remove the duplicated block that calls
xServerView?.setFrameRateLimit(...) and extension.setFrameRateLimit(...) and
instead call the existing helper applyFpsLimiterToEngines (using xServerView and
the current fpsLimiterEnabled/fpsLimiterTarget state as needed) so the same
centralized logic is reused; keep detectMaxRefreshRateHz(context, xServerView)
and the clamping/assignment of fpsLimiterTarget, then invoke
applyFpsLimiterToEngines to apply the limit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/component/QuickMenu.kt`:
- Around line 229-245: Normalize the input value to a valid step before using
the helpers: call fpsLimiterSteps(maxFps) and compute a normalized index (e.g.
find the index of the exact match or the indexOfLast { it <= currentValue } and
clamp it into 0..steps.lastIndex) and then use that index for
fpsLimiterProgress, nextFpsLimiterValue and previousFpsLimiterValue instead of
falling back to steps.lastIndex; reference the functions fpsLimiterProgress,
nextFpsLimiterValue, previousFpsLimiterValue and fpsLimiterSteps when applying
this change.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 1481-1489: The call to frameRating?.update() is executed off the
UI thread causing a data race on non-volatile fields (e.g., lastTime,
frameCount, lastFPS)—wrap both places where update() is invoked (the renderer's
onFrameRenderedListener and the window-manager callback where
frameRatingWindowId is handled) inside (context as? Activity)?.runOnUiThread {
frameRating?.update() } so update() always runs on the UI thread, matching how
visibility changes are already posted and preventing concurrent reads in run()
from racing with update().

In `@app/src/main/java/com/winlator/renderer/GLRenderer.java`:
- Line 36: The onFrameRenderedListener field is accessed from multiple threads
(written by setOnFrameRenderedListener() and read in onDrawFrame()), so make
onFrameRenderedListener volatile to ensure visibility and atomicity, and in
onDrawFrame() take a local snapshot (e.g., Runnable listener =
onFrameRenderedListener) before checking for null and invoking it to avoid a
race where the field becomes null between the check and call; update the field
declaration and adjust onDrawFrame() to use the local variable while leaving
setOnFrameRenderedListener() unchanged.

In `@app/src/main/java/com/winlator/widget/XServerView.java`:
- Around line 79-97: When frameRateLimit changes in setFrameRateLimit(),
recompute any pending delayed render rather than only cancelling when new limit
is 0: inside the synchronized(renderThrottleLock) block after recalculating
frameRateLimit and minRenderIntervalMs, if renderRequestScheduled is true then
remove the existing throttledRenderRunnable from renderThrottleHandler and
re-post it with the newly computed minRenderIntervalMs (or cancel if
frameRateLimit==0) so the next render cadence reflects the new limit
immediately; update references to renderRequestScheduled,
throttledRenderRunnable, renderThrottleHandler, minRenderIntervalMs and ensure
requestRender() behavior remains consistent with the new scheduling.

In `@app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java`:
- Around line 57-67: setFrameRateLimit currently only clears lastScheduledUst
when disabling and updates targetIntervalUs, leaving already-queued tasks in
presentScheduler firing at the old cadence; modify setFrameRateLimit (and the
similar blocks at the other locations) to reset queued pacing state by:
acquiring scheduleLock, updating targetIntervalUs and frameRateLimit, clearing
or zeroing lastScheduledUst, and notifying/interrupting the presentScheduler so
any already-submitted lambdas recompute their next run using the new
targetIntervalUs (or cancel and reschedule them) instead of using stale timing;
reference methods/fields setFrameRateLimit, targetIntervalUs, lastScheduledUst,
scheduleLock, and presentScheduler when implementing.
- Around line 50-55: PresentExtension creates a dedicated
ScheduledExecutorService field presentScheduler but there is no lifecycle hook
to shut it down; add a close/destroy method to the Extension contract (e.g., add
void destroy() or close()) and implement it in PresentExtension to call
presentScheduler.shutdownNow() (or shutdown() + awaitTermination with timeout)
to stop the "PresentExt-FpsLimiter" thread, then ensure the application calls
the new destroy/close method when an XServer instance is torn down so threads
are not leaked.

In `@app/src/main/res/values/strings.xml`:
- Line 269: Update the string resource performance_hud_fps_limiter_description
to reflect that the feature throttles the game's actual frame/present loop, not
just the overlay; change the wording from “Caps the displayed frame rate while
in-game. Max: %1$d Hz.” to something like “Caps the in-game frame rate while
in-game. Max: %1$d Hz.” preserving the %1$d placeholder and punctuation.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 494-506: The LaunchedEffect duplicates the frame-rate application
logic found in applyFpsLimiterToEngines; after computing
detectedMaxRefreshRateHz and adjusting fpsLimiterTarget you should remove the
duplicated block that calls xServerView?.setFrameRateLimit(...) and
extension.setFrameRateLimit(...) and instead call the existing helper
applyFpsLimiterToEngines (using xServerView and the current
fpsLimiterEnabled/fpsLimiterTarget state as needed) so the same centralized
logic is reused; keep detectMaxRefreshRateHz(context, xServerView) and the
clamping/assignment of fpsLimiterTarget, then invoke applyFpsLimiterToEngines to
apply the limit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cd31396c-f059-4832-8863-3df86cd7b307

📥 Commits

Reviewing files that changed from the base of the PR and between aa412fc and 7a741f9.

📒 Files selected for processing (6)
  • app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/renderer/GLRenderer.java
  • app/src/main/java/com/winlator/widget/XServerView.java
  • app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java
  • app/src/main/res/values/strings.xml

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
app/src/main/java/app/gamenative/ui/component/QuickMenu.kt (1)

228-244: ⚠️ Potential issue | 🟡 Minor

Normalize fpsLimiterTarget before using step indices.

These helpers still assume currentValue is already one of the generated steps. app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt only range-clamps the target, so a restored or externally provided value like 47 can still reach this code. In that case the lastIndex fallback makes the bar render as 100%, and previous can even jump upward to 55. Compute the nearest valid index first and reuse it here.

🛠️ Suggested fix
+private fun fpsLimiterCurrentIndex(steps: List<Int>, currentValue: Int): Int {
+    return steps.indexOfLast { it <= currentValue }
+        .takeIf { it >= 0 }
+        ?: 0
+}
+
 private fun fpsLimiterProgress(currentValue: Int, maxFps: Int): Float {
     val steps = fpsLimiterSteps(maxFps)
-    val currentIndex = steps.indexOfFirst { it == currentValue }.takeIf { it >= 0 } ?: (steps.lastIndex)
+    val currentIndex = fpsLimiterCurrentIndex(steps, currentValue)
     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
+    val currentIndex = fpsLimiterCurrentIndex(steps, currentValue)
     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
+    val currentIndex = fpsLimiterCurrentIndex(steps, currentValue)
     return steps[(currentIndex - 1).coerceAtLeast(0)]
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/component/QuickMenu.kt` around lines 228
- 244, The three helpers fpsLimiterProgress, nextFpsLimiterValue, and
previousFpsLimiterValue assume currentValue is exactly one of fpsLimiterSteps;
instead compute the nearest step index up front by finding the index of the
closest step to currentValue (using fpsLimiterSteps(maxFps)), use that
normalized index for progress calculation and to pick next/previous entries, and
avoid falling back to lastIndex erroneously — i.e., replace the current index
lookup with a nearest-index computation and reuse that in all three functions so
an out-of-range or non-step value (e.g., 47) maps to the nearest valid step
rather than forcing lastIndex or jumping incorrectly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/src/main/java/app/gamenative/ui/component/QuickMenu.kt`:
- Around line 228-244: The three helpers fpsLimiterProgress,
nextFpsLimiterValue, and previousFpsLimiterValue assume currentValue is exactly
one of fpsLimiterSteps; instead compute the nearest step index up front by
finding the index of the closest step to currentValue (using
fpsLimiterSteps(maxFps)), use that normalized index for progress calculation and
to pick next/previous entries, and avoid falling back to lastIndex erroneously —
i.e., replace the current index lookup with a nearest-index computation and
reuse that in all three functions so an out-of-range or non-step value (e.g.,
47) maps to the nearest valid step rather than forcing lastIndex or jumping
incorrectly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a4d644b-bc4a-463e-b8ba-8f5cf06ca5f5

📥 Commits

Reviewing files that changed from the base of the PR and between 536f44e and 8c90c40.

📒 Files selected for processing (1)
  • app/src/main/java/app/gamenative/ui/component/QuickMenu.kt

@xXJSONDeruloXx xXJSONDeruloXx force-pushed the feat/quick-menu-fps-clamp branch from 8db0781 to 53465f5 Compare April 3, 2026 01:41
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 7 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:1924">
P1: Do not unconditionally close `PresentExtension` in `onRelease`; this view can be recreated while reusing the same XServer, so closing the shared extension can break FPS limiting after recreation.</violation>
</file>

<file name="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java">

<violation number="1" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:67">
P2: FPS limit updates do not take effect immediately for already queued frame notifications because scheduled tasks are not canceled/rescheduled.</violation>

<violation number="2" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:211">
P1: Generation check is insufficient to detect all limit-change races; a queued frame can still be emitted on a stale interval after the FPS limit changes.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt (1)

1479-1485: ⚠️ Potential issue | 🟠 Major

FrameRating.update() is still off the UI thread here.

The renderer callback was fixed, but Line 1484 still calls frameRating?.update() directly from WindowManager.OnWindowModificationListener. That listener is not UI-thread confined, so this reintroduces the same race on FrameRating’s mutable fields.

Suggested fix
                                     shouldTrackDisplayedFrames.set(true)
                                     (context as? Activity)?.runOnUiThread {
                                         frameRating?.visibility = View.VISIBLE
+                                        frameRating?.update()
                                     }
-                                    frameRating?.update()
                                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt` around
lines 1479 - 1485, The call to frameRating?.update() is happening off the UI
thread inside the WindowManager.OnWindowModificationListener, causing races on
FrameRating's mutable fields; change it to run on the UI thread (use the same
pattern as the runOnUiThread block used for setting visibility) so that
frameRating?.update() is invoked inside Activity.runOnUiThread (or equivalent)
after setting frameRatingWindowId and shouldTrackDisplayedFrames.set(true),
ensuring all FrameRating mutations occur on the UI thread.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 1922-1924: The onRelease handler in XServerScreen is closing the
PresentExtension
(binding.xServerView.getxServer().getExtension<PresentExtension>(...)?
.close()), which prematurely shuts down the shared scheduler when the
AndroidView is temporarily released; remove the .close() call from this
view-lifecycle onRelease and instead invoke PresentExtension.close() only from
the real X server/environment teardown path where existingXServer is actually
disposed (the same place you handle full XServer shutdown or cleanup), ensuring
getExtension<PresentExtension>() is closed during actual XServer teardown rather
than during view recreation.
- Around line 217-221: The current return chain on refreshRate unnecessarily
forces any finite positive refreshRate up to at least 30 via .coerceAtLeast(30),
which inflates real 24/25 Hz panels; remove the .coerceAtLeast(30) so the result
uses roundToInt() (and falls back to DEFAULT_FPS_LIMITER_MAX_HZ when invalid)
and let the existing limiter/helpers enforce the 5 FPS floor; update the
expression using the refreshRate variable and DEFAULT_FPS_LIMITER_MAX_HZ
accordingly (i.e., remove the coerceAtLeast(30) call in the
takeIf/.../roundToInt() chain).

In `@app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java`:
- Around line 196-223: The MSC (msc) is computed using the current
targetInterval, which can change via limitGeneration and cause msc to go
backwards; instead compute MSC from a stable interval so it remains monotonic
across live cap changes. Modify the scheduled task and the immediate branch in
PresentExtension so sendCompleteNotify(...) uses an msc derived from a
consistent reference interval (e.g. compute and capture a stableInterval before
scheduling or use FAKE_INTERVAL_DEFAULT_US/constants) rather than targetInterval
at the time of notification; keep the existing limitGeneration capture and
fallback branch but compute msc using the captured/stable interval (and for the
"limit changed" branch compute using the same stable basis so MSC only ever
increases).

---

Duplicate comments:
In `@app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt`:
- Around line 1479-1485: The call to frameRating?.update() is happening off the
UI thread inside the WindowManager.OnWindowModificationListener, causing races
on FrameRating's mutable fields; change it to run on the UI thread (use the same
pattern as the runOnUiThread block used for setting visibility) so that
frameRating?.update() is invoked inside Activity.runOnUiThread (or equivalent)
after setting frameRatingWindowId and shouldTrackDisplayedFrames.set(true),
ensuring all FrameRating mutations occur on the UI thread.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7332edf7-a65d-486a-b66c-9032e4b66add

📥 Commits

Reviewing files that changed from the base of the PR and between 8c90c40 and 53465f5.

📒 Files selected for processing (7)
  • app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt
  • app/src/main/java/app/gamenative/ui/component/QuickMenu.kt
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/widget/XServerView.java
  • app/src/main/java/com/winlator/xserver/extensions/Extension.java
  • app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java
  • app/src/test/java/app/gamenative/ui/component/FpsLimiterUtilsTest.kt
✅ Files skipped from review due to trivial changes (1)
  • app/src/main/java/com/winlator/xserver/extensions/Extension.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/java/app/gamenative/ui/component/QuickMenu.kt

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java`:
- Around line 193-209: The bug is that capturedGen is read after calling
nextScheduledUst(nowUst), so a cap change between those calls can make the
scheduled timing stale; to fix, read limitGeneration.get() (the generation)
before computing nextScheduledUst(nowUst) or compute both under the same
lock/atomic section so the generation and scheduledUst are derived from the same
state; update the code around nextScheduledUst(...), capturedGen, and the lambda
passed to presentScheduler.schedule(...) so capturedGen always reflects the
state used to compute finalScheduledUst/finalInterval.
- Around line 209-227: The scheduled lambda in PresentExtension (the
presentScheduler block that calls sendIdleNotify/sendCompleteNotify) can race
with request-thread writes because XClient.sendEvent writes to XOutputStream
without the stream lock; change the notify path to use a thread-safe send API on
XClient instead of calling the non-synchronized sendEvent directly: add or use a
synchronized method on XClient (e.g., sendEventThreadSafe/sendEventLocked or
enqueueEvent) that acquires the XOutputStream lock (or the client's send lock)
before writing, update sendIdleNotify and sendCompleteNotify to call that
thread-safe method, and ensure the scheduled lambda uses this new safe send path
and preserves the existing exception handling for disconnected clients.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f425e056-f5fd-445a-8709-aba29974b784

📥 Commits

Reviewing files that changed from the base of the PR and between 53465f5 and d3b7d55.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
  • app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java
✅ Files skipped from review due to trivial changes (1)
  • app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt

…ps-clamp

# Conflicts:
#	app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
@xXJSONDeruloXx xXJSONDeruloXx force-pushed the feat/quick-menu-fps-clamp branch from d6ac999 to 42c860c Compare April 9, 2026 12:53
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

translations pls

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@utkarshdalal
Copy link
Copy Markdown
Owner

How does this interact with the existing default DXVK_FRAME_RATE variable?

Does it make sense to remove DXVK_FRAME_RATE from the default env vars? And clamp games to 60 fps here by default?

@xXJSONDeruloXx
Copy link
Copy Markdown
Contributor Author

@utkarshdalal yes DXVK launch arg will be the ceiling, and this limiter can lower from there.

I left the removal of that var out of this scope but could roll it in if you like

… 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.
- 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.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java">

<violation number="1" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:67">
P2: FPS-limit changes do not take effect immediately for already queued presents because stale scheduled tasks are not canceled and generation is checked only at execution time.</violation>

<violation number="2" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:207">
P2: The microsecond-to-millisecond truncation (`delayUs / 1_000L` with `TimeUnit.MILLISECONDS`) consistently under-delays each frame, making the actual FPS cap slightly higher than intended. Use `TimeUnit.MICROSECONDS` directly to preserve the precision you already computed.</violation>

<violation number="3" location="app/src/main/java/com/winlator/xserver/extensions/PresentExtension.java:222">
P2: Catching bare `Exception` and ignoring it silently can hide programming errors. Consider narrowing to `IOException` for the expected disconnection case, or at minimum logging unexpected exception types at a warning level so they don't vanish silently.</violation>
</file>

<file name="app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/component/FpsLimiterUtils.kt:7">
P2: FPS step generation starts at 5 instead of 15, so the limiter can expose unintended 5/10 FPS options.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt">

<violation number="1" location="app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt:222">
P2: Legacy DXVK FPS cap is not migrated/cleared, so disabling the new quick-menu limiter may still leave FPS capped via `DXVK_FRAME_RATE`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants