Skip to content

Commit 4c9e66c

Browse files
authored
Merge pull request #300 from winnerspiros/copilot/fix-vulkan-issues-android
Android: hardware offset accuracy + undo; Vulkan investigation follow-up; Draw/Update unlimited validation
2 parents 26ea953 + 91cf99f commit 4c9e66c

16 files changed

Lines changed: 459 additions & 34 deletions

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.504.1" />
102+
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.504.2" />
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/AndroidManifest.xml

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,86 @@
1414
<provider android:name="androidx.core.content.FileProvider" android:authorities="sh.ppy.osulazer.fileprovider" android:grantUriPermissions="true" android:exported="false">
1515
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" />
1616
</provider>
17+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
18+
Samsung (One UI / Galaxy / DeX)
19+
─────────────────────────────────────────────────────────────────────────────────
20+
keepalive.density — keep app alive across density changes (DeX connect/disconnect).
21+
multidisplay.* — keep process alive when switching between phone and DeX display.
22+
game.biz — register with Game Launcher / Game Booster for CPU+GPU boost,
23+
thermal management, and display-refresh priority on phones/tablets.
24+
dex.game — register with the DeX Game Engine (One UI 5+): applies the same
25+
CPU/GPU boost pipeline as Game Booster but on the DeX/external-
26+
monitor path, where the Game Launcher overlay does not appear. -->
1727
<meta-data android:name="com.samsung.android.keepalive.density" android:value="true" />
1828
<meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true" />
19-
<!-- Register as a game for Samsung Game Launcher / Game Booster.
20-
This enables vendor-specific optimizations (performance boost, thermal management,
21-
display refresh rate priority) on Samsung devices automatically. -->
2229
<meta-data android:name="com.samsung.android.game.biz" android:value="true" />
23-
<!-- Android 13+ Game Mode opt-out (https://developer.android.com/games/gamemode/gamemode-api).
24-
We explicitly disable Battery- and Performance-mode interventions, FPS override, and
25-
downscaling. A rhythm game's input-to-audio latency budget is hard-bound by the
26-
active refresh rate and the audio burst cadence — letting the OS Game Manager cap
27-
our framerate (typical "battery saver" downscale: 60Hz → 30Hz) or downscale our
28-
render resolution (typical "battery saver" downscale: 100% → 75%) breaks both
29-
timing accuracy AND visual hit-circle alignment. We already manage thermals, refresh
30-
rate, and sustained performance ourselves (see OsuGameAndroid.LoadComplete). -->
30+
<meta-data android:name="com.samsung.android.dex.game" android:value="true" />
31+
32+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
33+
Xiaomi / Redmi / POCO (MIUI / HyperOS Game Turbo)
34+
─────────────────────────────────────────────────────────────────────────────────
35+
xiaomi.game.engine — Game Turbo main registration; activates CPU/GPU boost,
36+
network QoS priority, and touch-sampling boost.
37+
miui.intent.category.GAME — legacy MIUI category used by older Game Turbo builds
38+
(MIUI 12 and below) to identify the package as a game. -->
39+
<meta-data android:name="xiaomi.game.engine" android:value="true" />
40+
<meta-data android:name="miui.intent.category.GAME" android:value="true" />
41+
42+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
43+
Vivo / iQOO (FunTouch OS / OriginOS Game Space)
44+
─────────────────────────────────────────────────────────────────────────────────
45+
vivo.game.mode — registers with Game Space for performance mode, touch boost,
46+
and background-process suspension during gameplay. -->
47+
<meta-data android:name="vivo.game.mode" android:value="true" />
48+
49+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
50+
Oppo / OnePlus / Realme (ColorOS / OxygenOS / realme UI — all OPPO-derived)
51+
─────────────────────────────────────────────────────────────────────────────────
52+
oppo.intent.category.GAME — ColorOS / OxygenOS 14+ Game Space registration;
53+
enables CPU boost, network priority, and Do-Not-Disturb.
54+
com.oplus.games — supplementary key read by newer ColorOS Game Driver. -->
55+
<meta-data android:name="oppo.intent.category.GAME" android:value="true" />
56+
<meta-data android:name="com.oplus.games" android:value="true" />
57+
58+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
59+
Honor (MagicOS — independent of Huawei since 2020)
60+
─────────────────────────────────────────────────────────────────────────────────
61+
com.hihonor.intent.category.GAME — MagicOS Game Space registration;
62+
activates GPU Turbo and performance scheduling. -->
63+
<meta-data android:name="com.hihonor.intent.category.GAME" android:value="true" />
64+
65+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
66+
Huawei (EMUI / HarmonyOS — devices with Google Play)
67+
─────────────────────────────────────────────────────────────────────────────────
68+
com.huawei.gamebox — Game Assistant registration; activates GPU Turbo,
69+
touch-sampling boost, and background-process suspension. -->
70+
<meta-data android:name="com.huawei.gamebox" android:value="true" />
71+
72+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
73+
Asus (ROG Phone / Zenfone — ROG UI / ZenUI)
74+
─────────────────────────────────────────────────────────────────────────────────
75+
asus.intent.category.GAME — Armoury Crate / Game Genie registration;
76+
activates X-Mode CPU/GPU boost and network priority. -->
77+
<meta-data android:name="asus.intent.category.GAME" android:value="true" />
78+
79+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
80+
Lenovo / Legion (Legion OS / ZUI)
81+
─────────────────────────────────────────────────────────────────────────────────
82+
com.lenovo.gameassistant — Legion Game Assistant registration;
83+
activates CPU/GPU performance mode and shoulder-trigger
84+
mappings on Legion Phone hardware. -->
85+
<meta-data android:name="com.lenovo.gameassistant" android:value="true" />
86+
87+
<!-- ═══════════════════════════════════════════════════════════════════════════════════
88+
Android 13+ AOSP Game Mode API (opt-out of OS interventions)
89+
─────────────────────────────────────────────────────────────────────────────────
90+
We explicitly disable Battery- and Performance-mode interventions, FPS override,
91+
and downscaling. A rhythm game's input-to-audio latency budget is hard-bound by
92+
the active refresh rate and the audio burst cadence — letting the OS Game Manager
93+
cap our framerate (typical "battery saver" downscale: 60 Hz → 30 Hz) or downscale
94+
our render resolution breaks both timing accuracy AND visual hit-circle alignment.
95+
We already manage thermals, refresh rate, and sustained performance ourselves
96+
(see OsuGameAndroid.LoadComplete). -->
3197
<meta-data android:name="android.game_mode_config" android:resource="@xml/gamemode_config" />
3298
</application>
3399
</manifest>

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ public void StartOboeBridge(IntPtr provider, int sampleRate = 0, Action<int>? on
122122
/// <summary>
123123
/// Public hook for the user-facing "Resync hardware audio offset" button. Polls the
124124
/// AAudio-reported output latency every <c>sample_interval_ms</c> for a fixed
125-
/// <c>window_ms</c> measurement window, drops the very first reading (warm-up
126-
/// transient), and applies the MEDIAN of the remaining positive readings via
125+
/// <c>window_ms</c> measurement window, skips the first two ticks (300 ms warm-up
126+
/// no samples are collected during this period), and applies the MEDIAN of the remainingpositive readings via
127127
/// <paramref name="onLatencyMeasured"/>. Median is robust against the occasional
128128
/// outlier AAudio reports right after a presentation glitch — strictly better than
129129
/// the previous "first positive reading wins" policy.
@@ -154,6 +154,14 @@ public void ResyncHardwareAudioOffset(Scheduler scheduler, Action<double> onLate
154154
const int window_ms = 2000;
155155
const int max_samples = window_ms / sample_interval_ms; // ~13
156156

157+
// Warm-up ticks to drop before collecting samples.
158+
// AAudio's getTimestamp() requires the hardware pipeline to be full before
159+
// the presentation latency stabilises — typically 200–400 ms of real audio
160+
// output. Two ticks (= 300 ms) is a conservative but safe boundary.
161+
// Previously only 1 tick (150 ms) was dropped, which could admit a biased
162+
// warm-up reading on devices with longer pipeline fill times.
163+
const int warmup_ticks = 2;
164+
157165
// Fixed-size buffer rather than List<double>: max_samples is known at
158166
// compile time, so the List's heap-allocated backing T[] + per-Add
159167
// bounds-check / count-bump is wasted work for a 13-element buffer
@@ -173,13 +181,17 @@ public void ResyncHardwareAudioOffset(Scheduler scheduler, Action<double> onLate
173181
return;
174182
}
175183

176-
double latency = b.GetOutputLatencyMs();
184+
// GetInstantLatencyMs() calls calculateLatencyMillis() directly on the
185+
// stream rather than reading the cached latencyMs_ value. The cache is
186+
// only refreshed every ~1 s from onAudioReady, so in a 2 s window most
187+
// of the 13 GetOutputLatencyMs() calls were reading the same stale value.
188+
// GetInstantLatencyMs() gives a fresh reading at every 150 ms poll.
189+
double latency = b.GetInstantLatencyMs();
177190
ticks++;
178191

179-
// Drop the very first reading: AAudio's getTimestamp() needs a few hundred
180-
// milliseconds of pulled frames before its reported presentation latency
181-
// stabilises, and the warm-up sample tends to be biased high.
182-
if (ticks > 1 && latency > 0 && samplesCount < samples.Length)
192+
// Drop the first warmup_ticks readings — AAudio needs time to fill its
193+
// hardware pipeline before getTimestamp() reports stable values.
194+
if (ticks > warmup_ticks && latency > 0 && samplesCount < samples.Length)
183195
samples[samplesCount++] = latency;
184196

185197
if (ticks * sample_interval_ms >= window_ms)
@@ -198,7 +210,16 @@ public void ResyncHardwareAudioOffset(Scheduler scheduler, Action<double> onLate
198210
? samples[samplesCount / 2]
199211
: 0.5 * (samples[samplesCount / 2 - 1] + samples[samplesCount / 2]);
200212

201-
Logger.Log($"[osu!] Hardware audio latency measured: median={median:F1} ms (n={samplesCount}, range=[{samples[0]:F1}, {samples[samplesCount - 1]:F1}] ms)");
213+
// Compute standard deviation for diagnostic logging (no heap alloc —
214+
// plain loops over the stack-allocated samples span).
215+
double sum = 0;
216+
for (int i = 0; i < samplesCount; i++) sum += samples[i];
217+
double mean = sum / samplesCount;
218+
double variance = 0;
219+
for (int i = 0; i < samplesCount; i++) variance += (samples[i] - mean) * (samples[i] - mean);
220+
double stdDev = Math.Sqrt(variance / samplesCount);
221+
222+
Logger.Log($"[osu!] Hardware audio latency measured: median={median:F1} ms (n={samplesCount}, σ={stdDev:F1} ms, range=[{samples[0]:F1}, {samples[samplesCount - 1]:F1}] ms)");
202223

203224
try { onLatencyMeasured(median); }
204225
catch (Exception ex) { Logger.Log($"[osu!] Hardware-latency callback failed: {ex.Message}", level: LogLevel.Error); }

osu.Android/Native/OboeAudioBridge.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ public double GetOutputLatencyMs()
100100
catch { return -1; }
101101
}
102102

103+
/// <summary>
104+
/// Returns a fresh hardware-latency reading by calling calculateLatencyMillis()
105+
/// directly on the stream, bypassing the cached value that onAudioReady only
106+
/// refreshes every ~1 second. Use this for measurement loops that need
107+
/// accurate per-poll readings.
108+
/// </summary>
109+
public double GetInstantLatencyMs()
110+
{
111+
if (disposed || nativePtr == IntPtr.Zero) return -1;
112+
try { return nOboeGetInstantLatencyMs(nativePtr); }
113+
catch { return -1; }
114+
}
115+
103116
public string GetLastErrorMessage()
104117
{
105118
if (disposed || nativePtr == IntPtr.Zero) return "Not initialized";
@@ -201,6 +214,7 @@ public void Dispose()
201214
[DllImport(lib_name)] private static extern byte nOboeStart(IntPtr ptr);
202215
[DllImport(lib_name)] private static extern void nOboeStop(IntPtr ptr);
203216
[DllImport(lib_name)] private static extern double nOboeGetLatencyMs(IntPtr ptr);
217+
[DllImport(lib_name)] private static extern double nOboeGetInstantLatencyMs(IntPtr ptr);
204218
[DllImport(lib_name)] private static extern byte nOboeIsActive(IntPtr ptr);
205219
[DllImport(lib_name)] private static extern int nOboeGetSampleRate(IntPtr ptr);
206220
[DllImport(lib_name)] private static extern int nOboeGetFramesPerBurst(IntPtr ptr);

osu.Android/Native/oboe_bridge.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,17 @@ double OboeBridge::getOutputLatencyMs() const {
239239
return latencyMs_.load();
240240
}
241241

242+
double OboeBridge::getInstantLatencyMs() const {
243+
std::lock_guard<std::mutex> lock(streamLock_);
244+
if (!stream_) return -1.0;
245+
// calculateLatencyMillis() is documented as thread-safe in Oboe and is
246+
// backed by AAudioStream_getTimestamp(). Calling it here (scheduler thread,
247+
// outside of onAudioReady) is safe: we hold streamLock_ so stream_ cannot
248+
// be reset underneath us, and the AAudio syscall itself is re-entrant.
249+
auto result = stream_->calculateLatencyMillis();
250+
return result ? result.value() : -1.0;
251+
}
252+
242253
bool OboeBridge::isActive() const {
243254
return active_.load();
244255
}
@@ -487,6 +498,11 @@ OSU_EXPORT double nOboeGetLatencyMs(intptr_t ptr) {
487498
return bridge ? bridge->getOutputLatencyMs() : -1.0;
488499
}
489500

501+
OSU_EXPORT double nOboeGetInstantLatencyMs(intptr_t ptr) {
502+
auto* bridge = reinterpret_cast<OboeBridge*>(ptr);
503+
return bridge ? bridge->getInstantLatencyMs() : -1.0;
504+
}
505+
490506
OSU_EXPORT byte nOboeIsActive(intptr_t ptr) {
491507
auto* bridge = reinterpret_cast<OboeBridge*>(ptr);
492508
return (bridge && bridge->isActive()) ? 1 : 0;

osu.Android/Native/oboe_bridge.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ class OboeBridge : public oboe::AudioStreamCallback {
2626
void stop();
2727

2828
double getOutputLatencyMs() const;
29+
/// Calls calculateLatencyMillis() directly on the stream (thread-safe per
30+
/// Oboe docs) and returns the result. Unlike getOutputLatencyMs() this is
31+
/// NOT a cached value, so callers always receive a fresh measurement.
32+
/// Returns -1.0 if the stream is not open or the query fails.
33+
double getInstantLatencyMs() const;
2934
bool isActive() const;
3035
int32_t getSampleRate() const;
3136
int32_t getFramesPerBurst() const;

0 commit comments

Comments
 (0)