From 5d848d118e3bde65f2a8fddff862f112cf8792d8 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 15:48:37 +0530 Subject: [PATCH 1/5] =?UTF-8?q?android:=20A52=20=E2=80=94=20release=20limi?= =?UTF-8?q?ted=20frames=20on=20the=20vblank=20time=20grid,=20not=20on=20ev?= =?UTF-8?q?ery=20Nth=20wakeup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...52-fps-limit-on-the-vblank-time-grid.patch | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch diff --git a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch new file mode 100644 index 000000000..82cb917c6 --- /dev/null +++ b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch @@ -0,0 +1,98 @@ +A52 — release limited frames on the vblank time grid, not on every Nth wakeup + +The non-VRR frame-rate limit counted compositor wakeups: a window got its frame +callback on every (refresh / target)th wakeup, which equals every Nth vblank only +while no wakeup is ever late. The timer re-arms by stepping to the next vblank +after "now", so a wakeup that comes more than one vblank late skips the vblanks +in between, and those never count. On the 1200 Hz grid a session runs on, a +wakeup is late by a vblank after 0.83 ms of scheduling delay, and every late one +holds the next frame a whole vblank longer. A 60 cap then reads 50-57, a 30 cap +25-28, in proportion to the wakeups lost -- exactly the flat 18-21 ms frames +users saw with the CPU and the GPU idle -- and it sinks further as the device +throttles and the compositor thread is scheduled later still. + +The limiter now keeps a release grid in time: the next release is the previous +one plus the target period, and a frame is released on the first vblank whose +nominal time reaches it, with half a vblank of tolerance for the anchor moving. +A late wakeup delays that one release by the lateness, and the grid absorbs it on +the next, so the average rate is the target. A stall longer than a period is a +lost frame, never a debt to repay with back-to-back releases. The VRR path is +untouched: it already schedules by time. + +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 773e95c..d6da2ad 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -1233,6 +1233,52 @@ uint32_t g_uCurrentBasePlaneAppID = 0; + bool g_bCurrentBasePlaneIsFifo = false; + + static int g_nSteamCompMgrTargetFPS = 0; ++ ++// Non-VRR frame-rate limit: a frame is released on the vblank whose nominal time reaches ++// the release grid (the previous release plus the target period), not on every Nth ++// compositor wakeup. The timer steps over the vblanks a late wakeup missed, so counting ++// wakeups turns every late one into a frame held a whole vblank longer: on a fine vblank ++// grid that reads as a cap below the target whenever the compositor thread is scheduled ++// late, and it sinks further as the device throttles. ++static uint64_t g_ulFPSLimitNextRelease = 0; ++static bool g_bFPSLimitReleaseThisVBlank = true; ++ ++static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) ++{ ++ int nRefreshmHz = g_nNestedRefresh ? g_nNestedRefresh : g_nOutputRefresh; ++ int nRefreshHz = gamescope::ConvertmHzToHz( nRefreshmHz ); ++ int nTargetFPS = g_nSteamCompMgrTargetFPS; ++ ++ if ( !nTargetFPS || nRefreshHz <= nTargetFPS ) ++ { ++ g_bFPSLimitReleaseThisVBlank = true; ++ g_ulFPSLimitNextRelease = 0; ++ return; ++ } ++ ++ const uint64_t ulPeriod = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); ++ const uint64_t ulVBlank = gamescope::mHzToRefreshCycle( nRefreshmHz ); ++ ++ // First vblank under this cap, or the grid is ahead of us (cap change, clock step): anchor here. ++ if ( g_ulFPSLimitNextRelease == 0 || g_ulFPSLimitNextRelease > ulTargetVBlank + ulPeriod ) ++ g_ulFPSLimitNextRelease = ulTargetVBlank; ++ ++ // Half a vblank of tolerance so a release point that lands a hair past this vblank's ++ // nominal time is not pushed to the next one when the anchor moves. ++ if ( ulTargetVBlank + ulVBlank / 2 < g_ulFPSLimitNextRelease ) ++ { ++ g_bFPSLimitReleaseThisVBlank = false; ++ return; ++ } ++ ++ g_bFPSLimitReleaseThisVBlank = true; ++ g_ulFPSLimitNextRelease += ulPeriod; ++ ++ // A stall longer than a period is a lost frame, not a debt: never release on ++ // consecutive vblanks to catch up. ++ if ( g_ulFPSLimitNextRelease <= ulTargetVBlank + ulVBlank / 2 ) ++ g_ulFPSLimitNextRelease = ulTargetVBlank + ulPeriod; ++} + static uint64_t g_uDynamicRefreshEqualityTime = 0; + static int g_nDynamicRefreshRate[gamescope::GAMESCOPE_SCREEN_TYPE_COUNT] = { 0, 0 }; + // Delay to stop modes flickering back and forth. +@@ -7037,9 +7083,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb + { + if ( g_nSteamCompMgrTargetFPS && bShouldLimitFPS && nRefreshHz > nTargetFPS ) + { +- int nVblankDivisor = nRefreshHz / nTargetFPS; +- +- if ( vblank_idx % nVblankDivisor != 0 ) ++ if ( !g_bFPSLimitReleaseThisVBlank ) + bSendCallback = false; + } + } +@@ -10553,6 +10597,8 @@ steamcompmgr_main(int argc, char **argv) + static uint64_t vblank_idx = 0; + if ( vblank ) + { ++ steamcompmgr_update_fps_limit_release( g_SteamCompMgrVBlankTime.schedule.ulTargetVBlank ); ++ + { + uint64_t now = get_time_in_nanos(); + From b57d5309a745e30e7d6702933b5842915eca769d Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 16:24:46 +0530 Subject: [PATCH 2/5] =?UTF-8?q?android:=20A52=20=E2=80=94=20snap=20the=20l?= =?UTF-8?q?imiter's=20release=20grid=20onto=20the=20panel's=20latch=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...52-fps-limit-on-the-vblank-time-grid.patch | 116 +++++++++++++++--- 1 file changed, 102 insertions(+), 14 deletions(-) diff --git a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch index 82cb917c6..4feed102f 100644 --- a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch +++ b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch @@ -1,4 +1,4 @@ -A52 — release limited frames on the vblank time grid, not on every Nth wakeup +A52 — release limited frames on a time grid, snapped to the panel's latch grid The non-VRR frame-rate limit counted compositor wakeups: a window got its frame callback on every (refresh / target)th wakeup, which equals every Nth vblank only @@ -6,24 +6,37 @@ while no wakeup is ever late. The timer re-arms by stepping to the next vblank after "now", so a wakeup that comes more than one vblank late skips the vblanks in between, and those never count. On the 1200 Hz grid a session runs on, a wakeup is late by a vblank after 0.83 ms of scheduling delay, and every late one -holds the next frame a whole vblank longer. A 60 cap then reads 50-57, a 30 cap -25-28, in proportion to the wakeups lost -- exactly the flat 18-21 ms frames -users saw with the CPU and the GPU idle -- and it sinks further as the device -throttles and the compositor thread is scheduled later still. +holds the next frame a whole vblank longer: the cap reads below the target in +proportion to the wakeups lost, and sinks further as the device throttles. The limiter now keeps a release grid in time: the next release is the previous one plus the target period, and a frame is released on the first vblank whose nominal time reaches it, with half a vblank of tolerance for the anchor moving. -A late wakeup delays that one release by the lateness, and the grid absorbs it on -the next, so the average rate is the target. A stall longer than a period is a -lost frame, never a debt to repay with back-to-back releases. The VRR path is -untouched: it already schedules by time. +A late wakeup delays that one release by the lateness, and the grid absorbs it +on the next, so the average rate is the target. A stall longer than a period is +a lost frame, never a debt to repay with back-to-back releases. + +That grid is still free-running against the panel. The daemon stamps the flip +events on its own 1200 Hz timeline, so nothing ties a release to where +SurfaceFlinger latches, and with a 60 cap on a 60 Hz panel the commits drift +across the latch deadline: two land on one latch point and one is replaced, +measured as 59.4 commits/s shown at 56.7 fps, where at 120 Hz (every commit +its own latch point) the same session shows all 59.4. So when the DRM shim +answers the panel's latch grid (gamenative_drm_latch_grid, weak: anchor, period, +lead, measured by the daemon from the latch stats) and the target period is a +whole number of latch periods, the release grid is snapped onto the latch grid, +offset by a lead the limiter steers itself: each commit's time is read against +the grid, and the lead moves an eighth of the error toward landing commits in +the middle of their latch window, so none sits on the edge and no two share a +point. Without the symbol, or with a target the panel period does not divide, +the release grid stays free-running. The VRR path is untouched: it already +schedules by time. diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 773e95c..d6da2ad 100644 +index 773e95c..eb15f5b 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -1233,6 +1233,52 @@ uint32_t g_uCurrentBasePlaneAppID = 0; +@@ -1233,6 +1233,119 @@ uint32_t g_uCurrentBasePlaneAppID = 0; bool g_bCurrentBasePlaneIsFifo = false; static int g_nSteamCompMgrTargetFPS = 0; @@ -34,8 +47,72 @@ index 773e95c..d6da2ad 100644 +// wakeups turns every late one into a frame held a whole vblank longer: on a fine vblank +// grid that reads as a cap below the target whenever the compositor thread is scheduled +// late, and it sinks further as the device throttles. ++// ++// On the gamenative backend the panel's latch grid is known (the daemon measures it from ++// the display's latch stats and the DRM shim answers it), and the vblank grid is not tied ++// to it. When the target period is a whole number of latch periods the release grid is ++// snapped onto the latch grid, offset by a lead the limiter steers itself: it reads where ++// each commit landed in its latch window and aims for the middle, so no two commits fall ++// on one latch point and none sits on the edge. Without the grid the release grid is ++// free-running. +static uint64_t g_ulFPSLimitNextRelease = 0; +static bool g_bFPSLimitReleaseThisVBlank = true; ++static uint64_t g_ulFPSLimitLastCommit = 0; ++static uint64_t g_ulFPSLimitLastCommitSeen = 0; ++static int64_t g_lFPSLimitLatchLead = 0; ++static bool g_bFPSLimitOnLatchGrid = false; ++ ++extern "C" int gamenative_drm_latch_grid( uint64_t *pulAnchorNs, uint32_t *puPeriodNs, uint32_t *puLeadNs ) __attribute__(( weak )); ++ ++// lValue reduced into [-lPeriod/2, lPeriod/2). ++static int64_t steamcompmgr_wrap_ns( int64_t lValue, int64_t lPeriod ) ++{ ++ const int64_t lHalf = lPeriod / 2; ++ const int64_t r = ( ( lValue + lHalf ) % lPeriod + lPeriod ) % lPeriod; ++ return r - lHalf; ++} ++ ++// Snap the release grid onto the latch grid when there is one the target period fits. ++// Returns the period to step the grid by: the latch multiple when snapped. ++static uint64_t steamcompmgr_fps_limit_snap_to_latch_grid( uint64_t ulPeriod ) ++{ ++ g_bFPSLimitOnLatchGrid = false; ++ if ( !gamenative_drm_latch_grid ) ++ return ulPeriod; ++ ++ uint64_t ulAnchor = 0; ++ uint32_t uLatch = 0, uLead = 0; ++ if ( !gamenative_drm_latch_grid( &ulAnchor, &uLatch, &uLead ) || !uLatch ) ++ return ulPeriod; ++ ++ const uint64_t ulMult = ( ulPeriod + uLatch / 2 ) / uLatch; ++ if ( !ulMult ) ++ return ulPeriod; ++ const uint64_t ulGrid = ulMult * uLatch; ++ const int64_t lError = int64_t( ulGrid ) - int64_t( ulPeriod ); ++ if ( std::abs( lError ) * 100 > int64_t( ulPeriod ) ) ++ return ulPeriod; ++ ++ // Steer the lead from where the last commit landed in its latch window: aim for the ++ // middle, in steps of an eighth of the error so the pipeline's own jitter is not chased. ++ if ( g_ulFPSLimitLastCommit && g_ulFPSLimitLastCommit != g_ulFPSLimitLastCommitSeen ) ++ { ++ g_ulFPSLimitLastCommitSeen = g_ulFPSLimitLastCommit; ++ // Time from the commit to the latch point it lands on: (0, latch]. ++ int64_t lMargin = steamcompmgr_wrap_ns( int64_t( ulAnchor ) - int64_t( g_ulFPSLimitLastCommit ), int64_t( uLatch ) ); ++ if ( lMargin <= 0 ) ++ lMargin += uLatch; ++ const int64_t lErr = lMargin - int64_t( uLatch ) / 2; ++ g_lFPSLimitLatchLead = std::clamp( g_lFPSLimitLatchLead - lErr / 8, 0, int64_t( ulGrid ) - 1 ); ++ } ++ ++ const int64_t lBase = int64_t( ulAnchor ) - g_lFPSLimitLatchLead; ++ const int64_t lOff = steamcompmgr_wrap_ns( int64_t( g_ulFPSLimitNextRelease ) - lBase, int64_t( ulGrid ) ); ++ const int64_t lSnapped = int64_t( g_ulFPSLimitNextRelease ) - lOff; ++ g_ulFPSLimitNextRelease = uint64_t( std::max( lSnapped, 0 ) ); ++ g_bFPSLimitOnLatchGrid = true; ++ return ulGrid; ++} + +static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) +{ @@ -47,16 +124,19 @@ index 773e95c..d6da2ad 100644 + { + g_bFPSLimitReleaseThisVBlank = true; + g_ulFPSLimitNextRelease = 0; ++ g_bFPSLimitOnLatchGrid = false; + return; + } + -+ const uint64_t ulPeriod = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); ++ uint64_t ulPeriod = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); + const uint64_t ulVBlank = gamescope::mHzToRefreshCycle( nRefreshmHz ); + + // First vblank under this cap, or the grid is ahead of us (cap change, clock step): anchor here. + if ( g_ulFPSLimitNextRelease == 0 || g_ulFPSLimitNextRelease > ulTargetVBlank + ulPeriod ) + g_ulFPSLimitNextRelease = ulTargetVBlank; + ++ ulPeriod = steamcompmgr_fps_limit_snap_to_latch_grid( ulPeriod ); ++ + // Half a vblank of tolerance so a release point that lands a hair past this vblank's + // nominal time is not pushed to the next one when the anchor moves. + if ( ulTargetVBlank + ulVBlank / 2 < g_ulFPSLimitNextRelease ) @@ -76,7 +156,15 @@ index 773e95c..d6da2ad 100644 static uint64_t g_uDynamicRefreshEqualityTime = 0; static int g_nDynamicRefreshRate[gamescope::GAMESCOPE_SCREEN_TYPE_COUNT] = { 0, 0 }; // Delay to stop modes flickering back and forth. -@@ -7037,9 +7083,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb +@@ -3654,6 +3767,7 @@ paint_all( global_focus_t *pFocus, bool async ) + } + } + ++ g_ulFPSLimitLastCommit = get_time_in_nanos(); + if ( pConnector && pConnector->Present( &frameInfo, async ) != 0 ) + { + return; +@@ -7037,9 +7151,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb { if ( g_nSteamCompMgrTargetFPS && bShouldLimitFPS && nRefreshHz > nTargetFPS ) { @@ -87,7 +175,7 @@ index 773e95c..d6da2ad 100644 bSendCallback = false; } } -@@ -10553,6 +10597,8 @@ steamcompmgr_main(int argc, char **argv) +@@ -10553,6 +10665,8 @@ steamcompmgr_main(int argc, char **argv) static uint64_t vblank_idx = 0; if ( vblank ) { From d57c6a5790295a95cc93fd97c865c28f16f62f1c Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 16:38:37 +0530 Subject: [PATCH 3/5] =?UTF-8?q?android:=20A52=20=E2=80=94=20log=20when=20t?= =?UTF-8?q?he=20limiter=20sits=20on=20the=20panel=20latch=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...52-fps-limit-on-the-vblank-time-grid.patch | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch index 4feed102f..9f8abf675 100644 --- a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch +++ b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch @@ -33,10 +33,10 @@ the release grid stays free-running. The VRR path is untouched: it already schedules by time. diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 773e95c..eb15f5b 100644 +index 773e95c..fca0913 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -1233,6 +1233,119 @@ uint32_t g_uCurrentBasePlaneAppID = 0; +@@ -1233,6 +1233,128 @@ uint32_t g_uCurrentBasePlaneAppID = 0; bool g_bCurrentBasePlaneIsFifo = false; static int g_nSteamCompMgrTargetFPS = 0; @@ -111,6 +111,15 @@ index 773e95c..eb15f5b 100644 + const int64_t lSnapped = int64_t( g_ulFPSLimitNextRelease ) - lOff; + g_ulFPSLimitNextRelease = uint64_t( std::max( lSnapped, 0 ) ); + g_bFPSLimitOnLatchGrid = true; ++ ++ static bool s_bLoggedOnGrid = false; ++ static uint64_t s_ulSteerCount = 0; ++ if ( !s_bLoggedOnGrid || ( ++s_ulSteerCount % 1200 ) == 0 ) ++ { ++ s_bLoggedOnGrid = true; ++ xwm_log.infof( "fps limit: on the panel latch grid, period %u us x%u, lead %lld us", ++ uLatch / 1000, (unsigned)ulMult, (long long)( g_lFPSLimitLatchLead / 1000 ) ); ++ } + return ulGrid; +} + @@ -156,7 +165,7 @@ index 773e95c..eb15f5b 100644 static uint64_t g_uDynamicRefreshEqualityTime = 0; static int g_nDynamicRefreshRate[gamescope::GAMESCOPE_SCREEN_TYPE_COUNT] = { 0, 0 }; // Delay to stop modes flickering back and forth. -@@ -3654,6 +3767,7 @@ paint_all( global_focus_t *pFocus, bool async ) +@@ -3654,6 +3776,7 @@ paint_all( global_focus_t *pFocus, bool async ) } } @@ -164,7 +173,7 @@ index 773e95c..eb15f5b 100644 if ( pConnector && pConnector->Present( &frameInfo, async ) != 0 ) { return; -@@ -7037,9 +7151,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb +@@ -7037,9 +7160,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb { if ( g_nSteamCompMgrTargetFPS && bShouldLimitFPS && nRefreshHz > nTargetFPS ) { @@ -175,7 +184,7 @@ index 773e95c..eb15f5b 100644 bSendCallback = false; } } -@@ -10553,6 +10665,8 @@ steamcompmgr_main(int argc, char **argv) +@@ -10553,6 +10674,8 @@ steamcompmgr_main(int argc, char **argv) static uint64_t vblank_idx = 0; if ( vblank ) { From 21a13f31ea2cb575b9ec5672d3406ac8247d2b95 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 16:58:29 +0530 Subject: [PATCH 4/5] =?UTF-8?q?android:=20A52=20=E2=80=94=20say=20when=20t?= =?UTF-8?q?he=20shim=20has=20no=20latch=20grid=20to=20give?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...A52-fps-limit-on-the-vblank-time-grid.patch | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch index 9f8abf675..0d1840d69 100644 --- a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch +++ b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch @@ -33,10 +33,10 @@ the release grid stays free-running. The VRR path is untouched: it already schedules by time. diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 773e95c..fca0913 100644 +index 773e95c..39e9e12 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -1233,6 +1233,128 @@ uint32_t g_uCurrentBasePlaneAppID = 0; +@@ -1233,6 +1233,136 @@ uint32_t g_uCurrentBasePlaneAppID = 0; bool g_bCurrentBasePlaneIsFifo = false; static int g_nSteamCompMgrTargetFPS = 0; @@ -82,8 +82,16 @@ index 773e95c..fca0913 100644 + + uint64_t ulAnchor = 0; + uint32_t uLatch = 0, uLead = 0; ++ static bool s_bLoggedNoGrid = false; + if ( !gamenative_drm_latch_grid( &ulAnchor, &uLatch, &uLead ) || !uLatch ) ++ { ++ if ( !s_bLoggedNoGrid ) ++ { ++ s_bLoggedNoGrid = true; ++ xwm_log.infof( "fps limit: the shim answers no panel latch grid yet, free-running" ); ++ } + return ulPeriod; ++ } + + const uint64_t ulMult = ( ulPeriod + uLatch / 2 ) / uLatch; + if ( !ulMult ) @@ -165,7 +173,7 @@ index 773e95c..fca0913 100644 static uint64_t g_uDynamicRefreshEqualityTime = 0; static int g_nDynamicRefreshRate[gamescope::GAMESCOPE_SCREEN_TYPE_COUNT] = { 0, 0 }; // Delay to stop modes flickering back and forth. -@@ -3654,6 +3776,7 @@ paint_all( global_focus_t *pFocus, bool async ) +@@ -3654,6 +3784,7 @@ paint_all( global_focus_t *pFocus, bool async ) } } @@ -173,7 +181,7 @@ index 773e95c..fca0913 100644 if ( pConnector && pConnector->Present( &frameInfo, async ) != 0 ) { return; -@@ -7037,9 +7160,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb +@@ -7037,9 +7168,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb { if ( g_nSteamCompMgrTargetFPS && bShouldLimitFPS && nRefreshHz > nTargetFPS ) { @@ -184,7 +192,7 @@ index 773e95c..fca0913 100644 bSendCallback = false; } } -@@ -10553,6 +10674,8 @@ steamcompmgr_main(int argc, char **argv) +@@ -10553,6 +10682,8 @@ steamcompmgr_main(int argc, char **argv) static uint64_t vblank_idx = 0; if ( vblank ) { From 6007ebe1512345a154bc80ad8f2899165cfc7d66 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Fri, 30 Oct 2026 18:10:14 +0530 Subject: [PATCH 5/5] =?UTF-8?q?android:=20A52=20=E2=80=94=20the=20limiter'?= =?UTF-8?q?s=20grid=20is=20the=20nested=20refresh=20and=20the=20target=20a?= =?UTF-8?q?lone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...52-fps-limit-on-the-vblank-time-grid.patch | 135 +++--------------- 1 file changed, 16 insertions(+), 119 deletions(-) diff --git a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch index 0d1840d69..afd5c7953 100644 --- a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch +++ b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch @@ -1,42 +1,31 @@ -A52 — release limited frames on a time grid, snapped to the panel's latch grid +A52 — release limited frames on the vblank time grid, not on every Nth wakeup The non-VRR frame-rate limit counted compositor wakeups: a window got its frame callback on every (refresh / target)th wakeup, which equals every Nth vblank only while no wakeup is ever late. The timer re-arms by stepping to the next vblank after "now", so a wakeup that comes more than one vblank late skips the vblanks -in between, and those never count. On the 1200 Hz grid a session runs on, a -wakeup is late by a vblank after 0.83 ms of scheduling delay, and every late one -holds the next frame a whole vblank longer: the cap reads below the target in -proportion to the wakeups lost, and sinks further as the device throttles. +in between, and those never count. On the 1200 Hz grid a session runs on +(-r 1200, the nested refresh), a wakeup is late by a vblank after 0.83 ms of +scheduling delay, and every late one holds the next frame a whole vblank +longer: a 60 cap then reads 50-57, a 30 cap 25-28, in proportion to the wakeups +lost -- the flat 18-21 ms frames users saw with the CPU and the GPU idle -- and +it sinks further as the device throttles and the compositor thread is scheduled +later still. The limiter now keeps a release grid in time: the next release is the previous one plus the target period, and a frame is released on the first vblank whose nominal time reaches it, with half a vblank of tolerance for the anchor moving. A late wakeup delays that one release by the lateness, and the grid absorbs it on the next, so the average rate is the target. A stall longer than a period is -a lost frame, never a debt to repay with back-to-back releases. - -That grid is still free-running against the panel. The daemon stamps the flip -events on its own 1200 Hz timeline, so nothing ties a release to where -SurfaceFlinger latches, and with a 60 cap on a 60 Hz panel the commits drift -across the latch deadline: two land on one latch point and one is replaced, -measured as 59.4 commits/s shown at 56.7 fps, where at 120 Hz (every commit -its own latch point) the same session shows all 59.4. So when the DRM shim -answers the panel's latch grid (gamenative_drm_latch_grid, weak: anchor, period, -lead, measured by the daemon from the latch stats) and the target period is a -whole number of latch periods, the release grid is snapped onto the latch grid, -offset by a lead the limiter steers itself: each commit's time is read against -the grid, and the lead moves an eighth of the error toward landing commits in -the middle of their latch window, so none sits on the edge and no two share a -point. Without the symbol, or with a target the panel period does not divide, -the release grid stays free-running. The VRR path is untouched: it already -schedules by time. +a lost frame, never a debt to repay with back-to-back releases. The grid is the +nested refresh and the target alone; the panel's rate plays no part here. The +VRR path is untouched: it already schedules by time. diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 773e95c..39e9e12 100644 +index 773e95c..d6da2ad 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -1233,6 +1233,136 @@ uint32_t g_uCurrentBasePlaneAppID = 0; +@@ -1233,6 +1233,52 @@ uint32_t g_uCurrentBasePlaneAppID = 0; bool g_bCurrentBasePlaneIsFifo = false; static int g_nSteamCompMgrTargetFPS = 0; @@ -47,89 +36,8 @@ index 773e95c..39e9e12 100644 +// wakeups turns every late one into a frame held a whole vblank longer: on a fine vblank +// grid that reads as a cap below the target whenever the compositor thread is scheduled +// late, and it sinks further as the device throttles. -+// -+// On the gamenative backend the panel's latch grid is known (the daemon measures it from -+// the display's latch stats and the DRM shim answers it), and the vblank grid is not tied -+// to it. When the target period is a whole number of latch periods the release grid is -+// snapped onto the latch grid, offset by a lead the limiter steers itself: it reads where -+// each commit landed in its latch window and aims for the middle, so no two commits fall -+// on one latch point and none sits on the edge. Without the grid the release grid is -+// free-running. +static uint64_t g_ulFPSLimitNextRelease = 0; +static bool g_bFPSLimitReleaseThisVBlank = true; -+static uint64_t g_ulFPSLimitLastCommit = 0; -+static uint64_t g_ulFPSLimitLastCommitSeen = 0; -+static int64_t g_lFPSLimitLatchLead = 0; -+static bool g_bFPSLimitOnLatchGrid = false; -+ -+extern "C" int gamenative_drm_latch_grid( uint64_t *pulAnchorNs, uint32_t *puPeriodNs, uint32_t *puLeadNs ) __attribute__(( weak )); -+ -+// lValue reduced into [-lPeriod/2, lPeriod/2). -+static int64_t steamcompmgr_wrap_ns( int64_t lValue, int64_t lPeriod ) -+{ -+ const int64_t lHalf = lPeriod / 2; -+ const int64_t r = ( ( lValue + lHalf ) % lPeriod + lPeriod ) % lPeriod; -+ return r - lHalf; -+} -+ -+// Snap the release grid onto the latch grid when there is one the target period fits. -+// Returns the period to step the grid by: the latch multiple when snapped. -+static uint64_t steamcompmgr_fps_limit_snap_to_latch_grid( uint64_t ulPeriod ) -+{ -+ g_bFPSLimitOnLatchGrid = false; -+ if ( !gamenative_drm_latch_grid ) -+ return ulPeriod; -+ -+ uint64_t ulAnchor = 0; -+ uint32_t uLatch = 0, uLead = 0; -+ static bool s_bLoggedNoGrid = false; -+ if ( !gamenative_drm_latch_grid( &ulAnchor, &uLatch, &uLead ) || !uLatch ) -+ { -+ if ( !s_bLoggedNoGrid ) -+ { -+ s_bLoggedNoGrid = true; -+ xwm_log.infof( "fps limit: the shim answers no panel latch grid yet, free-running" ); -+ } -+ return ulPeriod; -+ } -+ -+ const uint64_t ulMult = ( ulPeriod + uLatch / 2 ) / uLatch; -+ if ( !ulMult ) -+ return ulPeriod; -+ const uint64_t ulGrid = ulMult * uLatch; -+ const int64_t lError = int64_t( ulGrid ) - int64_t( ulPeriod ); -+ if ( std::abs( lError ) * 100 > int64_t( ulPeriod ) ) -+ return ulPeriod; -+ -+ // Steer the lead from where the last commit landed in its latch window: aim for the -+ // middle, in steps of an eighth of the error so the pipeline's own jitter is not chased. -+ if ( g_ulFPSLimitLastCommit && g_ulFPSLimitLastCommit != g_ulFPSLimitLastCommitSeen ) -+ { -+ g_ulFPSLimitLastCommitSeen = g_ulFPSLimitLastCommit; -+ // Time from the commit to the latch point it lands on: (0, latch]. -+ int64_t lMargin = steamcompmgr_wrap_ns( int64_t( ulAnchor ) - int64_t( g_ulFPSLimitLastCommit ), int64_t( uLatch ) ); -+ if ( lMargin <= 0 ) -+ lMargin += uLatch; -+ const int64_t lErr = lMargin - int64_t( uLatch ) / 2; -+ g_lFPSLimitLatchLead = std::clamp( g_lFPSLimitLatchLead - lErr / 8, 0, int64_t( ulGrid ) - 1 ); -+ } -+ -+ const int64_t lBase = int64_t( ulAnchor ) - g_lFPSLimitLatchLead; -+ const int64_t lOff = steamcompmgr_wrap_ns( int64_t( g_ulFPSLimitNextRelease ) - lBase, int64_t( ulGrid ) ); -+ const int64_t lSnapped = int64_t( g_ulFPSLimitNextRelease ) - lOff; -+ g_ulFPSLimitNextRelease = uint64_t( std::max( lSnapped, 0 ) ); -+ g_bFPSLimitOnLatchGrid = true; -+ -+ static bool s_bLoggedOnGrid = false; -+ static uint64_t s_ulSteerCount = 0; -+ if ( !s_bLoggedOnGrid || ( ++s_ulSteerCount % 1200 ) == 0 ) -+ { -+ s_bLoggedOnGrid = true; -+ xwm_log.infof( "fps limit: on the panel latch grid, period %u us x%u, lead %lld us", -+ uLatch / 1000, (unsigned)ulMult, (long long)( g_lFPSLimitLatchLead / 1000 ) ); -+ } -+ return ulGrid; -+} + +static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) +{ @@ -141,19 +49,16 @@ index 773e95c..39e9e12 100644 + { + g_bFPSLimitReleaseThisVBlank = true; + g_ulFPSLimitNextRelease = 0; -+ g_bFPSLimitOnLatchGrid = false; + return; + } + -+ uint64_t ulPeriod = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); ++ const uint64_t ulPeriod = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); + const uint64_t ulVBlank = gamescope::mHzToRefreshCycle( nRefreshmHz ); + + // First vblank under this cap, or the grid is ahead of us (cap change, clock step): anchor here. + if ( g_ulFPSLimitNextRelease == 0 || g_ulFPSLimitNextRelease > ulTargetVBlank + ulPeriod ) + g_ulFPSLimitNextRelease = ulTargetVBlank; + -+ ulPeriod = steamcompmgr_fps_limit_snap_to_latch_grid( ulPeriod ); -+ + // Half a vblank of tolerance so a release point that lands a hair past this vblank's + // nominal time is not pushed to the next one when the anchor moves. + if ( ulTargetVBlank + ulVBlank / 2 < g_ulFPSLimitNextRelease ) @@ -173,15 +78,7 @@ index 773e95c..39e9e12 100644 static uint64_t g_uDynamicRefreshEqualityTime = 0; static int g_nDynamicRefreshRate[gamescope::GAMESCOPE_SCREEN_TYPE_COUNT] = { 0, 0 }; // Delay to stop modes flickering back and forth. -@@ -3654,6 +3784,7 @@ paint_all( global_focus_t *pFocus, bool async ) - } - } - -+ g_ulFPSLimitLastCommit = get_time_in_nanos(); - if ( pConnector && pConnector->Present( &frameInfo, async ) != 0 ) - { - return; -@@ -7037,9 +7168,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb +@@ -7037,9 +7083,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb { if ( g_nSteamCompMgrTargetFPS && bShouldLimitFPS && nRefreshHz > nTargetFPS ) { @@ -192,7 +89,7 @@ index 773e95c..39e9e12 100644 bSendCallback = false; } } -@@ -10553,6 +10682,8 @@ steamcompmgr_main(int argc, char **argv) +@@ -10553,6 +10597,8 @@ steamcompmgr_main(int argc, char **argv) static uint64_t vblank_idx = 0; if ( vblank ) {