Skip to content

Commit e1f307c

Browse files
authored
Merge pull request #268 from winnerspiros/copilot/fix-app-navigation-and-performance
Android: fix back-key minimise, mouse jitter, S Pen misclicks; reorganise audio/debug settings; one-shot hardware audio offset; FPS additional-info line
2 parents 7a16314 + 528763b commit e1f307c

14 files changed

Lines changed: 452 additions & 99 deletions

File tree

osu.Android.props

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,46 @@
2525
</PropertyGroup>
2626

2727
<!-- Release-only optimisations.
28-
Trimming and AOT were previously enabled here for size/startup, but caused a
29-
reproducible "splash → black screen → crash, no logs" failure on real devices.
30-
Root cause: PublishTrimmed + TrimMode=partial + AndroidEnableProfiledAot, with
31-
SuppressTrimAnalysisWarnings=true masking the build-time signal. Only Microsoft.CSharp
32-
and ppy.Veldrid.SPIRV were rooted — osu.Game, the rulesets, Realm, Newtonsoft.Json,
33-
AutoMapper, Sentry, OsuTK and other reflection-heavy assemblies were not. A trimmed
34-
method/type or an un-AOT'd profiled-AOT call site throws TypeLoadException /
35-
MissingMethodException early in OsuGame construction, before the file logger is open;
36-
the process dies on the SDLThread / .NET ThreadPool thread with only a tombstone, so
37-
the user gets no osu.log and Logcat shows a bare native crash.
38-
Upstream ppy/osu's osu.Android.props enables NEITHER trimming NOR AOT — we now match
39-
that baseline. The remaining release-only knobs (assembly compression, no PDBs) are
40-
safe and unrelated to the startup crash. To re-enable trimming/AOT in the future,
41-
reintroduce them one at a time, root every reflection-using assembly explicitly, and
42-
run with SuppressTrimAnalysisWarnings=false to surface the gaps before shipping. -->
28+
History:
29+
Trimming + AOT were once enabled together (PublishTrimmed + TrimMode=partial +
30+
AndroidEnableProfiledAot, with SuppressTrimAnalysisWarnings=true masking the
31+
build-time signal). That combination caused a reproducible "splash → black screen
32+
→ crash, no logs" failure: only Microsoft.CSharp and ppy.Veldrid.SPIRV were rooted,
33+
while osu.Game, the rulesets, Realm, Newtonsoft.Json, AutoMapper, Sentry, OsuTK
34+
and other reflection-heavy assemblies were not. A trimmed method/type or an
35+
un-AOT'd profiled-AOT call site threw TypeLoadException / MissingMethodException
36+
early in OsuGame construction, before the file logger was open; the process died
37+
on SDLThread / .NET ThreadPool with only a tombstone, so the user got no osu.log
38+
and Logcat showed a bare native crash.
39+
40+
Current policy:
41+
* AndroidEnableProfiledAot=true — re-enabled. The crash root cause was the
42+
trimming side of the combination (untrimmed-but-AOT'd is safe — every method
43+
the profile lists exists in the assembly because nothing was stripped; methods
44+
outside the profile fall back to the JIT and are also still present). This
45+
gives a measurable startup time reduction and cuts steady-state CPU on hot
46+
managed paths (the JIT no longer needs to tier them up at runtime, and Mono's
47+
profiled AOT covers framework/game hot loops including draw-thread Schedulers,
48+
bindable propagation, and the texture upload path).
49+
* PublishTrimmed stays OFF — re-enabling it requires explicitly rooting every
50+
reflection-using assembly (osu.Game, rulesets, Realm, Newtonsoft.Json,
51+
AutoMapper, Sentry, OsuTK, Microsoft.CSharp, ppy.Veldrid.SPIRV …) and running
52+
with SuppressTrimAnalysisWarnings=false to surface gaps before shipping. That
53+
is a separate, larger PR.
54+
* AndroidEnableMarshalMethods stays OFF (see PropertyGroup above) — silent
55+
SIGSEGV with current interop on .NET 10 Android.
56+
* Server GC is enabled for Release: workstation GC's STW pauses on the Update/
57+
Draw threads are a primary source of >1ms frame spikes that defeat our 1ms
58+
frame-time goal. Server GC parallelises mark/sweep across cores and uses
59+
per-core LOH/SOH allocation contexts, dramatically lowering pause time at
60+
the cost of ~20-30% extra resident memory. Mono Android has supported this
61+
since .NET 8; ppy/osu desktop already uses it. Concurrent GC is the .NET
62+
default but we set it explicitly so Server GC runs in the background mode
63+
rather than blocking foreground.
64+
65+
To re-enable trimming in the future, reintroduce it on its own, root every
66+
reflection-using assembly explicitly, and run with SuppressTrimAnalysisWarnings=false
67+
to surface the gaps before shipping. -->
4368
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
4469
<!-- Compress managed assemblies inside the APK (LZ4). Android extracts them on first run
4570
but the download/APK size is significantly smaller. -->
@@ -48,6 +73,16 @@
4873
Stack traces still work via embedded metadata. -->
4974
<DebugType>none</DebugType>
5075
<DebugSymbols>false</DebugSymbols>
76+
<!-- Profiled AOT: pre-compile the hot methods listed in the bundled .NET / Android
77+
profiles. Methods outside the profile remain JIT-compiled at runtime. NO trimming —
78+
every method in every assembly stays present, so a profile-miss falls back to JIT
79+
instead of throwing MissingMethodException like the previous trim+AOT combo did. -->
80+
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
81+
<!-- Concurrent Server GC. Server GC uses per-core allocation contexts and parallel
82+
collections to reduce STW pause time, which is the largest source of >1ms frame
83+
spikes on the Update/Draw threads. Memory usage rises ~20-30% in exchange. -->
84+
<ServerGarbageCollection>true</ServerGarbageCollection>
85+
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
5186
</PropertyGroup>
5287

5388
<ItemGroup>

osu.Android/AndroidManifest.xml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
<?xml version='1.0' encoding='utf-8'?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="sh.ppy.osulazer" android:installLocation="auto">
33
<uses-sdk android:minSdkVersion="33" android:targetSdkVersion="36" />
4-
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:largeHeap="true" android:hardwareAccelerated="true" android:extractNativeLibs="false">
4+
<!--
5+
android:enableOnBackInvokedCallback="false" — opt OUT of the Android predictive back gesture.
6+
With targetSdk=36 the OnBackInvokedDispatcher path is enabled by default, which routes the
7+
system Back gesture through OnBackInvokedCallback INSTEAD of the legacy KeyEvent pipeline.
8+
Our OsuGameActivity.DispatchKeyEvent overrides the legacy path to translate KEYCODE_BACK
9+
into Escape so Back navigates within the game; without this opt-out the gesture never
10+
reaches DispatchKeyEvent and the OS default for root-task activities (moveTaskToBack,
11+
i.e. minimise) takes over instead.
12+
-->
13+
<application android:allowBackup="true" android:supportsRtl="true" android:label="osu!" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:largeHeap="true" android:hardwareAccelerated="true" android:extractNativeLibs="false" android:enableOnBackInvokedCallback="false">
514
<provider android:name="androidx.core.content.FileProvider" android:authorities="sh.ppy.osulazer.fileprovider" android:grantUriPermissions="true" android:exported="false">
615
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/filepaths" />
716
</provider>

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,21 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
7070

7171
if (started)
7272
{
73-
Logger.Log("[osu!] Oboe bridge started successfully");
73+
Logger.Log($"[osu!] Oboe bridge started successfully (api={(bridge.IsAAudio ? "AAudio" : "OpenSLES")}, mmap={bridge.IsMMap}, rate={bridge.SampleRate}Hz, burst={bridge.FramesPerBurst}f, buffer={bridge.BufferSizeInFrames}f)");
7474
logOboeInfo(bridge);
7575

7676
onStarted?.Invoke(bridge.SampleRate);
7777

78-
scheduler.Add(new ScheduledDelegate(() =>
79-
{
80-
if (oboeBridge is not OboeAudioBridge b) return;
81-
82-
double latency = b.GetOutputLatencyMs();
83-
84-
if (latency > 0)
85-
onLatencyMeasured(latency);
86-
}, 2000, 5000));
78+
// One-shot hardware-latency measurement. The native pipeline needs a few
79+
// hundred milliseconds of warm-up before AAudio reports a stable timestamp,
80+
// so we poll every 250 ms for up to ~2 s and apply the FIRST positive
81+
// reading we see. Once applied (or once the budget is exhausted), the
82+
// ScheduledDelegate cancels itself and never fires again. The previous
83+
// implementation used a 5000 ms repeat period, which kept overwriting the
84+
// user's audio offset every 5 s for the entire session — visible to the
85+
// user as a "jittering" / "auto-altering" hardware offset they could not
86+
// pin down. Use ResyncHardwareAudioOffset() for an explicit re-measure.
87+
scheduleHardwareLatencyMeasurement(scheduler, onLatencyMeasured);
8788
}
8889
else
8990
{
@@ -103,12 +104,78 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
103104
}
104105
}
105106

107+
private ScheduledDelegate? hardwareLatencyDelegate;
108+
109+
/// <summary>
110+
/// Schedules a one-shot hardware-latency measurement that polls the bridge every 250 ms
111+
/// for up to ~2 s, applies the first positive reading via <paramref name="onLatencyMeasured"/>,
112+
/// and then cancels itself. Cancels any previously-scheduled measurement.
113+
/// </summary>
114+
private void scheduleHardwareLatencyMeasurement(Scheduler scheduler, Action<double> onLatencyMeasured)
115+
{
116+
hardwareLatencyDelegate?.Cancel();
117+
118+
const int interval_ms = 250;
119+
const int budget_ticks = 8; // 8 × 250ms = 2 s
120+
int ticks = 0;
121+
122+
ScheduledDelegate? handle = null;
123+
handle = new ScheduledDelegate(() =>
124+
{
125+
if (oboeBridge is not OboeAudioBridge b)
126+
{
127+
handle?.Cancel();
128+
return;
129+
}
130+
131+
double latency = b.GetOutputLatencyMs();
132+
ticks++;
133+
134+
if (latency > 0)
135+
{
136+
Logger.Log($"[osu!] Hardware audio latency measured: {latency:F1} ms (after {ticks * interval_ms} ms warm-up)");
137+
try { onLatencyMeasured(latency); }
138+
catch (Exception ex) { Logger.Log($"[osu!] Hardware-latency callback failed: {ex.Message}", level: LogLevel.Error); }
139+
handle?.Cancel();
140+
return;
141+
}
142+
143+
if (ticks >= budget_ticks)
144+
{
145+
Logger.Log("[osu!] Hardware audio latency unavailable after 2 s — leaving audio offset unchanged.", level: LogLevel.Important);
146+
handle?.Cancel();
147+
}
148+
}, interval_ms, interval_ms);
149+
150+
hardwareLatencyDelegate = handle;
151+
scheduler.Add(handle);
152+
}
153+
154+
/// <summary>
155+
/// Public hook for the user-facing "Resync hardware audio offset" button. Re-runs the
156+
/// one-shot measurement if the Oboe bridge is currently active. No-op otherwise.
157+
/// </summary>
158+
public void ResyncHardwareAudioOffset(Scheduler scheduler, Action<double> onLatencyMeasured)
159+
{
160+
if (oboeBridge is OboeAudioBridge)
161+
{
162+
Logger.Log("[osu!] Resyncing hardware audio offset (user request)");
163+
scheduleHardwareLatencyMeasurement(scheduler, onLatencyMeasured);
164+
}
165+
else
166+
{
167+
Logger.Log("[osu!] Resync requested but Oboe bridge is not active — enable low-latency audio first.", level: LogLevel.Important);
168+
}
169+
}
170+
106171
[MethodImpl(MethodImplOptions.NoInlining)]
107172
public void StopOboeBridge()
108173
{
109174
lock (oboeLock)
110175
{
111176
Logger.Log("[osu!] Stopping Oboe bridge...");
177+
hardwareLatencyDelegate?.Cancel();
178+
hardwareLatencyDelegate = null;
112179
(oboeBridge as OboeAudioBridge)?.Dispose();
113180
oboeBridge = null;
114181
cachedOboeStatus = null;
@@ -279,6 +346,9 @@ public void Dispose()
279346
if (disposed) return;
280347
disposed = true;
281348

349+
try { hardwareLatencyDelegate?.Cancel(); } catch { }
350+
hardwareLatencyDelegate = null;
351+
282352
try { (oboeBridge as OboeAudioBridge)?.Dispose(); } catch { }
283353
oboeBridge = null;
284354

osu.Android/Input/AndroidStylusHandler.cs

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
4040
private readonly Bindable<TabletInfo?> tablet = new Bindable<TabletInfo?>();
4141

4242
private bool lastLeftDown;
43-
private bool lastRightDown;
44-
private bool lastEraserDown;
4543

4644
// Cached area values for hot path (avoids bindable access per event).
4745
private float areaLeft, areaTop, areaWidth, areaHeight;
@@ -143,7 +141,6 @@ public bool HandleMotionEvent(MotionEvent e)
143141
if (e.ActionMasked == MotionEventActions.HoverExit || e.ActionMasked == MotionEventActions.Up || e.ActionMasked == MotionEventActions.Cancel)
144142
{
145143
if (lastLeftDown) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Left, false)); lastLeftDown = false; }
146-
if (lastRightDown) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Right, false)); lastRightDown = false; }
147144

148145
if (e.ActionMasked != MotionEventActions.HoverExit)
149146
return true;
@@ -227,21 +224,13 @@ private void handlePointer(MotionEvent e, int historyIndex)
227224
lastLeftDown = isLeftDown;
228225
}
229226

230-
// S Pen button → right click.
231-
bool isRightDown = (buttonState & MotionEventButtonState.StylusPrimary) != 0;
232-
if (isRightDown != lastRightDown)
233-
{
234-
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Right, isRightDown));
235-
lastRightDown = isRightDown;
236-
}
237-
238-
// Eraser → middle click.
239-
bool isEraserDown = (buttonState & MotionEventButtonState.StylusSecondary) != 0 || e.GetToolType(pointer_index) == MotionEventToolType.Eraser;
240-
if (isEraserDown != lastEraserDown)
241-
{
242-
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Middle, isEraserDown));
243-
lastEraserDown = isEraserDown;
244-
}
227+
// S Pen side button and eraser tip are intentionally NOT mapped to right/middle
228+
// mouse buttons. On Samsung devices a stray button-bit on a normal tap was
229+
// synthesizing a right-click, which opened in-game context overlays at whatever
230+
// position the desktop-style mouse cursor was last at (often (0,0) — the
231+
// "stuck top-left options" the user reported). Pressure-only left-click is the
232+
// expected pen-as-pointer behaviour and matches how the framework handles
233+
// graphics-tablet styli on desktop.
245234
}
246235
}
247236
}

osu.Android/OsuGameActivity.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,9 +394,25 @@ public override bool OnGenericMotionEvent(MotionEvent? e)
394394
return base.OnGenericMotionEvent(e);
395395
}
396396

397+
/// <summary>
398+
/// When true, S Pen / stylus events are routed through the standard touch dispatch
399+
/// pipeline (i.e. treated like a finger) instead of through <see cref="AndroidStylusHandler"/>.
400+
/// Mirrored from <see cref="osu.Game.Configuration.OsuSetting.AndroidStylusAsTouch"/>
401+
/// by <see cref="OsuGameAndroid"/>. Held as a volatile static so the per-event
402+
/// dispatch hot path on the OS dispatch thread can read it without crossing the
403+
/// managed-config bindable lock.
404+
/// </summary>
405+
internal static volatile bool StylusAsTouch;
406+
397407
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
398408
private bool isStylusEvent(MotionEvent e)
399409
{
410+
// User opted to treat S Pen as plain touch input — short-circuit so the
411+
// event flows to base.DispatchTouchEvent (and the framework's SDL touch
412+
// handler) instead of to AndroidStylusHandler.
413+
if (StylusAsTouch)
414+
return false;
415+
400416
// Source flag check is cheapest and short-circuits for the common case.
401417
if ((e.Source & InputSourceType.Stylus) == InputSourceType.Stylus)
402418
return true;

0 commit comments

Comments
 (0)