@@ -165,6 +165,17 @@ public partial class OsuGameAndroid : OsuGame
165165 private global ::Android . Content . PM . ScreenOrientation ? lastRequestedOrientation ;
166166 private int currentRefreshRate ;
167167
168+ // ADPF (Android Dynamic Performance Framework) hint sessions for the Draw and Update threads.
169+ // These tell the CPU scheduler to boost the clock frequency so game-loop threads can complete
170+ // their work within the display frame deadline (e.g. 8.33 ms at 120 Hz).
171+ // The sessions are created once after LoadComplete (when thread IDs are stable) and closed on
172+ // Dispose. Target duration is updated whenever the active display refresh rate changes.
173+ // Per-frame actual-duration reporting is done via GameThread.FrameCompleted, which fires on
174+ // the respective game thread at the end of every frame (after clock throttle), giving the
175+ // CPU governor a signal to pre-boost the clock for the next frame.
176+ private IntPtr adpfDrawSession ;
177+ private IntPtr adpfUpdateSession ;
178+
168179 // Surface.setFrameRate() compatibility constants from android.view.Surface.
169180 // Hard-coded because the Xamarin/.NET-for-Android bindings do not always expose
170181 // these as named fields across binding versions.
@@ -517,6 +528,59 @@ protected override void LoadComplete()
517528 }
518529 } ) ;
519530 }
531+
532+ // ADPF (Android Dynamic Performance Framework) hint sessions for Draw + Update threads.
533+ // These hint sessions tell the CPU governor "these threads need to finish their work
534+ // within one display-frame interval". The kernel then pre-boosts the CPU frequency
535+ // so the threads don't stall mid-frame waiting for a slow core to spin up.
536+ //
537+ // Target duration = 1 / displayRefreshRate. We default to 120 Hz (8.33 ms) and
538+ // update the target when the display refresh rate is confirmed by applyDisplayMode.
539+ //
540+ // nADPFCreateSession() captures gettid() of the *calling* thread, so each Add
541+ // lambda must run on its respective game thread to register the correct TID.
542+ Scheduler . Add ( ( ) =>
543+ {
544+ try
545+ {
546+ Host ? . DrawThread ? . Scheduler . Add ( ( ) =>
547+ {
548+ try
549+ {
550+ long targetNs = currentRefreshRate > 0 ? 1_000_000_000L / currentRefreshRate : 8_333_333L ;
551+ adpfDrawSession = OboeAudioBridge . nADPFCreateSession ( targetNs ) ;
552+ if ( adpfDrawSession != IntPtr . Zero )
553+ {
554+ Logger . Log ( $ "[osu!] ADPF session created for Draw thread (target={ targetNs / 1_000_000.0 : F2} ms)", LoggingTarget . Performance ) ;
555+ // Subscribe per-frame reporting now that the session handle is valid.
556+ // FrameCompleted fires on the Draw thread itself, so reading
557+ // Host.DrawThread.Clock.ElapsedFrameTime is thread-safe.
558+ Host ! . DrawThread ! . FrameCompleted += onDrawFrameCompleted ;
559+ }
560+ }
561+ catch { }
562+ } ) ;
563+
564+ Host ? . UpdateThread ? . Scheduler . Add ( ( ) =>
565+ {
566+ try
567+ {
568+ long targetNs = currentRefreshRate > 0 ? 1_000_000_000L / currentRefreshRate : 8_333_333L ;
569+ adpfUpdateSession = OboeAudioBridge . nADPFCreateSession ( targetNs ) ;
570+ if ( adpfUpdateSession != IntPtr . Zero )
571+ {
572+ Logger . Log ( $ "[osu!] ADPF session created for Update thread (target={ targetNs / 1_000_000.0 : F2} ms)", LoggingTarget . Performance ) ;
573+ Host ! . UpdateThread ! . FrameCompleted += onUpdateFrameCompleted ;
574+ }
575+ }
576+ catch { }
577+ } ) ;
578+ }
579+ catch ( Exception e )
580+ {
581+ Debug . WriteLine ( $ "[osu!] Failed to enqueue ADPF session creation: { e . Message } ") ;
582+ }
583+ } ) ;
520584 }
521585 catch ( Exception e )
522586 {
@@ -1380,6 +1444,14 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And
13801444 }
13811445
13821446 Logger . Log ( $ "[osu!] Display mode applied: { mode . RefreshRate } Hz (mode { mode . ModeId } , { mode . PhysicalWidth } x{ mode . PhysicalHeight } )", LoggingTarget . Performance ) ;
1447+
1448+ // Update ADPF target duration to match the new display refresh rate.
1449+ // This keeps the CPU governor hint aligned with the actual frame deadline.
1450+ if ( mode . RefreshRate > 0 )
1451+ {
1452+ long targetNs = ( long ) ( 1_000_000_000.0 / mode . RefreshRate ) ;
1453+ updateAdpfTargetDuration ( targetNs ) ;
1454+ }
13831455 }
13841456 catch ( Exception e )
13851457 {
@@ -1388,6 +1460,60 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And
13881460 } ) ;
13891461 }
13901462
1463+ /// <summary>
1464+ /// Updates the target work duration on both ADPF hint sessions (Draw + Update thread)
1465+ /// so the CPU governor can pre-boost each thread to meet the new frame deadline.
1466+ /// </summary>
1467+ private void updateAdpfTargetDuration ( long targetNs )
1468+ {
1469+ try
1470+ {
1471+ if ( adpfDrawSession != IntPtr . Zero )
1472+ OboeAudioBridge . nADPFUpdateTargetDuration ( adpfDrawSession , targetNs ) ;
1473+ if ( adpfUpdateSession != IntPtr . Zero )
1474+ OboeAudioBridge . nADPFUpdateTargetDuration ( adpfUpdateSession , targetNs ) ;
1475+ }
1476+ catch { }
1477+ }
1478+
1479+ /// <summary>
1480+ /// Called by <see cref="osu.Framework.Threading.GameThread.FrameCompleted"/> on the Draw thread.
1481+ /// Reports the actual frame duration to the ADPF session so the CPU governor can adjust clock
1482+ /// frequency for the next frame. <see cref="osu.Framework.Threading.GameThread.Clock"/>
1483+ /// <c>ElapsedFrameTime</c> is in milliseconds; we convert to nanoseconds for the ADPF API.
1484+ /// In <see cref="FrameSync.ActualUnlimited"/> mode there is no throttle sleep, so
1485+ /// ElapsedFrameTime accurately reflects actual GPU+CPU work time.
1486+ /// </summary>
1487+ private void onDrawFrameCompleted ( )
1488+ {
1489+ if ( adpfDrawSession == IntPtr . Zero ) return ;
1490+
1491+ try
1492+ {
1493+ double elapsedMs = Host ? . DrawThread ? . Clock . ElapsedFrameTime ?? 0 ;
1494+ if ( elapsedMs > 0 )
1495+ OboeAudioBridge . nADPFReportActualDuration ( adpfDrawSession , ( long ) ( elapsedMs * 1_000_000.0 ) ) ;
1496+ }
1497+ catch { }
1498+ }
1499+
1500+ /// <summary>
1501+ /// Called by <see cref="osu.Framework.Threading.GameThread.FrameCompleted"/> on the Update thread.
1502+ /// See <see cref="onDrawFrameCompleted"/> for rationale.
1503+ /// </summary>
1504+ private void onUpdateFrameCompleted ( )
1505+ {
1506+ if ( adpfUpdateSession == IntPtr . Zero ) return ;
1507+
1508+ try
1509+ {
1510+ double elapsedMs = Host ? . UpdateThread ? . Clock . ElapsedFrameTime ?? 0 ;
1511+ if ( elapsedMs > 0 )
1512+ OboeAudioBridge . nADPFReportActualDuration ( adpfUpdateSession , ( long ) ( elapsedMs * 1_000_000.0 ) ) ;
1513+ }
1514+ catch { }
1515+ }
1516+
13911517 private global ::Android . Views . Display ? getActiveDisplay ( )
13921518 {
13931519 if ( gameActivity . IsFinishing || gameActivity . IsDestroyed )
@@ -1846,7 +1972,7 @@ private void updateOrientation()
18461972
18471973 /// <summary>
18481974 /// One-shot migration that switches Android-side <see cref="FrameSync"/> from the
1849- /// framework default of <see cref="FrameSync.Limit2x"/> to <see cref="FrameSync.VSync "/>.
1975+ /// framework default of <see cref="FrameSync.Limit2x"/> to <see cref="FrameSync.ActualUnlimited "/>.
18501976 ///
18511977 /// <para>
18521978 /// On a 120Hz Adreno-class display (Snapdragon 8 Gen 2 / S23 Ultra),
@@ -1860,11 +1986,14 @@ private void updateOrientation()
18601986 /// </para>
18611987 ///
18621988 /// <para>
1863- /// <see cref="FrameSync.VSync"/> caps the draw thread to the display refresh and
1864- /// bounds in-flight frames to one, eliminating the pile-up. The migration runs
1865- /// exactly once per install (gated by <see cref="OsuSetting.AndroidStartupFrameSyncMigrationApplied"/>)
1866- /// so a user who later prefers <c>Limit2x</c>/<c>Unlimited</c> from
1867- /// Settings > Graphics > Renderer is not fought on every launch.
1989+ /// <see cref="FrameSync.ActualUnlimited"/> uses Vulkan IMMEDIATE present mode (VK_PRESENT_MODE_IMMEDIATE_KHR)
1990+ /// which presents each frame as soon as it is ready without waiting for vblank.
1991+ /// Combined with VK_GOOGLE_display_timing (skipping desiredPresentTime in IMMEDIATE mode),
1992+ /// this delivers the lowest possible input-to-display latency while avoiding the
1993+ /// vkAcquireNextImageKHR queue pile-up of Limit2x. The migration runs exactly once per
1994+ /// install (gated by <see cref="OsuSetting.AndroidStartupFrameSyncMigrationApplied"/>)
1995+ /// so a user who later prefers a different mode from Settings → Graphics → Renderer
1996+ /// is not fought on every launch.
18681997 /// </para>
18691998 /// </summary>
18701999 private void applyAndroidFrameSyncMigrationOnce ( FrameworkConfigManager frameworkConfig )
@@ -1875,21 +2004,40 @@ private void applyAndroidFrameSyncMigrationOnce(FrameworkConfigManager framework
18752004 if ( LocalConfig . Get < bool > ( OsuSetting . AndroidStartupFrameSyncMigrationApplied ) )
18762005 {
18772006 CrashDiagnostics . WriteAliveMarker ( "applyAndroidFrameSyncMigrationOnce (already applied)" ) ;
2007+
2008+ // v2 migration: upgrade users who were previously migrated to VSync (by an older
2009+ // build) to ActualUnlimited. Only applies if:
2010+ // 1. The v2 migration hasn't run yet.
2011+ // 2. The user is currently on VSync (hasn't manually changed it since v1).
2012+ // This gives existing users the lower-latency uncapped mode without overriding
2013+ // deliberate user choices.
2014+ if ( ! LocalConfig . Get < bool > ( OsuSetting . AndroidStartupFrameSyncV2MigrationApplied ) )
2015+ {
2016+ var frameSync = frameworkConfig . GetBindable < FrameSync > ( FrameworkSetting . FrameSync ) ;
2017+ if ( frameSync . Value == FrameSync . VSync )
2018+ {
2019+ frameSync . Value = FrameSync . ActualUnlimited ;
2020+ Logger . Log ( "[osu!] Android FrameSync v2 migration: VSync → ActualUnlimited (IMMEDIATE present mode, lower latency)" , LoggingTarget . Performance ) ;
2021+ }
2022+ LocalConfig . SetValue ( OsuSetting . AndroidStartupFrameSyncV2MigrationApplied , true ) ;
2023+ }
2024+
18782025 return ;
18792026 }
18802027
1881- var frameSync = frameworkConfig . GetBindable < FrameSync > ( FrameworkSetting . FrameSync ) ;
2028+ var frameSyncV1 = frameworkConfig . GetBindable < FrameSync > ( FrameworkSetting . FrameSync ) ;
18822029
18832030 // Only override the framework default. If the user has already explicitly
18842031 // chosen a different mode (Unlimited / VSync / Custom), respect that —
18852032 // the migration's job is to nudge the *default*, not to overwrite intent.
1886- if ( frameSync . Value == FrameSync . Limit2x )
2033+ if ( frameSyncV1 . Value == FrameSync . Limit2x )
18872034 {
1888- frameSync . Value = FrameSync . VSync ;
1889- Logger . Log ( "[osu!] Android first-launch FrameSync migration: Limit2x → VSync (bounds Vulkan present-queue depth on Adreno )" , LoggingTarget . Performance ) ;
2035+ frameSyncV1 . Value = FrameSync . ActualUnlimited ;
2036+ Logger . Log ( "[osu!] Android first-launch FrameSync migration: Limit2x → ActualUnlimited (IMMEDIATE present, no vblank stall )" , LoggingTarget . Performance ) ;
18902037 }
18912038
18922039 LocalConfig . SetValue ( OsuSetting . AndroidStartupFrameSyncMigrationApplied , true ) ;
2040+ LocalConfig . SetValue ( OsuSetting . AndroidStartupFrameSyncV2MigrationApplied , true ) ;
18932041 }
18942042 catch ( Exception e )
18952043 {
@@ -2342,6 +2490,29 @@ protected override void Dispose(bool isDisposing)
23422490 dexPerformanceSession ? . Dispose ( ) ;
23432491 dexPerformanceSession = null ;
23442492
2493+ // Close ADPF hint sessions for game threads.
2494+ // Unsubscribe FrameCompleted FIRST so the callbacks don't fire with a stale
2495+ // (already-closed) session handle during the final frames of teardown.
2496+ try
2497+ {
2498+ if ( Host ? . DrawThread != null )
2499+ Host . DrawThread . FrameCompleted -= onDrawFrameCompleted ;
2500+ if ( Host ? . UpdateThread != null )
2501+ Host . UpdateThread . FrameCompleted -= onUpdateFrameCompleted ;
2502+
2503+ if ( adpfDrawSession != IntPtr . Zero )
2504+ {
2505+ OboeAudioBridge . nADPFCloseSession ( adpfDrawSession ) ;
2506+ adpfDrawSession = IntPtr . Zero ;
2507+ }
2508+ if ( adpfUpdateSession != IntPtr . Zero )
2509+ {
2510+ OboeAudioBridge . nADPFCloseSession ( adpfUpdateSession ) ;
2511+ adpfUpdateSession = IntPtr . Zero ;
2512+ }
2513+ }
2514+ catch { }
2515+
23452516 var cst = System . Threading . Interlocked . Exchange ( ref coldStartTamingTimer , null ) ;
23462517 try { cst ? . Dispose ( ) ; }
23472518 catch { /* ignore */ }
0 commit comments