From 8a0f100356d9e95923e06531d8f53fe8b8ae918a Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 31 Oct 2026 14:30:06 +0530 Subject: [PATCH 1/2] =?UTF-8?q?android:=20A53,=20A54=20=E2=80=94=20vblank?= =?UTF-8?q?=20on=20the=20panel=20latch=20grid,=20compositor=20sleeps=20whe?= =?UTF-8?q?n=20idle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A53 asks gamenative-drm for page-flip stamps on SurfaceFlinger's latch grid (client cap GNLG, 0x474E4C47) when -r is a panel rate, takes live panel-rate changes through the gn_panel_refresh ConVar, gives limited clients the target's period as their refresh cycle (the whole-vblank divisor truncated on a coarse grid) and repaints once after a gamescope_private command. A54 stops re-arming the vblank timer after every wakeup: it re-arms on every loop pass while a repaint, a flip, a frame callback, a held commit, a fade or a visible cursor's auto-hide is pending, and stops after vblank_idle_ticks (default 2) idle wakeups. Every out-of-loop repaint request now nudges. --- ...1-A53-vblank-on-the-panel-latch-grid.patch | 216 ++++++++++++ ...0052-A54-compositor-sleeps-when-idle.patch | 322 ++++++++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch create mode 100644 android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch diff --git a/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch b/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch new file mode 100644 index 000000000..9f08aedd4 --- /dev/null +++ b/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch @@ -0,0 +1,216 @@ +A53 — the vblank grid is the panel's latch grid + +gamescope's vblank timer steps on the nested refresh and re-anchors on every +page-flip timestamp. Under gamenative-drm both were fictions: the app passed +-r 1200, and the daemon stamped each flip on its own 1200 Hz timeline, whose +phase is set by the first flip and has nothing to do with SurfaceFlinger's +latch. The compositor woke ~1200-1500 times a second, and the frame limiter +(A52) released frames on ticks that sat at an arbitrary, drifting phase +against the panel's 8.3 ms latches. + +With -r at the panel rate, this asks gamenative-drm for page-flip stamps on +SurfaceFlinger's latch grid (client cap GNDRM_CLIENT_CAP_LATCH_GRID, +0x474E4C47 "GNLG"): each flip is stamped with the latch it lands on minus the +daemon's publish lead. MarkVBlank already re-anchors the timer on that stamp, +so the timer now wakes once per panel latch, a fixed lead before it, and the +limiter's releases, the frame callbacks and the paints all sit at a fixed +phase against the latch. Nothing else in the timer changes: it was already an +absolute CLOCK_MONOTONIC timerfd. + +- An older daemon refuses the cap (EINVAL): one log line, the stamps stay on + its own timeline, and the timer still runs at the panel rate (no phase + lock). +- A session launched on the legacy fine grid (-r above 500) does not ask for + the cap and ignores gn_panel_refresh, so an old app with a new daemon keeps + today's behaviour exactly. +- The panel's rate can change while the session runs (120 <-> 60). The daemon + posts it as the ConVar gn_panel_refresh over gamescope_private; the + steamcompmgr thread moves the nested refresh to it at the top of its loop, + re-anchors the limiter and re-arms the timer on the new grid. +- The limited refresh cycle handed to clients (presentation feedback) was a + whole number of vblanks, refresh / target, which truncates on a coarse grid + (40 fps on 60 Hz read as one vblank, 16.7 ms instead of 25). The limiter + releases on a time grid since A52, so the cycle is now the target's period. +- A gamescope_private command repaints once (force_repaint): scaling_filter, + scaling_sharpness and debug_force_repaint change the next frame with no + commit behind them. + +Log lines (logcat, System.out "[gamescope]"): + drm: vblank: daemon latch-grid stamps on (GNLG accepted, grid N mHz) + drm: vblank: daemon latch-grid stamps off (GNLG rejected: ...) + drm: vblank: daemon latch-grid stamps off (legacy -r grid N mHz) + vblank: gn_panel_refresh applied: vblank grid N mHz (was M mHz) + +diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp +index de5960f..a9e0542 100644 +--- a/src/Backends/DRMBackend.cpp ++++ b/src/Backends/DRMBackend.cpp +@@ -1602,6 +1602,31 @@ bool init_drm(struct drm_t *drm, int width, int height, int refresh) + return false; + } + ++ // gamenative-drm: ask for page-flip timestamps on SurfaceFlinger's latch grid ++ // (the latch a flip lands on, minus the daemon's publish lead) instead of the ++ // daemon's own fixed-rate timeline. MarkVBlank anchors the vblank timer on those ++ // stamps, so with -r at the panel rate the compositor wakes once per latch, a fixed ++ // lead before it. A session on the legacy fine grid (-r far above any panel) keeps ++ // the daemon's timeline it was built around. An older daemon refuses the cap ++ // (EINVAL) and stamps as before. ++ if ( BUsesGameNativeDrm() ) ++ { ++ static constexpr uint64_t GNDRM_CLIENT_CAP_LATCH_GRID = 0x474E4C47ull; // "GNLG" ++ static constexpr int k_nMaxPanelRefreshmHz = 500'000; ++ if ( refresh > k_nMaxPanelRefreshmHz ) ++ { ++ drm_log.infof( "vblank: daemon latch-grid stamps off (legacy -r grid %d mHz)", refresh ); ++ } ++ else if ( drmSetClientCap( drm->fd, GNDRM_CLIENT_CAP_LATCH_GRID, 1 ) == 0 ) ++ { ++ drm_log.infof( "vblank: daemon latch-grid stamps on (GNLG accepted, grid %d mHz)", refresh ); ++ } ++ else ++ { ++ drm_log.infof( "vblank: daemon latch-grid stamps off (GNLG rejected: %s)", strerror( errno ) ); ++ } ++ } ++ + if (drmGetCap(drm->fd, DRM_CAP_CURSOR_WIDTH, &drm->cursor_width) != 0) { + drm->cursor_width = 64; + } +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index d6da2ad..1ef5d95 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -1279,6 +1279,32 @@ static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) + if ( g_ulFPSLimitNextRelease <= ulTargetVBlank + ulVBlank / 2 ) + g_ulFPSLimitNextRelease = ulTargetVBlank + ulPeriod; + } ++ ++// The panel's refresh while the session runs. gamenative-drm posts it through ++// gamescope_private when the panel changes rate (120 <-> 60); the launch value is -r. ++// The session's vblank grid is the nested refresh: with the daemon's latch-grid flip ++// stamps the timer then wakes once per panel latch, so the frame limiter releases on ++// real latches. A session launched on the legacy fine grid (-r far above any panel) ++// keeps that grid: the daemon stamps it on its own timeline, and moving it to a panel ++// rate with no phase lock would change pacing for that build pair. ++static constexpr int k_nMaxPanelRefreshHz = 500; ++ ++static gamescope::ConVar cv_gn_panel_refresh( "gn_panel_refresh", 0, "GameNative: the panel's refresh in Hz; moves the vblank grid (nested refresh) to it.", ++[]( gamescope::ConVar &cv ) ++{ ++ if ( cv <= 0 || cv > k_nMaxPanelRefreshHz ) ++ { ++ xwm_log.infof( "gn_panel_refresh %d ignored: out of range", cv.Get() ); ++ return; ++ } ++ if ( g_nNestedRefresh > gamescope::ConvertHztomHz( k_nMaxPanelRefreshHz ) ) ++ { ++ xwm_log.infof( "gn_panel_refresh %d ignored: session runs the legacy %d mHz grid", cv.Get(), g_nNestedRefresh ); ++ return; ++ } ++ GetVBlankTimer().RequestRefreshmHz( gamescope::ConvertHztomHz( cv.Get() ) ); ++ nudge_steamcompmgr(); ++}); + 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. +@@ -10278,6 +10304,15 @@ steamcompmgr_main(int argc, char **argv) + + g_SteamCompMgrWaiter.PollEvents(); + ++ // A panel rate change (gn_panel_refresh) lands here, on the thread that owns the ++ // limiter: the limiter re-anchors on the new grid and the timer re-arms on it now ++ // instead of after one more wakeup on the old one. ++ if ( GetVBlankTimer().ApplyPendingRefresh() ) ++ { ++ g_ulFPSLimitNextRelease = 0; ++ GetVBlankTimer().ArmNextVBlank( false ); ++ } ++ + bool vblank = false; + if ( std::optional pendingVBlank = GetVBlankTimer().ProcessVBlank() ) + { +@@ -10664,9 +10699,11 @@ steamcompmgr_main(int argc, char **argv) + } + else + { +- int nVblankDivisor = nRealRefreshHz / nTargetFPS; +- +- g_SteamCompMgrLimitedAppRefreshCycle = g_SteamCompMgrAppRefreshCycle * nVblankDivisor; ++ // The limiter releases on a time grid (A52), so the cycle a limited ++ // client sees is the target's period. A whole number of vblanks ++ // truncates on a coarse grid: 40 on 60 Hz read as one vblank, 16.7 ms ++ // instead of 25. ++ g_SteamCompMgrLimitedAppRefreshCycle = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); + } + } + } +diff --git a/src/vblankmanager.cpp b/src/vblankmanager.cpp +index 3eb99ac..c70b05d 100644 +--- a/src/vblankmanager.cpp ++++ b/src/vblankmanager.cpp +@@ -279,6 +279,22 @@ namespace gamescope + } + } + ++ void CVBlankTimer::RequestRefreshmHz( int nRefreshmHz ) ++ { ++ m_nPendingRefreshmHz = nRefreshmHz; ++ } ++ ++ bool CVBlankTimer::ApplyPendingRefresh() ++ { ++ int nRefreshmHz = m_nPendingRefreshmHz.exchange( 0 ); ++ if ( nRefreshmHz <= 0 || nRefreshmHz == g_nNestedRefresh ) ++ return false; ++ ++ g_VBlankLog.infof( "gn_panel_refresh applied: vblank grid %d mHz (was %d mHz)", nRefreshmHz, g_nNestedRefresh ); ++ g_nNestedRefresh = nRefreshmHz; ++ return true; ++ } ++ + bool CVBlankTimer::UsingTimerFD() const + { + return m_nNudgePipe[ 0 ] < 0; +diff --git a/src/vblankmanager.hpp b/src/vblankmanager.hpp +index 41902c3..1d9dc5f 100644 +--- a/src/vblankmanager.hpp ++++ b/src/vblankmanager.hpp +@@ -60,6 +60,12 @@ namespace gamescope + void WaitToBeArmed(); + void ArmNextVBlank( bool bPreemptive ); + ++ // The panel's refresh, reported while the session runs (gn_panel_refresh). ++ // Request from any thread; the steamcompmgr thread applies it to the ++ // nested refresh, the rate this timer and the frame limiter step on. ++ void RequestRefreshmHz( int nRefreshmHz ); ++ bool ApplyPendingRefresh(); ++ + bool UsingTimerFD() const; + int GetFD() final; + void OnPollIn() final; +@@ -71,6 +77,7 @@ namespace gamescope + std::atomic m_ulLastVBlank = { 0 }; + std::atomic m_bArmed = { false }; + std::atomic m_bRunning = { true }; ++ std::atomic m_nPendingRefreshmHz = { 0 }; + + std::optional m_PendingVBlank; + +diff --git a/src/wlserver.cpp b/src/wlserver.cpp +index 10b6dda..82831cf 100644 +--- a/src/wlserver.cpp ++++ b/src/wlserver.cpp +@@ -1534,7 +1534,12 @@ static void gamescope_private_execute( struct wl_client *client, struct wl_resou + args.emplace_back( cvar_name ); + args.emplace_back( value ); + if ( gamescope::ConCommand::Exec( std::span{ args } ) ) ++ { ++ // A command can change what the next frame shows (scaling_filter, ++ // scaling_sharpness, debug_force_repaint) without a commit behind it. ++ force_repaint(); + gamescope_private_send_command_executed( resource ); ++ } + } + + static void gamescope_private_handle_destroy( struct wl_client *client, struct wl_resource *resource ) diff --git a/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch b/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch new file mode 100644 index 000000000..71cc9d9e8 --- /dev/null +++ b/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch @@ -0,0 +1,322 @@ +A54 — the compositor stops waking when nothing is pending + +After every timer wakeup steamcompmgr re-armed the vblank timer +unconditionally ("just in case the page-flip handler doesn't happen"), so the +loop ran at the full grid rate for the whole session: epoll, X dispatch, the +frame-callback walk over every window, presentation feedback, the mangoapp +publish and the cursor checks, with only the composite skipped. On the +Retroid Pocket 6 that was ~1500 wakeups and 6-9 % of a core on gamescope-xwm, +whatever the frame count. + +The re-arm now runs on every pass of the loop, not only timer ones, and only +while there is work for the next wakeup: + +- a repaint is pending (hasRepaint, a non-base-plane repaint, a forced + repaint, a forwarded VR overlay) or a focus is fading out; +- a flip is in flight; +- a window has a done commit whose frame callback (or the limiter's release + of it) has not gone out, or a done commit is held for a later vblank (FIFO, + a desired present time); +- the cursor is shown and its auto-hide time has not passed (plus 100 ms, so + the wakeup that hides it is never the one that stops); +- VRR is active or PipeWire is streaming (both keep the old behaviour). + +After vblank_idle_ticks (default 2) timer wakeups with none of that, the +timer stays off until the next nudge. The page-flip handler still re-arms on +every flip, so the wakeup after a flip always runs. vblank_idle_ticks 0 +restores the old always-armed behaviour. + +Everything that set hasRepaint from another thread without waking the loop +now wakes it, or it would wait for an unrelated wakeup: cursor motion and +buttons (wlserver_oncursorevent), the cursor hide (wlserver_mousehide), the +set_look ConCommand and gamescope_control set_look / unset_look (through the +new wlserver_request_repaint: set, and nudge once per painted frame), and +screenshots (CScreenshotManager::TakeScreenshot). gamescope_private commands +repaint through A53's force_repaint. + +With vblank_debug on, idle/resume transitions are logged (rate-limited). + +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 1ef5d95..7c54b05 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -10087,6 +10087,133 @@ static void relay_mangoapp_control() + s_bMangoappLogging = !s_bMangoappLogging; + } + ++namespace gamescope ++{ ++ extern ConVar vblank_debug; ++} ++ ++// The vblank timer re-armed itself after every wakeup, so the compositor thread woke at ++// the full grid rate for the whole session whether or not anything was pending: epoll, ++// X dispatch, the frame-callback walk over every window, presentation feedback and the ++// mangoapp publish, with only the composite skipped. It now stops after a few idle ++// wakeups and starts again on the next nudge (a commit, an X event, input, a flip). ++gamescope::ConVar cv_vblank_idle_ticks( "vblank_idle_ticks", 2, "Vblank wakeups with nothing pending before the compositor stops waking on the vblank grid; it restarts on the next commit, input or command. 0 = wake on every vblank (old behaviour)." ); ++ ++// A window that has a done commit whose frame callback has not gone out yet: the ++// callback (or the limiter's release that lets it out) is only sent on a vblank wakeup. ++static bool steamcompmgr_any_window_owes_frame_done() ++{ ++ gamescope_xwayland_server_t *server = NULL; ++ for ( size_t i = 0; ( server = wlserver_get_xwayland_server( i ) ); i++ ) ++ { ++ for ( steamcompmgr_win_t *w = server->ctx->list; w; w = w->xwayland().next ) ++ { ++ if ( w->receivedDoneCommit && w->current_surface() ) ++ return true; ++ } ++ ++ // Commits held for a later vblank (FIFO, or a desired present time ahead). ++ std::lock_guard lock( server->ctx->doneCommits.listCommitsDoneLock ); ++ if ( !server->ctx->doneCommits.listCommitsDone.empty() ) ++ return true; ++ } ++ ++ for ( const auto &xdg_win : g_steamcompmgr_xdg_wins ) ++ { ++ if ( xdg_win->receivedDoneCommit && xdg_win->current_surface() ) ++ return true; ++ } ++ ++ std::lock_guard lock( g_steamcompmgr_xdg_done_commits.listCommitsDoneLock ); ++ return !g_steamcompmgr_xdg_done_commits.listCommitsDone.empty(); ++} ++ ++// Something the next vblank wakeup has to do: paint, finish a fade, send a frame ++// callback, see a flip retire, or hide an idle cursor when its time comes. ++static bool steamcompmgr_vblank_has_work( bool bVRR ) ++{ ++ if ( bVRR ) ++ return true; ++ ++ if ( hasRepaint || hasRepaintNonBasePlane || g_bForceRepaint || g_bUpdateForwardedVROverlays ) ++ return true; ++ ++ if ( is_any_focus_fading_out() ) ++ return true; ++ ++ if ( GetBackend()->GetCurrentConnector() && ++ GetBackend()->GetCurrentConnector()->PresentationFeedback().CurrentPresentsInFlight() != 0 ) ++ return true; ++ ++ if ( steamcompmgr_any_window_owes_frame_done() ) ++ return true; ++ ++ // A shown cursor auto-hides cursorHideTime after it last moved, checked on a wakeup; ++ // a little margin so the wakeup that hides it is never the one that stops. ++ if ( ShouldDrawCursor() && !wlserver.bCursorHidden && wlserver.bCursorHasImage ) ++ { ++ static constexpr uint64_t k_ulCursorHideMargin = 100'000'000ul; // 100ms ++ if ( get_time_in_nanos() <= wlserver.ulLastMovedCursorTime + cursorHideTime + k_ulCursorHideMargin ) ++ return true; ++ } ++ ++#if HAVE_PIPEWIRE ++ if ( pipewire_is_streaming() ) ++ return true; ++#endif ++ ++ return false; ++} ++ ++static void steamcompmgr_rearm_vblank( bool bIsVBlankFromTimer, bool bVRR ) ++{ ++ const int nIdleTicks = cv_vblank_idle_ticks; ++ if ( nIdleTicks <= 0 ) ++ { ++ // Old behaviour: re-arm after every timer wakeup, just in case the page-flip ++ // handler never comes, so we never stop vblanking. ++ if ( bIsVBlankFromTimer ) ++ GetVBlankTimer().ArmNextVBlank( true ); ++ return; ++ } ++ ++ static int s_nIdleTicks = 0; ++ static bool s_bIdle = false; ++ ++ if ( steamcompmgr_vblank_has_work( bVRR ) ) ++ s_nIdleTicks = 0; ++ else if ( bIsVBlankFromTimer ) ++ s_nIdleTicks++; ++ ++ const bool bIdle = s_nIdleTicks >= nIdleTicks; ++ if ( !bIdle ) ++ { ++ // Pre-emptive: a no-op while armed, so a nudge never moves a wakeup already set. ++ GetVBlankTimer().ArmNextVBlank( true ); ++ } ++ ++ if ( bIdle != s_bIdle ) ++ { ++ s_bIdle = bIdle; ++ if ( gamescope::vblank_debug ) ++ { ++ static uint64_t s_ulLastLog = 0; ++ static uint32_t s_uSuppressed = 0; ++ const uint64_t ulNow = get_time_in_nanos(); ++ if ( ulNow - s_ulLastLog >= 100'000'000ul ) ++ { ++ xwm_log.infof( "vblank: %s (%u transitions not logged)", bIdle ? "idle" : "resume", s_uSuppressed ); ++ s_ulLastLog = ulNow; ++ s_uSuppressed = 0; ++ } ++ else ++ { ++ s_uSuppressed++; ++ } ++ } ++ } ++} ++ + void + steamcompmgr_main(int argc, char **argv) + { +@@ -11008,16 +11135,6 @@ steamcompmgr_main(int argc, char **argv) + publish_mangoapp_connector_snapshots(); + relay_mangoapp_control(); + +- if ( bIsVBlankFromTimer ) +- { +- // Pre-emptively re-arm the vblank timer if it +- // isn't already re-armed. +- // +- // Juuust in case pageflip handler doesn't happen +- // so we don't stop vblanking forever. +- GetVBlankTimer().ArmNextVBlank( true ); +- } +- + #if HAVE_PIPEWIRE + // Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it). + if ( vblank && pipewire_is_streaming() ) +@@ -11046,6 +11163,12 @@ steamcompmgr_main(int argc, char **argv) + XFlush(root_ctx->dpy); + } + ++ // Re-arm on every pass, not only timer ones, so the nudge that brought new work ++ // (a commit, an X event, input) arms the next grid point; with nothing pending ++ // for a few wakeups the timer stays off. A flip always re-arms it from the ++ // page-flip handler, so the wakeup after a flip still runs. ++ steamcompmgr_rearm_vblank( bIsVBlankFromTimer, bVRR ); ++ + vulkan_garbage_collect(); + + vblank = false; +diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp +index 87cf86d..ffcff65 100644 +--- a/src/steamcompmgr_shared.hpp ++++ b/src/steamcompmgr_shared.hpp +@@ -291,6 +291,7 @@ struct steamcompmgr_win_t { + }; + + extern std::atomic hasRepaint; ++void nudge_steamcompmgr( void ); + + namespace gamescope + { +@@ -311,6 +312,8 @@ namespace gamescope + std::unique_lock lock{ m_ScreenshotInfoMutex }; + m_ScreenshotInfo = std::move( info ); + hasRepaint = true; ++ // The compositor may be asleep with nothing else pending (A54). ++ nudge_steamcompmgr(); + } + + void TakeScreenshot( bool bAVIF ) +diff --git a/src/wlserver.cpp b/src/wlserver.cpp +index 82831cf..66a2337 100644 +--- a/src/wlserver.cpp ++++ b/src/wlserver.cpp +@@ -1286,7 +1286,7 @@ static gamescope::ConCommand cc_set_look("set_look", "Set a look for a specific + g_ColorMgmtLooks[ EOTF_Gamma22 ] = LoadCubeLut( arg1.c_str(), bRaisesBlackLevelFloor ); + cv_overlay_unmultiplied_alpha = bRaisesBlackLevelFloor; + g_ColorMgmt.pending.externalDirtyCtr++; +- hasRepaint = true; ++ wlserver_request_repaint(); + } + else if ( args.size() == 3 ) + { +@@ -1317,7 +1317,7 @@ static gamescope::ConCommand cc_set_look("set_look", "Set a look for a specific + } + cv_overlay_unmultiplied_alpha = bRaisesBlackLevelFloor; + g_ColorMgmt.pending.externalDirtyCtr++; +- hasRepaint = true; ++ wlserver_request_repaint(); + } + else + { +@@ -1325,7 +1325,7 @@ static gamescope::ConCommand cc_set_look("set_look", "Set a look for a specific + g_ColorMgmtLooks[ EOTF_Gamma22 ] = nullptr; + g_ColorMgmtLooks[ EOTF_PQ ] = nullptr; + g_ColorMgmt.pending.externalDirtyCtr++; +- hasRepaint = true; ++ wlserver_request_repaint(); + } + }); + +@@ -1374,7 +1374,7 @@ static void gamescope_control_set_look( struct wl_client *client, struct wl_reso + g_ColorMgmtLooks[ EOTF_Gamma22 ] = pG22LUT; + g_ColorMgmtLooks[ EOTF_PQ ] = pPQLUT; + g_ColorMgmt.pending.externalDirtyCtr++; +- hasRepaint = true; ++ wlserver_request_repaint(); + } + + static void gamescope_control_unset_look( struct wl_client *client, struct wl_resource *resource ) +@@ -1383,7 +1383,7 @@ static void gamescope_control_unset_look( struct wl_client *client, struct wl_re + g_ColorMgmtLooks[ EOTF_Gamma22 ] = nullptr; + g_ColorMgmtLooks[ EOTF_PQ ] = nullptr; + g_ColorMgmt.pending.externalDirtyCtr++; +- hasRepaint = true; ++ wlserver_request_repaint(); + } + + static void gamescope_control_handle_destroy( struct wl_client *client, struct wl_resource *resource ) +@@ -2929,6 +2929,15 @@ std::pair wlserver_get_surface_extent( struct wlr_surface *pSurface ) + return std::make_pair( pSurface->current.width, pSurface->current.height ); + } + ++// Ask the compositor for a repaint from outside its loop. The compositor stops waking on ++// the vblank grid when it has nothing pending (A54), so a repaint request must also wake ++// it. One nudge per painted frame: a request while one is outstanding is already covered. ++void wlserver_request_repaint() ++{ ++ if ( !hasRepaint.exchange( true ) ) ++ nudge_steamcompmgr(); ++} ++ + bool ShouldDrawCursor(); + void wlserver_oncursorevent() + { +@@ -2938,7 +2947,7 @@ void wlserver_oncursorevent() + + if ( !wlserver.bCursorHidden && wlserver.bCursorHasImage ) + { +- hasRepaint = true; ++ wlserver_request_repaint(); + } + } + +@@ -3092,7 +3101,7 @@ void wlserver_mousehide() + if ( wlserver.bCursorHidden != true ) + { + wlserver.bCursorHidden = true; +- hasRepaint = true; ++ wlserver_request_repaint(); + } + } + +diff --git a/src/wlserver.hpp b/src/wlserver.hpp +index 54452c1..885bab5 100644 +--- a/src/wlserver.hpp ++++ b/src/wlserver.hpp +@@ -281,6 +281,7 @@ void wlserver_clear_dropdowns(); + void wlserver_notify_dropdown( struct wlr_surface *wlrsurface, int nX, int nY ); + void wlserver_mousemotion( double x, double y, uint32_t time ); + void wlserver_mousehide(); ++void wlserver_request_repaint(); + void wlserver_mousewarp( double x, double y, uint32_t time, bool bSynthetic ); + void wlserver_mousebutton( int button, bool press, uint32_t time ); + void wlserver_mousewheel( double x, double y, uint32_t time ); From 5d4c9279f4eab68ca97aba0c3fd00badf986a99a Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 31 Oct 2026 14:32:38 +0530 Subject: [PATCH 2/2] =?UTF-8?q?android:=20A53/A54=20=E2=80=94=20repeat=20p?= =?UTF-8?q?anel=20reports=20are=20no-ops,=20flip=20stamps=20ahead=20of=20n?= =?UTF-8?q?ow=20are=20safe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gn_panel_refresh is re-posted by the daemon on every panel report: a repeat of the current rate changes nothing and does not repaint, and a refused value is logged once. A commit done while the compositor is idle takes its earliest present time from the next grid point, and the present margin no longer wraps when the earliest present time is behind the latch time. --- ...1-A53-vblank-on-the-panel-latch-grid.patch | 44 +++++++++---- ...0052-A54-compositor-sleeps-when-idle.patch | 65 ++++++++++++++++--- 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch b/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch index 9f08aedd4..658d478dc 100644 --- a/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch +++ b/android/patches/gamescope/0051-A53-vblank-on-the-panel-latch-grid.patch @@ -31,9 +31,18 @@ absolute CLOCK_MONOTONIC timerfd. whole number of vblanks, refresh / target, which truncates on a coarse grid (40 fps on 60 Hz read as one vblank, 16.7 ms instead of 25). The limiter releases on a time grid since A52, so the cycle is now the target's period. + The daemon re-posts the rate on every panel report: a repeat of the current + rate changes nothing (no limiter re-anchor, no log), and a refused value + (out of range, legacy grid) is logged once. +- A latch-grid stamp can be up to a panel period plus the lead ahead of the + moment the event arrives (the old timeline was at most 0.83 ms ahead). + Every reader was checked: GetNextVBlank only steps forward from it (a stamp + ahead skips the vblank the flip already occupies, which is right), the + mangoapp frametime takes stamp-to-stamp deltas and already drops a stamp + older than the last, and nothing else subtracts it from now. - A gamescope_private command repaints once (force_repaint): scaling_filter, scaling_sharpness and debug_force_repaint change the next frame with no - commit behind them. + commit behind them. gn_panel_refresh does not: it only moves the grid. Log lines (logcat, System.out "[gamescope]"): drm: vblank: daemon latch-grid stamps on (GNLG accepted, grid N mHz) @@ -78,10 +87,10 @@ index de5960f..a9e0542 100644 drm->cursor_width = 64; } diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index d6da2ad..1ef5d95 100644 +index d6da2ad..6af0c7e 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -1279,6 +1279,32 @@ static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) +@@ -1279,6 +1279,42 @@ static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) if ( g_ulFPSLimitNextRelease <= ulTargetVBlank + ulVBlank / 2 ) g_ulFPSLimitNextRelease = ulTargetVBlank + ulPeriod; } @@ -98,23 +107,33 @@ index d6da2ad..1ef5d95 100644 +static gamescope::ConVar cv_gn_panel_refresh( "gn_panel_refresh", 0, "GameNative: the panel's refresh in Hz; moves the vblank grid (nested refresh) to it.", +[]( gamescope::ConVar &cv ) +{ ++ // The daemon re-posts the rate on every report, not only on a change: a repeat of ++ // the current rate is a no-op (ApplyPendingRefresh), and a refusal is logged once ++ // per value. ++ static int s_nLastRefused = 0; + if ( cv <= 0 || cv > k_nMaxPanelRefreshHz ) + { -+ xwm_log.infof( "gn_panel_refresh %d ignored: out of range", cv.Get() ); ++ if ( std::exchange( s_nLastRefused, cv.Get() ) != cv.Get() ) ++ xwm_log.infof( "gn_panel_refresh %d ignored: out of range", cv.Get() ); + return; + } + if ( g_nNestedRefresh > gamescope::ConvertHztomHz( k_nMaxPanelRefreshHz ) ) + { -+ xwm_log.infof( "gn_panel_refresh %d ignored: session runs the legacy %d mHz grid", cv.Get(), g_nNestedRefresh ); ++ if ( std::exchange( s_nLastRefused, cv.Get() ) != cv.Get() ) ++ xwm_log.infof( "gn_panel_refresh %d ignored: session runs the legacy %d mHz grid", cv.Get(), g_nNestedRefresh ); + return; + } ++ s_nLastRefused = 0; ++ // Always posted, so a later repeat supersedes a change still pending; the loop is ++ // only woken for a change. + GetVBlankTimer().RequestRefreshmHz( gamescope::ConvertHztomHz( cv.Get() ) ); -+ nudge_steamcompmgr(); ++ if ( gamescope::ConvertHztomHz( cv.Get() ) != g_nNestedRefresh ) ++ nudge_steamcompmgr(); +}); 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. -@@ -10278,6 +10304,15 @@ steamcompmgr_main(int argc, char **argv) +@@ -10278,6 +10314,15 @@ steamcompmgr_main(int argc, char **argv) g_SteamCompMgrWaiter.PollEvents(); @@ -130,7 +149,7 @@ index d6da2ad..1ef5d95 100644 bool vblank = false; if ( std::optional pendingVBlank = GetVBlankTimer().ProcessVBlank() ) { -@@ -10664,9 +10699,11 @@ steamcompmgr_main(int argc, char **argv) +@@ -10664,9 +10709,11 @@ steamcompmgr_main(int argc, char **argv) } else { @@ -198,17 +217,20 @@ index 41902c3..1d9dc5f 100644 std::optional m_PendingVBlank; diff --git a/src/wlserver.cpp b/src/wlserver.cpp -index 10b6dda..82831cf 100644 +index 10b6dda..8229dfb 100644 --- a/src/wlserver.cpp +++ b/src/wlserver.cpp -@@ -1534,7 +1534,12 @@ static void gamescope_private_execute( struct wl_client *client, struct wl_resou +@@ -1534,7 +1534,15 @@ static void gamescope_private_execute( struct wl_client *client, struct wl_resou args.emplace_back( cvar_name ); args.emplace_back( value ); if ( gamescope::ConCommand::Exec( std::span{ args } ) ) + { + // A command can change what the next frame shows (scaling_filter, + // scaling_sharpness, debug_force_repaint) without a commit behind it. -+ force_repaint(); ++ // gn_panel_refresh is re-posted on every panel report and moves only the ++ // vblank grid, so it does not cost a frame. ++ if ( std::string_view{ cvar_name } != "gn_panel_refresh" ) ++ force_repaint(); gamescope_private_send_command_executed( resource ); + } } diff --git a/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch b/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch index 71cc9d9e8..5adb72bb1 100644 --- a/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch +++ b/android/patches/gamescope/0052-A54-compositor-sleeps-when-idle.patch @@ -34,13 +34,62 @@ new wlserver_request_repaint: set, and nudge once per painted frame), and screenshots (CScreenshotManager::TakeScreenshot). gamescope_private commands repaint through A53's force_repaint. +A commit that becomes done while the compositor is idle takes its earliest +present time from the next grid point, not the last wakeup's target, which +is in the past after an idle stretch; the present margin (earliest present +minus latch, unsigned) no longer wraps when the two cross. + With vblank_debug on, idle/resume transitions are logged (rate-limited). diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp -index 1ef5d95..7c54b05 100644 +index 6af0c7e..0770097 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp -@@ -10087,6 +10087,133 @@ static void relay_mangoapp_control() +@@ -8193,7 +8193,7 @@ bool handle_done_commit( steamcompmgr_win_t *w, xwayland_ctx_t *ctx, uint64_t co + gpuvis_trace_printf( "commit %lu done", w->commit_queue[ j ]->commitID ); + w->commit_queue[ j ]->done = true; + w->commit_queue[ j ]->earliest_present_time = earliestPresentTime; +- w->commit_queue[ j ]->present_margin = earliestPresentTime - earliestLatchTime; ++ w->commit_queue[ j ]->present_margin = earliestPresentTime > earliestLatchTime ? earliestPresentTime - earliestLatchTime : 0; + bFoundWindow = true; + + // Window just got a new available commit, determine if that's worth a repaint +@@ -8280,12 +8280,24 @@ bool handle_done_commit( steamcompmgr_win_t *w, xwayland_ctx_t *ctx, uint64_t co + return false; + } + ++// The vblank a commit that is done now can first be shown on. The last wakeup's target ++// is that vblank while the timer runs on every vblank; after the compositor has been ++// idle (A54) it is in the past, and a commit's earliest present time and present margin ++// (earliest present - latch, unsigned) must not be taken from it. ++static uint64_t steamcompmgr_next_refresh_time() ++{ ++ uint64_t ulTarget = g_SteamCompMgrVBlankTime.schedule.ulTargetVBlank; ++ if ( ulTarget < get_time_in_nanos() ) ++ ulTarget = GetVBlankTimer().GetNextVBlank( 0 ); ++ return ulTarget; ++} ++ + // TODO: Merge these two functions. + void handle_done_commits_xwayland( xwayland_ctx_t *ctx, bool vblank, uint64_t vblank_idx ) + { + std::lock_guard lock( ctx->doneCommits.listCommitsDoneLock ); + +- uint64_t next_refresh_time = g_SteamCompMgrVBlankTime.schedule.ulTargetVBlank; ++ uint64_t next_refresh_time = steamcompmgr_next_refresh_time(); + + // commits that were not ready to be presented based on their display timing. + static std::vector< CommitDoneEntry_t > commits_before_their_time; +@@ -8347,7 +8359,7 @@ void handle_done_commits_xdg( bool vblank, uint64_t vblank_idx ) + { + std::lock_guard lock( g_steamcompmgr_xdg_done_commits.listCommitsDoneLock ); + +- uint64_t next_refresh_time = g_SteamCompMgrVBlankTime.schedule.ulTargetVBlank; ++ uint64_t next_refresh_time = steamcompmgr_next_refresh_time(); + + // commits that were not ready to be presented based on their display timing. + static std::vector< CommitDoneEntry_t > commits_before_their_time; +@@ -10097,6 +10109,133 @@ static void relay_mangoapp_control() s_bMangoappLogging = !s_bMangoappLogging; } @@ -174,7 +223,7 @@ index 1ef5d95..7c54b05 100644 void steamcompmgr_main(int argc, char **argv) { -@@ -11008,16 +11135,6 @@ steamcompmgr_main(int argc, char **argv) +@@ -11018,16 +11157,6 @@ steamcompmgr_main(int argc, char **argv) publish_mangoapp_connector_snapshots(); relay_mangoapp_control(); @@ -191,7 +240,7 @@ index 1ef5d95..7c54b05 100644 #if HAVE_PIPEWIRE // Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it). if ( vblank && pipewire_is_streaming() ) -@@ -11046,6 +11163,12 @@ steamcompmgr_main(int argc, char **argv) +@@ -11056,6 +11185,12 @@ steamcompmgr_main(int argc, char **argv) XFlush(root_ctx->dpy); } @@ -226,7 +275,7 @@ index 87cf86d..ffcff65 100644 void TakeScreenshot( bool bAVIF ) diff --git a/src/wlserver.cpp b/src/wlserver.cpp -index 82831cf..66a2337 100644 +index 8229dfb..da058e1 100644 --- a/src/wlserver.cpp +++ b/src/wlserver.cpp @@ -1286,7 +1286,7 @@ static gamescope::ConCommand cc_set_look("set_look", "Set a look for a specific @@ -274,7 +323,7 @@ index 82831cf..66a2337 100644 } static void gamescope_control_handle_destroy( struct wl_client *client, struct wl_resource *resource ) -@@ -2929,6 +2929,15 @@ std::pair wlserver_get_surface_extent( struct wlr_surface *pSurface ) +@@ -2932,6 +2932,15 @@ std::pair wlserver_get_surface_extent( struct wlr_surface *pSurface ) return std::make_pair( pSurface->current.width, pSurface->current.height ); } @@ -290,7 +339,7 @@ index 82831cf..66a2337 100644 bool ShouldDrawCursor(); void wlserver_oncursorevent() { -@@ -2938,7 +2947,7 @@ void wlserver_oncursorevent() +@@ -2941,7 +2950,7 @@ void wlserver_oncursorevent() if ( !wlserver.bCursorHidden && wlserver.bCursorHasImage ) { @@ -299,7 +348,7 @@ index 82831cf..66a2337 100644 } } -@@ -3092,7 +3101,7 @@ void wlserver_mousehide() +@@ -3095,7 +3104,7 @@ void wlserver_mousehide() if ( wlserver.bCursorHidden != true ) { wlserver.bCursorHidden = true;