Skip to content

Commit bad38da

Browse files
Rewrite S Pen handler as true tablet with area mapping, fix thread safety
- S Pen now acts as a real tablet: area mapping, rotation, pressure threshold - Display-based initialization from actual screen dimensions (not 2000x1000) - Cached area transform values for hot-path performance - TabletSettings UI shown on Android via CreateSettingsSubsectionFor override - Thread-safe JNI surface ref (Interlocked.Exchange), error string mutex in C++ - Dynamic audio thread affinity matching OsuGameAndroid pattern Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/62a8048b-6fe1-424d-9c00-a3fbddf6ba74 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 9cf4b0c commit bad38da

6 files changed

Lines changed: 161 additions & 25 deletions

File tree

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,18 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
4646
if (provider != IntPtr.Zero)
4747
bridge.SetProvider(provider);
4848

49-
try { SetThreadAffinity(Environment.ProcessorCount > 4 ? 0xF0 : 0x0C); }
49+
// Calculate dynamic big-core mask for audio thread, matching the pattern in OsuGameAndroid.LoadComplete
50+
int audioAffinityMask;
51+
int cores = Environment.ProcessorCount;
52+
int bigStart = Math.Max(cores / 2, 1);
53+
audioAffinityMask = 0;
54+
55+
for (int i = bigStart; i < Math.Min(cores, 32); i++)
56+
audioAffinityMask |= 1 << i;
57+
58+
if (audioAffinityMask == 0) audioAffinityMask = (1 << cores) - 1;
59+
60+
try { SetThreadAffinity(audioAffinityMask); }
5061
catch (Exception e) { Debug.WriteLine($"[osu!] Audio thread affinity failed: {e.Message}"); }
5162

5263
bool started = bridge.Start();

osu.Android/Input/AndroidStylusHandler.cs

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@
1313

1414
namespace osu.Android.Input
1515
{
16+
/// <summary>
17+
/// Handles Samsung S Pen / stylus input as a true tablet device with area mapping.
18+
/// Provides the same coordinate transformation as desktop Wacom tablets:
19+
/// raw digitizer coordinates → area selection → output area on screen.
20+
/// </summary>
1621
public class AndroidStylusHandler : InputHandler, ITabletHandler
1722
{
18-
public override string Description => "S Pen / Stylus (Low Latency)";
23+
public override string Description => "S Pen / Stylus";
1924
public override bool IsActive => Enabled.Value;
2025

2126
public Bindable<Vector2> AreaOffset { get; } = new Bindable<Vector2>();
@@ -28,6 +33,7 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
2833
{
2934
MinValue = 0.01f,
3035
MaxValue = 0.9f,
36+
Precision = 0.01f,
3137
};
3238

3339
private readonly Bindable<TabletInfo?> tablet = new Bindable<TabletInfo?>();
@@ -36,6 +42,11 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
3642
private bool lastRightDown;
3743
private bool lastEraserDown;
3844

45+
// Cached area values for hot path (avoids bindable access per event).
46+
private float areaLeft, areaTop, areaWidth, areaHeight;
47+
private float outLeft, outTop, outWidth, outHeight;
48+
private float rotSin, rotCos;
49+
3950
public AndroidStylusHandler()
4051
{
4152
Enabled.Default = true;
@@ -44,10 +55,70 @@ public AndroidStylusHandler()
4455

4556
public override bool Initialize(GameHost host)
4657
{
47-
tablet.Value = new TabletInfo("S Pen", new Vector2(2000, 1000));
58+
// Default size will be updated by SetDisplaySize once the display metrics are known.
59+
tablet.Value = new TabletInfo("S Pen", new Vector2(1920, 1080));
60+
61+
AreaSize.BindValueChanged(_ => updateCachedTransform());
62+
AreaOffset.BindValueChanged(_ => updateCachedTransform());
63+
OutputAreaSize.BindValueChanged(_ => updateCachedTransform());
64+
OutputAreaOffset.BindValueChanged(_ => updateCachedTransform());
65+
Rotation.BindValueChanged(_ => updateCachedTransform());
66+
4867
return base.Initialize(host);
4968
}
5069

70+
/// <summary>
71+
/// Sets the digitizer/display dimensions. Must be called after the display is known.
72+
/// This sets the full tablet area and default output area.
73+
/// </summary>
74+
public void SetDisplaySize(int width, int height)
75+
{
76+
var size = new Vector2(width, height);
77+
tablet.Value = new TabletInfo("S Pen", size);
78+
79+
// Default: full digitizer area mapped to full screen (1:1 passthrough).
80+
AreaSize.Default = size;
81+
AreaOffset.Default = size / 2;
82+
OutputAreaSize.Default = size;
83+
OutputAreaOffset.Default = size / 2;
84+
85+
// Only set current values if they haven't been configured by the user yet.
86+
if (AreaSize.Value == default || AreaSize.Value == new Vector2(1920, 1080))
87+
{
88+
AreaSize.Value = size;
89+
AreaOffset.Value = size / 2;
90+
}
91+
92+
if (OutputAreaSize.Value == default || OutputAreaSize.Value == new Vector2(1920, 1080))
93+
{
94+
OutputAreaSize.Value = size;
95+
OutputAreaOffset.Value = size / 2;
96+
}
97+
98+
updateCachedTransform();
99+
}
100+
101+
private void updateCachedTransform()
102+
{
103+
var aSize = AreaSize.Value;
104+
var aOff = AreaOffset.Value;
105+
areaLeft = aOff.X - aSize.X / 2;
106+
areaTop = aOff.Y - aSize.Y / 2;
107+
areaWidth = aSize.X;
108+
areaHeight = aSize.Y;
109+
110+
var oSize = OutputAreaSize.Value;
111+
var oOff = OutputAreaOffset.Value;
112+
outLeft = oOff.X - oSize.X / 2;
113+
outTop = oOff.Y - oSize.Y / 2;
114+
outWidth = oSize.X;
115+
outHeight = oSize.Y;
116+
117+
float radians = MathF.PI / 180f * Rotation.Value;
118+
rotSin = MathF.Sin(radians);
119+
rotCos = MathF.Cos(radians);
120+
}
121+
51122
public bool HandleMotionEvent(MotionEvent e)
52123
{
53124
if (!Enabled.Value) return false;
@@ -61,10 +132,10 @@ public bool HandleMotionEvent(MotionEvent e)
61132
return true;
62133
}
63134

135+
// Process all batched historical events for maximum accuracy.
64136
for (int i = 0; i < e.HistorySize; i++)
65-
{
66137
handlePointer(e, i);
67-
}
138+
68139
handlePointer(e, -1);
69140

70141
return true;
@@ -75,19 +146,50 @@ private void handlePointer(MotionEvent e, int historyIndex)
75146
const int pointer_index = 0;
76147
if (e.PointerCount <= pointer_index) return;
77148

78-
float x = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex);
79-
float y = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex);
149+
float rawX = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex);
150+
float rawY = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex);
80151
float pressure = historyIndex < 0 ? e.GetPressure(pointer_index) : e.GetHistoricalPressure(pointer_index, historyIndex);
81152

82-
if (tablet.Value == null || x > tablet.Value.Size.X || y > tablet.Value.Size.Y)
153+
// Auto-expand tablet size if the digitizer reports coordinates beyond current bounds.
154+
if (tablet.Value == null || rawX > tablet.Value.Size.X || rawY > tablet.Value.Size.Y)
83155
{
84156
var currentSize = tablet.Value?.Size ?? Vector2.Zero;
85-
var newSize = new Vector2(Math.Max(x, currentSize.X), Math.Max(y, currentSize.Y));
157+
var newSize = new Vector2(Math.Max(rawX + 1, currentSize.X), Math.Max(rawY + 1, currentSize.Y));
86158
tablet.Value = new TabletInfo("S Pen", newSize);
87159
}
88160

89-
PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(x, y) });
161+
// Apply tablet area → output area coordinate mapping.
162+
float mappedX, mappedY;
163+
164+
if (areaWidth > 0 && areaHeight > 0)
165+
{
166+
// Normalize to [0, 1] within the configured tablet area.
167+
float normX = (rawX - areaLeft) / areaWidth;
168+
float normY = (rawY - areaTop) / areaHeight;
169+
170+
// Apply rotation around center of normalized space.
171+
if (Rotation.Value != 0)
172+
{
173+
float cx = normX - 0.5f;
174+
float cy = normY - 0.5f;
175+
normX = cx * rotCos - cy * rotSin + 0.5f;
176+
normY = cx * rotSin + cy * rotCos + 0.5f;
177+
}
178+
179+
// Map to output area.
180+
mappedX = outLeft + normX * outWidth;
181+
mappedY = outTop + normY * outHeight;
182+
}
183+
else
184+
{
185+
// Fallback: raw passthrough if area is invalid.
186+
mappedX = rawX;
187+
mappedY = rawY;
188+
}
189+
190+
PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(mappedX, mappedY) });
90191

192+
// Button state: pressure-based click (primary) with action overrides.
91193
bool isLeftDown = pressure >= PressureThreshold.Value;
92194
if (e.ActionMasked == MotionEventActions.Down || e.ActionMasked == MotionEventActions.ButtonPress) isLeftDown = true;
93195
else if (e.ActionMasked == MotionEventActions.Up || e.ActionMasked == MotionEventActions.ButtonRelease || e.ActionMasked == MotionEventActions.Cancel) isLeftDown = false;
@@ -99,13 +201,15 @@ private void handlePointer(MotionEvent e, int historyIndex)
99201
lastLeftDown = isLeftDown;
100202
}
101203

204+
// S Pen button → right click.
102205
bool isRightDown = (e.ButtonState & MotionEventButtonState.StylusPrimary) != 0;
103206
if (isRightDown != lastRightDown)
104207
{
105208
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Right, isRightDown));
106209
lastRightDown = isRightDown;
107210
}
108211

212+
// Eraser → middle click.
109213
bool isEraserDown = (e.ButtonState & MotionEventButtonState.StylusSecondary) != 0 || e.GetToolType(pointer_index) == MotionEventToolType.Eraser;
110214
if (isEraserDown != lastEraserDown)
111215
{

osu.Android/Native/oboe_bridge.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ bool OboeBridge::open(int32_t sampleRate) {
6060
if (result != oboe::Result::OK) {
6161
LOGE("AAudio open failed (%s), falling back to unspecified API",
6262
oboe::convertToText(result));
63-
lastError_ = std::string("AAudio: ") + oboe::convertToText(result);
63+
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); }
6464
builder.setAudioApi(oboe::AudioApi::Unspecified);
6565
builder.setSharingMode(oboe::SharingMode::Shared);
6666
result = builder.openStream(stream_);
6767
}
6868

6969
if (result != oboe::Result::OK) {
7070
LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
71-
lastError_ = std::string("Open failed: ") + oboe::convertToText(result);
71+
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("Open failed: ") + oboe::convertToText(result); }
7272
return false;
7373
}
7474

@@ -108,7 +108,7 @@ bool OboeBridge::start() {
108108

109109
if (result != oboe::Result::OK) {
110110
LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
111-
lastError_ = std::string("Start failed: ") + oboe::convertToText(result);
111+
{ std::lock_guard<std::mutex> eLock(errorLock_); lastError_ = std::string("Start failed: ") + oboe::convertToText(result); }
112112
return false;
113113
}
114114

@@ -174,6 +174,7 @@ void OboeBridge::setProvider(OboeAudioProvider provider) {
174174
}
175175

176176
const char* OboeBridge::getLastError() const {
177+
std::lock_guard<std::mutex> lock(errorLock_);
177178
return lastError_.empty() ? nullptr : lastError_.c_str();
178179
}
179180

osu.Android/Native/oboe_bridge.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class OboeBridge : public oboe::AudioStreamCallback {
5555
std::atomic<bool> affinitySet_{false};
5656
int32_t requestedSampleRate_{0};
5757
std::string lastError_;
58+
mutable std::mutex errorLock_;
5859

5960
void updateLatency();
6061
bool reopenAndRestart();

osu.Android/OsuGameActivity.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -337,14 +337,14 @@ public void SurfaceCreated(ISurfaceHolder holder)
337337
IntPtr handle = surface.Handle;
338338
if (handle == IntPtr.Zero) return;
339339

340-
// Release previous reference to prevent JNI global reference leak on rapid surface recreation.
341-
if (surfaceGlobalRef != IntPtr.Zero)
342-
{
343-
global::Android.Runtime.JNIEnv.DeleteGlobalRef(surfaceGlobalRef);
344-
surfaceGlobalRef = IntPtr.Zero;
345-
}
340+
IntPtr newRef = global::Android.Runtime.JNIEnv.NewGlobalRef(handle);
341+
342+
// Atomically swap the old reference to prevent race with SurfaceDestroyed.
343+
IntPtr oldRef = System.Threading.Interlocked.Exchange(ref surfaceGlobalRef, newRef);
344+
345+
if (oldRef != IntPtr.Zero)
346+
global::Android.Runtime.JNIEnv.DeleteGlobalRef(oldRef);
346347

347-
surfaceGlobalRef = global::Android.Runtime.JNIEnv.NewGlobalRef(handle);
348348
surfaceEvent.Set();
349349
Debug.WriteLine("[osu!] Native surface JNI global reference created");
350350
}
@@ -356,11 +356,11 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma
356356

357357
public void SurfaceDestroyed(ISurfaceHolder holder)
358358
{
359-
if (surfaceGlobalRef != IntPtr.Zero)
360-
{
361-
global::Android.Runtime.JNIEnv.DeleteGlobalRef(surfaceGlobalRef);
362-
surfaceGlobalRef = IntPtr.Zero;
363-
}
359+
IntPtr oldRef = System.Threading.Interlocked.Exchange(ref surfaceGlobalRef, IntPtr.Zero);
360+
361+
if (oldRef != IntPtr.Zero)
362+
global::Android.Runtime.JNIEnv.DeleteGlobalRef(oldRef);
363+
364364
surfaceEvent.Reset();
365365
}
366366

osu.Android/OsuGameAndroid.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ private void load()
144144
Host.AvailableInputHandlers.Add(stylusHandler);
145145
gameActivity.StylusHandler = stylusHandler;
146146

147+
// Pass actual display dimensions to the stylus handler so the tablet area
148+
// matches the real digitizer/screen size (not a hardcoded placeholder).
149+
try
150+
{
151+
if (gameActivity.WindowManager?.DefaultDisplay != null)
152+
{
153+
var displaySize = new global::Android.Graphics.Point();
154+
#pragma warning disable CA1422
155+
gameActivity.WindowManager.DefaultDisplay.GetRealSize(displaySize);
156+
#pragma warning restore CA1422
157+
if (displaySize.X > 0 && displaySize.Y > 0)
158+
stylusHandler.SetDisplaySize(displaySize.X, displaySize.Y);
159+
}
160+
}
161+
catch (Exception e)
162+
{
163+
Debug.WriteLine($"[osu!] Failed to get display size for stylus handler: {e.Message}");
164+
}
165+
147166
mouseHandler = new AndroidMouseHandler();
148167
Host.AvailableInputHandlers.Add(mouseHandler);
149168
gameActivity.MouseHandler = mouseHandler;

0 commit comments

Comments
 (0)