Skip to content

Commit 198bfbc

Browse files
authored
Merge pull request #299 from winnerspiros/copilot/fix-vulkan-texture-artifacts
Android: bump framework to 2026.504.1; wire ADPF per-frame reporting via GameThread.FrameCompleted
2 parents 987395f + 223ce47 commit 198bfbc

5 files changed

Lines changed: 188 additions & 15 deletions

File tree

osu.Android.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
</PropertyGroup>
100100

101101
<ItemGroup>
102-
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.503.7" />
102+
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.504.1" />
103103
<!-- `ppy.osu.Framework.NativeLibs` is a transitive dependency of `ppy.osu.Framework`
104104
that ships desktop-only natives (Linux/macOS/Windows) under `runtimes/<rid>/native/`
105105
— including a bare Linux `libbass.so`/`libbass_fx.so`/`libbassmix.so` for linux-arm64.

osu.Android/OsuGameAndroid.cs

Lines changed: 181 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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 &gt; Graphics &gt; 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 */ }

osu.Game/Configuration/OsuConfigManager.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ protected override void InitialiseDefaults()
274274
SetDefault(OsuSetting.AndroidLowLatencyAudio, true);
275275
SetDefault(OsuSetting.AndroidVulkanProbe, false);
276276
SetDefault(OsuSetting.AndroidStartupFrameSyncMigrationApplied, false);
277+
SetDefault(OsuSetting.AndroidStartupFrameSyncV2MigrationApplied, false);
277278

278279
// --- Android startup-safety toggles ---
279280
//
@@ -304,7 +305,7 @@ protected override void InitialiseDefaults()
304305
SetDefault(OsuSetting.AndroidVerboseLogging, false);
305306
SetDefault(OsuSetting.AndroidStylusAsTouch, false);
306307
SetDefault(OsuSetting.AndroidStylusDisableClick, false);
307-
SetDefault(OsuSetting.AndroidStylusPressureThreshold, 0.01f);
308+
SetDefault(OsuSetting.AndroidStylusPressureThreshold, 0.01f, 0.01f, 0.9f, 0.005f);
308309
SetDefault(OsuSetting.ShowFpsAdditionalInfo, false);
309310
}
310311

@@ -576,6 +577,7 @@ public enum OsuSetting
576577
AndroidLowLatencyAudio,
577578
AndroidVulkanProbe,
578579
AndroidStartupFrameSyncMigrationApplied,
580+
AndroidStartupFrameSyncV2MigrationApplied,
579581
AndroidCleanupStaleRealmFifos,
580582
AndroidDeferStartupNativeInit,
581583
AndroidStartupFrameSyncMigrationEnabled,

osu.Game/osu.Game.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@
3838
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
3939
</PackageReference>
4040
<PackageReference Include="Realm" Version="20.1.0" />
41-
<PackageReference Include="ppy.osu.Framework" Version="2026.503.7" />
41+
<PackageReference Include="ppy.osu.Framework" Version="2026.504.1" />
4242
<!--
4343
Explicitly pin `ppy.Veldrid.SPIRV` to the winnerspiros fork build that
44-
`ppy.osu.Framework 2026.503.7` was compiled against. This version is the only
44+
`ppy.osu.Framework 2026.504.1` was compiled against. This version is the only
4545
one whose `runtimes/android-arm64/native/libveldrid-spirv.so` is aligned to 16 KB
4646
pages (required by Android 16+). It lives only as a release asset on
4747
<https://github.com/winnerspiros/veldrid-spirv/releases/tag/1.0> and is vendored

osu.iOS.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@
3333
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
3434
</PropertyGroup>
3535
<ItemGroup>
36-
<PackageReference Include="ppy.osu.Framework.iOS" Version="2026.503.7" />
36+
<PackageReference Include="ppy.osu.Framework.iOS" Version="2026.504.1" />
3737
</ItemGroup>
3838
</Project>

0 commit comments

Comments
 (0)