@@ -165,6 +165,14 @@ 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+ private IntPtr adpfDrawSession ;
174+ private IntPtr adpfUpdateSession ;
175+
168176 // Surface.setFrameRate() compatibility constants from android.view.Surface.
169177 // Hard-coded because the Xamarin/.NET-for-Android bindings do not always expose
170178 // these as named fields across binding versions.
@@ -517,6 +525,50 @@ protected override void LoadComplete()
517525 }
518526 } ) ;
519527 }
528+
529+ // ADPF (Android Dynamic Performance Framework) hint sessions for Draw + Update threads.
530+ // These hint sessions tell the CPU governor "these threads need to finish their work
531+ // within one display-frame interval". The kernel then pre-boosts the CPU frequency
532+ // so the threads don't stall mid-frame waiting for a slow core to spin up.
533+ //
534+ // Target duration = 1 / displayRefreshRate. We default to 120 Hz (8.33 ms) and
535+ // update the target when the display refresh rate is confirmed by applyDisplayMode.
536+ //
537+ // nADPFCreateSession() captures gettid() of the *calling* thread, so each Add
538+ // lambda must run on its respective game thread to register the correct TID.
539+ Scheduler . Add ( ( ) =>
540+ {
541+ try
542+ {
543+ Host ? . DrawThread ? . Scheduler . Add ( ( ) =>
544+ {
545+ try
546+ {
547+ long targetNs = currentRefreshRate > 0 ? 1_000_000_000L / currentRefreshRate : 8_333_333L ;
548+ adpfDrawSession = OboeAudioBridge . nADPFCreateSession ( targetNs ) ;
549+ if ( adpfDrawSession != IntPtr . Zero )
550+ Logger . Log ( $ "[osu!] ADPF session created for Draw thread (target={ targetNs / 1_000_000.0 : F2} ms)", LoggingTarget . Performance ) ;
551+ }
552+ catch { }
553+ } ) ;
554+
555+ Host ? . UpdateThread ? . Scheduler . Add ( ( ) =>
556+ {
557+ try
558+ {
559+ long targetNs = currentRefreshRate > 0 ? 1_000_000_000L / currentRefreshRate : 8_333_333L ;
560+ adpfUpdateSession = OboeAudioBridge . nADPFCreateSession ( targetNs ) ;
561+ if ( adpfUpdateSession != IntPtr . Zero )
562+ Logger . Log ( $ "[osu!] ADPF session created for Update thread (target={ targetNs / 1_000_000.0 : F2} ms)", LoggingTarget . Performance ) ;
563+ }
564+ catch { }
565+ } ) ;
566+ }
567+ catch ( Exception e )
568+ {
569+ Debug . WriteLine ( $ "[osu!] Failed to enqueue ADPF session creation: { e . Message } ") ;
570+ }
571+ } ) ;
520572 }
521573 catch ( Exception e )
522574 {
@@ -1380,6 +1432,14 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And
13801432 }
13811433
13821434 Logger . Log ( $ "[osu!] Display mode applied: { mode . RefreshRate } Hz (mode { mode . ModeId } , { mode . PhysicalWidth } x{ mode . PhysicalHeight } )", LoggingTarget . Performance ) ;
1435+
1436+ // Update ADPF target duration to match the new display refresh rate.
1437+ // This keeps the CPU governor hint aligned with the actual frame deadline.
1438+ if ( mode . RefreshRate > 0 )
1439+ {
1440+ long targetNs = ( long ) ( 1_000_000_000.0 / mode . RefreshRate ) ;
1441+ updateAdpfTargetDuration ( targetNs ) ;
1442+ }
13831443 }
13841444 catch ( Exception e )
13851445 {
@@ -1388,8 +1448,23 @@ private void applyDisplayMode(global::Android.Views.Display display, global::And
13881448 } ) ;
13891449 }
13901450
1391- private global ::Android . Views . Display ? getActiveDisplay ( )
1451+ /// <summary>
1452+ /// Updates the target work duration on both ADPF hint sessions (Draw + Update thread)
1453+ /// so the CPU governor can pre-boost each thread to meet the new frame deadline.
1454+ /// </summary>
1455+ private void updateAdpfTargetDuration ( long targetNs )
13921456 {
1457+ try
1458+ {
1459+ if ( adpfDrawSession != IntPtr . Zero )
1460+ OboeAudioBridge . nADPFUpdateTargetDuration ( adpfDrawSession , targetNs ) ;
1461+ if ( adpfUpdateSession != IntPtr . Zero )
1462+ OboeAudioBridge . nADPFUpdateTargetDuration ( adpfUpdateSession , targetNs ) ;
1463+ }
1464+ catch { }
1465+ }
1466+
1467+ private global ::Android . Views . Display ? getActiveDisplay ( ) {
13931468 if ( gameActivity . IsFinishing || gameActivity . IsDestroyed )
13941469 return null ;
13951470
@@ -1846,7 +1921,7 @@ private void updateOrientation()
18461921
18471922 /// <summary>
18481923 /// 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 "/>.
1924+ /// framework default of <see cref="FrameSync.Limit2x"/> to <see cref="FrameSync.ActualUnlimited "/>.
18501925 ///
18511926 /// <para>
18521927 /// On a 120Hz Adreno-class display (Snapdragon 8 Gen 2 / S23 Ultra),
@@ -1860,11 +1935,14 @@ private void updateOrientation()
18601935 /// </para>
18611936 ///
18621937 /// <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.
1938+ /// <see cref="FrameSync.ActualUnlimited"/> uses Vulkan IMMEDIATE present mode (VK_PRESENT_MODE_IMMEDIATE_KHR)
1939+ /// which presents each frame as soon as it is ready without waiting for vblank.
1940+ /// Combined with VK_GOOGLE_display_timing (skipping desiredPresentTime in IMMEDIATE mode),
1941+ /// this delivers the lowest possible input-to-display latency while avoiding the
1942+ /// vkAcquireNextImageKHR queue pile-up of Limit2x. The migration runs exactly once per
1943+ /// install (gated by <see cref="OsuSetting.AndroidStartupFrameSyncMigrationApplied"/>)
1944+ /// so a user who later prefers a different mode from Settings → Graphics → Renderer
1945+ /// is not fought on every launch.
18681946 /// </para>
18691947 /// </summary>
18701948 private void applyAndroidFrameSyncMigrationOnce ( FrameworkConfigManager frameworkConfig )
@@ -1875,21 +1953,40 @@ private void applyAndroidFrameSyncMigrationOnce(FrameworkConfigManager framework
18751953 if ( LocalConfig . Get < bool > ( OsuSetting . AndroidStartupFrameSyncMigrationApplied ) )
18761954 {
18771955 CrashDiagnostics . WriteAliveMarker ( "applyAndroidFrameSyncMigrationOnce (already applied)" ) ;
1956+
1957+ // v2 migration: upgrade users who were previously migrated to VSync (by an older
1958+ // build) to ActualUnlimited. Only applies if:
1959+ // 1. The v2 migration hasn't run yet.
1960+ // 2. The user is currently on VSync (hasn't manually changed it since v1).
1961+ // This gives existing users the lower-latency uncapped mode without overriding
1962+ // deliberate user choices.
1963+ if ( ! LocalConfig . Get < bool > ( OsuSetting . AndroidStartupFrameSyncV2MigrationApplied ) )
1964+ {
1965+ var frameSync = frameworkConfig . GetBindable < FrameSync > ( FrameworkSetting . FrameSync ) ;
1966+ if ( frameSync . Value == FrameSync . VSync )
1967+ {
1968+ frameSync . Value = FrameSync . ActualUnlimited ;
1969+ Logger . Log ( "[osu!] Android FrameSync v2 migration: VSync → ActualUnlimited (IMMEDIATE present mode, lower latency)" , LoggingTarget . Performance ) ;
1970+ }
1971+ LocalConfig . SetValue ( OsuSetting . AndroidStartupFrameSyncV2MigrationApplied , true ) ;
1972+ }
1973+
18781974 return ;
18791975 }
18801976
1881- var frameSync = frameworkConfig . GetBindable < FrameSync > ( FrameworkSetting . FrameSync ) ;
1977+ var frameSyncV1 = frameworkConfig . GetBindable < FrameSync > ( FrameworkSetting . FrameSync ) ;
18821978
18831979 // Only override the framework default. If the user has already explicitly
18841980 // chosen a different mode (Unlimited / VSync / Custom), respect that —
18851981 // the migration's job is to nudge the *default*, not to overwrite intent.
1886- if ( frameSync . Value == FrameSync . Limit2x )
1982+ if ( frameSyncV1 . Value == FrameSync . Limit2x )
18871983 {
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 ) ;
1984+ frameSyncV1 . Value = FrameSync . ActualUnlimited ;
1985+ Logger . Log ( "[osu!] Android first-launch FrameSync migration: Limit2x → ActualUnlimited (IMMEDIATE present, no vblank stall )" , LoggingTarget . Performance ) ;
18901986 }
18911987
18921988 LocalConfig . SetValue ( OsuSetting . AndroidStartupFrameSyncMigrationApplied , true ) ;
1989+ LocalConfig . SetValue ( OsuSetting . AndroidStartupFrameSyncV2MigrationApplied , true ) ;
18931990 }
18941991 catch ( Exception e )
18951992 {
@@ -2342,6 +2439,22 @@ protected override void Dispose(bool isDisposing)
23422439 dexPerformanceSession ? . Dispose ( ) ;
23432440 dexPerformanceSession = null ;
23442441
2442+ // Close ADPF hint sessions for game threads.
2443+ try
2444+ {
2445+ if ( adpfDrawSession != IntPtr . Zero )
2446+ {
2447+ OboeAudioBridge . nADPFCloseSession ( adpfDrawSession ) ;
2448+ adpfDrawSession = IntPtr . Zero ;
2449+ }
2450+ if ( adpfUpdateSession != IntPtr . Zero )
2451+ {
2452+ OboeAudioBridge . nADPFCloseSession ( adpfUpdateSession ) ;
2453+ adpfUpdateSession = IntPtr . Zero ;
2454+ }
2455+ }
2456+ catch { }
2457+
23452458 var cst = System . Threading . Interlocked . Exchange ( ref coldStartTamingTimer , null ) ;
23462459 try { cst ? . Dispose ( ) ; }
23472460 catch { /* ignore */ }
0 commit comments