Skip to content

Commit a185033

Browse files
authored
Merge pull request #272 from winnerspiros/copilot/fix-s-pen-issue-and-review-logs
Fix S Pen "stuck top-left" via current-window-metrics + orientation refresh; honest DeX README
2 parents 32917eb + cd54000 commit a185033

16 files changed

Lines changed: 454 additions & 252 deletions

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,22 @@ Turn it on and the game squeezes every bit of performance from your hardware:
9898
The game queries your screen's supported modes and picks the highest refresh rate automatically:
9999

100100
- Supports 60 / 90 / 120 / 144 / 165 Hz panels (and beyond)
101-
- Tells the Android compositor your target frame rate for optimal scheduling
102-
- On Samsung DeX, it finds and uses the external display's best mode
101+
- Tells the Android compositor your target frame rate for optimal scheduling (`Surface.SetFrameRate` with the *seamless-only* flag, so a mode change can never destroy the active Vulkan swapchain mid-frame)
102+
- On Samsung DeX, queries the active (external) display's mode list via `Activity.Display` / `DisplayManager`
103103

104104
---
105105

106106
### 🖥️ Samsung DeX
107107

108108
Plug your phone into a monitor and play on the big screen:
109109

110-
- **Auto-detected** — the game knows when you're in DeX mode
111-
- Performance mode and immersive fullscreen turn on automatically
112-
- External display refresh rate is detected and applied
113-
- Keyboard + mouse input works seamlessly (no extra setup)
114-
- The game stays alive during display transitions (no restart)
110+
- **Auto-detected** via `UiMode.TypeDesk` — the game knows when you're in DeX mode
111+
- Performance mode and immersive fullscreen turn on automatically when DeX is connected
112+
- The active display's mode list is enumerated and the highest refresh rate is **requested** via `WindowManagerLayoutParams.PreferredDisplayModeId` (see honest caveat below)
113+
- Keyboard + mouse input works because of the dedicated `AndroidMouseHandler` / `AndroidKeyboardHandler` (those handlers aren't DeX-specific — they apply equally to USB peripherals on a phone)
114+
- The Activity is annotated with `ConfigurationChanges = …UiMode|ScreenSize|ScreenLayout|Density|…` so the game stays alive across DeX dock/undock without a full process restart
115+
116+
> **Honest caveat:** Samsung DeX runs your activity inside a virtual display that is fully managed by Samsung's compositor. From a regular (non-system) app, **there is no public Android API to enumerate the physical monitor's true EDID mode list or to force a specific resolution/refresh rate on the HDMI link** — the only public hook is `WindowManagerLayoutParams.PreferredDisplayModeId`, which DeX treats as a hint and frequently ignores. The mode list returned by `display.GetSupportedModes()` for the DeX virtual display is whatever DeX itself chooses to advertise, not the monitor's full mode list. Resolution change is not attempted (no public API exists). The DeX-mode fast-path in this fork is therefore: detect DeX, flip performance + immersive toggles, ask the compositor nicely for the highest refresh rate it will admit to. Anything beyond that requires the vendor-private `com.samsung.android.dex.SDexManager`, which is signature-protected and not callable from a normal app.
115117
116118
---
117119

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.423.1" />
102+
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.427.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/AndroidNativeBridgeManager.cs

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ internal sealed class AndroidNativeBridgeManager : IDisposable
2323
private readonly object oboeLock = new object();
2424

2525
[MethodImpl(MethodImplOptions.NoInlining)]
26-
public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasured, IntPtr provider, int sampleRate = 0, Action<int>? onStarted = null)
26+
public void StartOboeBridge(IntPtr provider, int sampleRate = 0, Action<int>? onStarted = null)
2727
{
2828
lock (oboeLock)
2929
{
@@ -75,16 +75,19 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
7575

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

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);
78+
// No automatic hardware-latency measurement on startup.
79+
//
80+
// The previous implementation polled the bridge for ~2 s after every
81+
// cold start and silently overwrote the user's AudioOffset with the
82+
// first positive AAudio reading. That fought the user's manual offset
83+
// tweaking (especially on devices where AAudio's reported latency
84+
// disagrees with their perception by tens of milliseconds) and was
85+
// observable as a "jittering" offset they couldn't pin down.
86+
//
87+
// Hardware-latency measurement is now exclusively user-triggered via
88+
// the "Resync hardware audio offset" button in Settings → Audio →
89+
// Android, which kicks off a 2-second sampling window and applies the
90+
// median of the readings. See ResyncHardwareAudioOffset below.
8891
}
8992
else
9093
{
@@ -107,16 +110,56 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
107110
private ScheduledDelegate? hardwareLatencyDelegate;
108111

109112
/// <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+
/// Whether a measurement window is currently active. Exposed so the public
114+
/// <see cref="ResyncHardwareAudioOffset"/> entry point can no-op (rather than
115+
/// queuing or interrupting) repeated clicks within the 2-second window — matching
116+
/// the user-facing contract that "you can click it as many times as you like, just
117+
/// not within these 2 seconds".
113118
/// </summary>
114-
private void scheduleHardwareLatencyMeasurement(Scheduler scheduler, Action<double> onLatencyMeasured)
119+
public bool IsMeasuringHardwareLatency => hardwareLatencyDelegate != null;
120+
121+
/// <summary>
122+
/// Public hook for the user-facing "Resync hardware audio offset" button. Polls the
123+
/// AAudio-reported output latency every <c>sample_interval_ms</c> for a fixed
124+
/// <c>window_ms</c> measurement window, drops the very first reading (warm-up
125+
/// transient), and applies the MEDIAN of the remaining positive readings via
126+
/// <paramref name="onLatencyMeasured"/>. Median is robust against the occasional
127+
/// outlier AAudio reports right after a presentation glitch — strictly better than
128+
/// the previous "first positive reading wins" policy.
129+
///
130+
/// <para>Repeated clicks while a window is in flight are ignored (logged) so users
131+
/// can mash the button without producing partial measurements.</para>
132+
///
133+
/// <para>If the Oboe bridge isn't active or no positive readings arrive in the
134+
/// window, the callback is not invoked and the previous offset is left in place.</para>
135+
/// </summary>
136+
public void ResyncHardwareAudioOffset(Scheduler scheduler, Action<double> onLatencyMeasured)
115137
{
116-
hardwareLatencyDelegate?.Cancel();
138+
if (oboeBridge is not OboeAudioBridge)
139+
{
140+
Logger.Log("[osu!] Resync requested but Oboe bridge is not active — enable low-latency audio first.", level: LogLevel.Important);
141+
return;
142+
}
117143

118-
const int interval_ms = 250;
119-
const int budget_ticks = 8; // 8 × 250ms = 2 s
144+
if (hardwareLatencyDelegate != null)
145+
{
146+
Logger.Log("[osu!] Resync ignored — a measurement is already in progress (wait ~2s).", level: LogLevel.Important);
147+
return;
148+
}
149+
150+
Logger.Log("[osu!] Hardware audio offset: starting 2 s measurement window.");
151+
152+
const int sample_interval_ms = 150;
153+
const int window_ms = 2000;
154+
const int max_samples = window_ms / sample_interval_ms; // ~13
155+
156+
// Fixed-size buffer rather than List<double>: max_samples is known at
157+
// compile time, so the List's heap-allocated backing T[] + per-Add
158+
// bounds-check / count-bump is wasted work for a 13-element buffer
159+
// measured once per user click. The whole resync now allocates
160+
// exactly one double[13] (vs List<double> + the wrapped double[]).
161+
double[] samples = new double[max_samples];
162+
int samplesCount = 0;
120163
int ticks = 0;
121164

122165
ScheduledDelegate? handle = null;
@@ -125,49 +168,46 @@ private void scheduleHardwareLatencyMeasurement(Scheduler scheduler, Action<doub
125168
if (oboeBridge is not OboeAudioBridge b)
126169
{
127170
handle?.Cancel();
171+
hardwareLatencyDelegate = null;
128172
return;
129173
}
130174

131175
double latency = b.GetOutputLatencyMs();
132176
ticks++;
133177

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-
}
178+
// Drop the very first reading: AAudio's getTimestamp() needs a few hundred
179+
// milliseconds of pulled frames before its reported presentation latency
180+
// stabilises, and the warm-up sample tends to be biased high.
181+
if (ticks > 1 && latency > 0 && samplesCount < samples.Length)
182+
samples[samplesCount++] = latency;
142183

143-
if (ticks >= budget_ticks)
184+
if (ticks * sample_interval_ms >= window_ms)
144185
{
145-
Logger.Log("[osu!] Hardware audio latency unavailable after 2 s — leaving audio offset unchanged.", level: LogLevel.Important);
146186
handle?.Cancel();
187+
hardwareLatencyDelegate = null;
188+
189+
if (samplesCount == 0)
190+
{
191+
Logger.Log("[osu!] Hardware audio latency unavailable after 2 s — leaving audio offset unchanged.", level: LogLevel.Important);
192+
return;
193+
}
194+
195+
Array.Sort(samples, 0, samplesCount);
196+
double median = samplesCount % 2 == 1
197+
? samples[samplesCount / 2]
198+
: 0.5 * (samples[samplesCount / 2 - 1] + samples[samplesCount / 2]);
199+
200+
Logger.Log($"[osu!] Hardware audio latency measured: median={median:F1} ms (n={samplesCount}, range=[{samples[0]:F1}, {samples[samplesCount - 1]:F1}] ms)");
201+
202+
try { onLatencyMeasured(median); }
203+
catch (Exception ex) { Logger.Log($"[osu!] Hardware-latency callback failed: {ex.Message}", level: LogLevel.Error); }
147204
}
148-
}, interval_ms, interval_ms);
205+
}, sample_interval_ms, sample_interval_ms);
149206

150207
hardwareLatencyDelegate = handle;
151208
scheduler.Add(handle);
152209
}
153210

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-
171211
[MethodImpl(MethodImplOptions.NoInlining)]
172212
public void StopOboeBridge()
173213
{
@@ -297,10 +337,7 @@ public string GetVulkanStatus()
297337
int major = (ver >> 22) & 0x3FF;
298338
int minor = (ver >> 12) & 0x3FF;
299339

300-
cachedVulkanStatus = $"Vk{major}.{minor}"
301-
+ (probe.DisablePresentId ? " [NoID]" : "")
302-
+ (probe.DisablePresentWait ? " [NoWait]" : "")
303-
+ (probe.DisableGraphicsPipelineLibrary ? " [NoGPL]" : "");
340+
cachedVulkanStatus = $"Vk{major}.{minor}";
304341
return cachedVulkanStatus;
305342
}
306343

0 commit comments

Comments
 (0)