diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index dcc47685eea1..4623d4678462 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "codefilesanity": { - "version": "0.0.37", + "version": "0.0.41", "commands": [ "CodeFileSanity" ], @@ -24,4 +24,4 @@ "rollForward": false } } -} \ No newline at end of file +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87b2f9795449..fb9c56ff7615 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,8 @@ jobs: NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" CMAKE_BIN="$ANDROID_HOME/cmake/3.22.1/bin/cmake" - # Only build arm64 and arm32. x86 removed to reduce APK size — - # modern emulators use x86_64 or ARM translation. - for ABI in arm64-v8a armeabi-v7a; do + # arm64 only — matches RuntimeIdentifiers in osu.Android.props. + for ABI in arm64-v8a; do echo "::group::Building osu_native for $ABI" "$CMAKE_BIN" -B "build-native/$ABI" -S osu.Android/Native \ -DCMAKE_TOOLCHAIN_FILE="$NDK_HOME/build/cmake/android.toolchain.cmake" \ @@ -148,6 +147,55 @@ jobs: echo "Found APK: $APK ($APK_SIZE_MB MB)" echo "apk_path=$APK" >> "$GITHUB_OUTPUT" + # .NET 10 Android SDK may skip debug-signing for Release publish builds. + # Verify the APK is signed; if not, sign it with apksigner using the debug + # keystore so the APK can be sideloaded without INSTALL_PARSE_FAILED_NO_CERTIFICATES. + - name: Verify and sign APK if needed + run: | + APK="${{ steps.find_apk.outputs.apk_path }}" + APKSIGNER="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/apksigner" + ZIPALIGN="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)/zipalign" + + if "$APKSIGNER" verify "$APK" 2>/dev/null; then + echo "APK is already signed ✓" + else + echo "::warning::APK is not signed. Signing with debug keystore..." + + # Generate debug keystore if it doesn't exist + DEBUG_KS="$HOME/.android/debug.keystore" + if [ ! -f "$DEBUG_KS" ]; then + mkdir -p "$HOME/.android" + keytool -genkeypair -v \ + -keystore "$DEBUG_KS" \ + -storepass android \ + -keypass android \ + -alias androiddebugkey \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + fi + + # Zipalign first (required before apksigner v2 signing) + ALIGNED_APK="${APK%.apk}-aligned.apk" + "$ZIPALIGN" -f -p 4 "$APK" "$ALIGNED_APK" + mv "$ALIGNED_APK" "$APK" + + # Sign with debug keystore (v1 + v2 + v3 schemes) + "$APKSIGNER" sign \ + --ks "$DEBUG_KS" \ + --ks-pass pass:android \ + --key-pass pass:android \ + --ks-key-alias androiddebugkey \ + "$APK" + + # Verify signature + if "$APKSIGNER" verify --print-certs "$APK"; then + echo "APK signed successfully ✓" + else + echo "::error::APK signing failed" + exit 1 + fi + fi + - name: Upload APK artifact uses: actions/upload-artifact@v7 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 347e0f558a09..a71cb60f849a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,10 @@ Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. +## Foreword on AI usage + +Our team believes in **human contributions**. Any contribution – be it an issue report or a pull request – which is created by, documented by, or aided by AI/LLM usage will typically be **closed and locked without further discussion**. + ## Table of contents 1. [Reporting bugs](#reporting-bugs) diff --git a/README.md b/README.md index d87ca31f72ed..1629e71857d6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ You can see some examples of custom rulesets by visiting the [custom ruleset dir Please make sure you have the following prerequisites: -- A desktop platform with the [.NET 8.0 SDK](https://dotnet.microsoft.com/download) installed. +- A desktop platform with the [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) installed (this fork targets .NET 10; upstream ppy/osu uses .NET 8). When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/), or [Visual Studio Code](https://code.visualstudio.com/) with the [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) and [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) plugin installed. @@ -96,6 +96,68 @@ When running locally to do any kind of performance testing, make sure to add `-c If the build fails, try to restore NuGet packages with `dotnet restore`. +#### Building for Android + +**Prerequisites:** +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (this fork targets .NET 10) +- JDK 17 (`sudo apt install openjdk-17-jdk` or use [Microsoft's JDK](https://learn.microsoft.com/en-us/java/openjdk/download)) +- Android workload: `dotnet workload install android` + +**Debug build** (auto-signed with debug keystore, suitable for local testing): + +```shell +dotnet build -c Debug osu.Android/osu.Android.csproj +``` + +The APK will be at `osu.Android/bin/Debug/net10.0-android/sh.ppy.osulazer.apk`. Debug builds are always signed with the Android debug keystore and can be installed directly via `adb install`. + +**Release build** (optimised with AOT, trimming, and compression): + +```shell +dotnet publish -c Release osu.Android/osu.Android.csproj -f net10.0-android +``` + +The APK will be at `osu.Android/bin/Release/net10.0-android/publish/sh.ppy.osulazer.apk`. + +**Signing the Release APK:** + +Release APKs may not be automatically signed by the .NET SDK. If you get `INSTALL_PARSE_FAILED_NO_CERTIFICATES` when installing, sign the APK manually: + +```shell +# Find your build-tools (adjust version as needed) +BUILD_TOOLS="$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | sort -V | tail -1)" + +# Zipalign (required before signing) +"$BUILD_TOOLS/zipalign" -f -p 4 sh.ppy.osulazer.apk sh.ppy.osulazer-aligned.apk +mv sh.ppy.osulazer-aligned.apk sh.ppy.osulazer.apk + +# Sign with debug keystore (or your own release keystore) +"$BUILD_TOOLS/apksigner" sign \ + --ks ~/.android/debug.keystore \ + --ks-pass pass:android \ + --key-pass pass:android \ + --ks-key-alias androiddebugkey \ + sh.ppy.osulazer.apk + +# Verify +"$BUILD_TOOLS/apksigner" verify sh.ppy.osulazer.apk +``` + +If `~/.android/debug.keystore` does not exist, generate it: + +```shell +keytool -genkeypair -v -keystore ~/.android/debug.keystore \ + -storepass android -keypass android -alias androiddebugkey \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" +``` + +**Install via ADB:** + +```shell +adb install sh.ppy.osulazer.apk +``` + ### Testing with resource/framework modifications Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands: @@ -138,6 +200,8 @@ If you wish to help with localisation efforts, head over to [crowdin](https://cr We love to reward quality contributions. If you have made a large contribution, or are a regular contributor, you are welcome to [submit an expense via opencollective](https://opencollective.com/ppy/expenses/new). If you have any questions, feel free to [reach out to peppy](mailto:pe@ppy.sh) before doing so. +Our team believes in **human contributions**. Any contribution – be it an issue report or a pull request – which is created by, documented by, or aided by AI/LLM usage will typically be **closed and locked without further discussion**. + ## Licence *osu!*'s code and framework are licensed under the [MIT licence](https://opensource.org/licenses/MIT). Please see [the licence file](LICENCE) for more information. [tl;dr](https://tldrlegal.com/license/mit-license) you can do whatever you want as long as you include the original copyright and license notice in any copy of the software/source. diff --git a/osu.Android.props b/osu.Android.props index d68de9bb5255..106c4a5bea7c 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -1,11 +1,10 @@  33.0 - - android-arm;android-arm64 + + android-arm64 apk @@ -68,12 +67,7 @@ - - + @@ -82,19 +76,18 @@ true - + - + native - + native diff --git a/osu.Android/AndroidManifest.xml b/osu.Android/AndroidManifest.xml index a11765157368..2ed257bbc92e 100644 --- a/osu.Android/AndroidManifest.xml +++ b/osu.Android/AndroidManifest.xml @@ -1,7 +1,7 @@ - + diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index ce520f120ead..c56987ff56f0 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -17,77 +17,98 @@ internal sealed class AndroidNativeBridgeManager : IDisposable private object? oboeBridge; private object? vulkanProbe; private volatile bool disposed; - private string? cachedOboeStatus; - private string? cachedVulkanStatus; + private volatile string? cachedOboeStatus; + private volatile string? cachedVulkanStatus; + private readonly object oboeLock = new object(); [MethodImpl(MethodImplOptions.NoInlining)] public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, IntPtr provider, int sampleRate = 0, Action? onStarted = null) { - if (oboeBridge != null) + lock (oboeLock) { - Debug.WriteLine("[osu!] Oboe bridge already started, ignoring request"); - return; - } + if (oboeBridge != null) + { + Debug.WriteLine("[osu!] Oboe bridge already started, ignoring request"); + return; + } - Debug.WriteLine($"[osu!] Starting Oboe bridge (sampleRate={sampleRate}, hasProvider={provider != IntPtr.Zero})"); - cachedOboeStatus = null; + Debug.WriteLine($"[osu!] Starting Oboe bridge (sampleRate={sampleRate}, hasProvider={provider != IntPtr.Zero})"); + cachedOboeStatus = null; - try - { - var bridge = OboeAudioBridge.Create(sampleRate); - - if (bridge != null) + try { - oboeBridge = bridge; + var bridge = OboeAudioBridge.Create(sampleRate); - if (provider != IntPtr.Zero) - bridge.SetProvider(provider); + if (bridge != null) + { + oboeBridge = bridge; - try { SetThreadAffinity(0xF8); } catch { } - bool started = bridge.Start(); - if (!started) { System.Threading.Thread.Sleep(100); started = bridge.Start(); } + if (provider != IntPtr.Zero) + bridge.SetProvider(provider); - if (started) - { - Debug.WriteLine("[osu!] Oboe bridge started successfully"); - logOboeInfo(bridge); + // Calculate dynamic big-core mask for audio thread, matching the pattern in OsuGameAndroid.LoadComplete + int audioAffinityMask; + int cores = System.Environment.ProcessorCount; + int bigStart = Math.Max(cores / 2, 1); + audioAffinityMask = 0; + + for (int i = bigStart; i < Math.Min(cores, 32); i++) + audioAffinityMask |= 1 << i; + + if (audioAffinityMask == 0) audioAffinityMask = (1 << Math.Min(cores, 31)) - 1; - onStarted?.Invoke(bridge.SampleRate); + try { SetThreadAffinity(audioAffinityMask); } + catch (Exception e) { Debug.WriteLine($"[osu!] Audio thread affinity failed: {e.Message}"); } - scheduler.Add(new ScheduledDelegate(() => + bool started = bridge.Start(); + if (!started) { System.Threading.Thread.Sleep(100); started = bridge.Start(); } + + if (started) { - if (oboeBridge is not OboeAudioBridge b) return; + Debug.WriteLine("[osu!] Oboe bridge started successfully"); + logOboeInfo(bridge); + + onStarted?.Invoke(bridge.SampleRate); + + scheduler.Add(new ScheduledDelegate(() => + { + if (oboeBridge is not OboeAudioBridge b) return; - double latency = b.GetOutputLatencyMs(); + double latency = b.GetOutputLatencyMs(); - if (latency > 0) - onLatencyMeasured(latency); - }, 2000, 5000)); + if (latency > 0) + onLatencyMeasured(latency); + }, 2000, 5000)); + } + else + { + string error = bridge.GetLastErrorMessage() ?? "Unknown"; + Debug.WriteLine($"[osu!] Oboe bridge created but failed to start: {error}"); + } } else { - Debug.WriteLine("[osu!] Oboe bridge created but failed to start (Start() returned false)"); + Debug.WriteLine("[osu!] Oboe bridge creation failed — native library not loaded or stream open failed"); } } - else + catch (Exception e) { - Debug.WriteLine("[osu!] Oboe bridge creation failed (Create() returned null)"); + Debug.WriteLine($"[osu!] Oboe bridge init failed with exception: {e.Message}"); } } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Oboe bridge init failed with exception: {e.Message}"); - } } [MethodImpl(MethodImplOptions.NoInlining)] public void StopOboeBridge() { - Debug.WriteLine("[osu!] Stopping Oboe bridge..."); - (oboeBridge as OboeAudioBridge)?.Dispose(); - oboeBridge = null; - cachedOboeStatus = null; - Debug.WriteLine("[osu!] Oboe bridge stopped"); + lock (oboeLock) + { + Debug.WriteLine("[osu!] Stopping Oboe bridge..."); + (oboeBridge as OboeAudioBridge)?.Dispose(); + oboeBridge = null; + cachedOboeStatus = null; + Debug.WriteLine("[osu!] Oboe bridge stopped"); + } } [MethodImpl(MethodImplOptions.NoInlining)] @@ -100,7 +121,13 @@ public void StopOboeBridge() public string GetOboeStatus() { if (oboeBridge is not OboeAudioBridge bridge) return "Not Created"; - if (!bridge.IsActive) return "Failed: " + bridge.GetLastErrorMessage(); + + if (!bridge.IsActive) + { + try { return "Failed: " + (bridge.GetLastErrorMessage() ?? "Unknown"); } + catch { return "Failed: Unknown"; } + } + return cachedOboeStatus ??= $"{(bridge.IsAAudio ? "AAudio" : "OpenSLES")} [{(bridge.IsMMap ? "MMAP" : "Legacy")}]"; } diff --git a/osu.Android/Input/AndroidKeyboardHandler.cs b/osu.Android/Input/AndroidKeyboardHandler.cs index 40d17eef717a..d20bb3cd4299 100644 --- a/osu.Android/Input/AndroidKeyboardHandler.cs +++ b/osu.Android/Input/AndroidKeyboardHandler.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Runtime.CompilerServices; using Android.Views; using osu.Framework.Input.Handlers; using osu.Framework.Input.StateChanges; @@ -14,6 +16,42 @@ public class AndroidKeyboardHandler : InputHandler public override string Description => "Keyboard (Low Latency)"; public override bool IsActive => Enabled.Value; + // Static dictionary for O(1) key mapping instead of 80+ case switch. + private static readonly Dictionary key_map = new Dictionary + { + { Keycode.A, Key.A }, { Keycode.B, Key.B }, { Keycode.C, Key.C }, { Keycode.D, Key.D }, + { Keycode.E, Key.E }, { Keycode.F, Key.F }, { Keycode.G, Key.G }, { Keycode.H, Key.H }, + { Keycode.I, Key.I }, { Keycode.J, Key.J }, { Keycode.K, Key.K }, { Keycode.L, Key.L }, + { Keycode.M, Key.M }, { Keycode.N, Key.N }, { Keycode.O, Key.O }, { Keycode.P, Key.P }, + { Keycode.Q, Key.Q }, { Keycode.R, Key.R }, { Keycode.S, Key.S }, { Keycode.T, Key.T }, + { Keycode.U, Key.U }, { Keycode.V, Key.V }, { Keycode.W, Key.W }, { Keycode.X, Key.X }, + { Keycode.Y, Key.Y }, { Keycode.Z, Key.Z }, + { Keycode.Num0, Key.Number0 }, { Keycode.Num1, Key.Number1 }, { Keycode.Num2, Key.Number2 }, + { Keycode.Num3, Key.Number3 }, { Keycode.Num4, Key.Number4 }, { Keycode.Num5, Key.Number5 }, + { Keycode.Num6, Key.Number6 }, { Keycode.Num7, Key.Number7 }, { Keycode.Num8, Key.Number8 }, + { Keycode.Num9, Key.Number9 }, + { Keycode.DpadUp, Key.Up }, { Keycode.DpadDown, Key.Down }, + { Keycode.DpadLeft, Key.Left }, { Keycode.DpadRight, Key.Right }, + { Keycode.Enter, Key.Enter }, { Keycode.Escape, Key.Escape }, + { Keycode.Space, Key.Space }, { Keycode.Tab, Key.Tab }, + { Keycode.Del, Key.BackSpace }, { Keycode.ForwardDel, Key.Delete }, + { Keycode.MoveHome, Key.Home }, { Keycode.MoveEnd, Key.End }, + { Keycode.PageUp, Key.PageUp }, { Keycode.PageDown, Key.PageDown }, + { Keycode.ShiftLeft, Key.ShiftLeft }, { Keycode.ShiftRight, Key.ShiftRight }, + { Keycode.CtrlLeft, Key.ControlLeft }, { Keycode.CtrlRight, Key.ControlRight }, + { Keycode.AltLeft, Key.AltLeft }, { Keycode.AltRight, Key.AltRight }, + { Keycode.CapsLock, Key.CapsLock }, + { Keycode.F1, Key.F1 }, { Keycode.F2, Key.F2 }, { Keycode.F3, Key.F3 }, + { Keycode.F4, Key.F4 }, { Keycode.F5, Key.F5 }, { Keycode.F6, Key.F6 }, + { Keycode.F7, Key.F7 }, { Keycode.F8, Key.F8 }, { Keycode.F9, Key.F9 }, + { Keycode.F10, Key.F10 }, { Keycode.F11, Key.F11 }, { Keycode.F12, Key.F12 }, + { Keycode.Grave, Key.Tilde }, { Keycode.Minus, Key.Minus }, { Keycode.Equals, Key.Plus }, + { Keycode.LeftBracket, Key.BracketLeft }, { Keycode.RightBracket, Key.BracketRight }, + { Keycode.Backslash, Key.BackSlash }, { Keycode.Semicolon, Key.Semicolon }, + { Keycode.Apostrophe, Key.Quote }, { Keycode.Comma, Key.Comma }, + { Keycode.Period, Key.Period }, { Keycode.Slash, Key.Slash }, + }; + public AndroidKeyboardHandler() { Enabled.Default = true; @@ -22,124 +60,32 @@ public AndroidKeyboardHandler() public override bool Initialize(GameHost host) => true; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HandleKeyEvent(KeyEvent e) { if (!Enabled.Value) return false; - // System keys should ALWAYS fall through to the OS if (e.KeyCode == Keycode.Back || e.KeyCode == Keycode.Home || e.KeyCode == Keycode.Menu || e.KeyCode == Keycode.VolumeUp || e.KeyCode == Keycode.VolumeDown || e.KeyCode == Keycode.VolumeMute || e.KeyCode == Keycode.AppSwitch) return false; - // In DeX, source might include other flags (like Mouse or Stylus). - // We should allow anything that is clearly a keyboard or has a valid keycode. if (!e.Source.HasFlag(InputSourceType.Keyboard) && !e.Source.HasFlag(InputSourceType.Mouse) && !e.Source.HasFlag(InputSourceType.Stylus) && e.Source != InputSourceType.Unknown) { - // If it's not a keyboard source, only allow if it's from a device that HAS a keyboard - var device = e.Device; - if (device == null || device.KeyboardType == global::Android.Views.InputKeyboardType.None) - return false; + var device = e.Device; + if (device == null || device.KeyboardType == global::Android.Views.InputKeyboardType.None) + return false; } - var key = mapKey(e.KeyCode); - if (key == Key.Unknown) return false; + if (!key_map.TryGetValue(e.KeyCode, out var key)) + return false; bool isDown = e.Action == KeyEventActions.Down; - // We want to handle the first press, but skip OS-level repeats to avoid input lag/buffer bloat if (e.RepeatCount > 0 && isDown) return true; PendingInputs.Enqueue(new KeyboardKeyInput(key, isDown)); return true; } - - private Key mapKey(Keycode code) - { - switch (code) - { - case Keycode.A: return Key.A; - case Keycode.B: return Key.B; - case Keycode.C: return Key.C; - case Keycode.D: return Key.D; - case Keycode.E: return Key.E; - case Keycode.F: return Key.F; - case Keycode.G: return Key.G; - case Keycode.H: return Key.H; - case Keycode.I: return Key.I; - case Keycode.J: return Key.J; - case Keycode.K: return Key.K; - case Keycode.L: return Key.L; - case Keycode.M: return Key.M; - case Keycode.N: return Key.N; - case Keycode.O: return Key.O; - case Keycode.P: return Key.P; - case Keycode.Q: return Key.Q; - case Keycode.R: return Key.R; - case Keycode.S: return Key.S; - case Keycode.T: return Key.T; - case Keycode.U: return Key.U; - case Keycode.V: return Key.V; - case Keycode.W: return Key.W; - case Keycode.X: return Key.X; - case Keycode.Y: return Key.Y; - case Keycode.Z: return Key.Z; - case Keycode.Num0: return Key.Number0; - case Keycode.Num1: return Key.Number1; - case Keycode.Num2: return Key.Number2; - case Keycode.Num3: return Key.Number3; - case Keycode.Num4: return Key.Number4; - case Keycode.Num5: return Key.Number5; - case Keycode.Num6: return Key.Number6; - case Keycode.Num7: return Key.Number7; - case Keycode.Num8: return Key.Number8; - case Keycode.Num9: return Key.Number9; - case Keycode.DpadUp: return Key.Up; - case Keycode.DpadDown: return Key.Down; - case Keycode.DpadLeft: return Key.Left; - case Keycode.DpadRight: return Key.Right; - case Keycode.Enter: return Key.Enter; - case Keycode.Escape: return Key.Escape; - case Keycode.Space: return Key.Space; - case Keycode.Tab: return Key.Tab; - case Keycode.Del: return Key.BackSpace; - case Keycode.ForwardDel: return Key.Delete; - case Keycode.MoveHome: return Key.Home; - case Keycode.MoveEnd: return Key.End; - case Keycode.PageUp: return Key.PageUp; - case Keycode.PageDown: return Key.PageDown; - case Keycode.ShiftLeft: return Key.ShiftLeft; - case Keycode.ShiftRight: return Key.ShiftRight; - case Keycode.CtrlLeft: return Key.ControlLeft; - case Keycode.CtrlRight: return Key.ControlRight; - case Keycode.AltLeft: return Key.AltLeft; - case Keycode.AltRight: return Key.AltRight; - case Keycode.CapsLock: return Key.CapsLock; - case Keycode.F1: return Key.F1; - case Keycode.F2: return Key.F2; - case Keycode.F3: return Key.F3; - case Keycode.F4: return Key.F4; - case Keycode.F5: return Key.F5; - case Keycode.F6: return Key.F6; - case Keycode.F7: return Key.F7; - case Keycode.F8: return Key.F8; - case Keycode.F9: return Key.F9; - case Keycode.F10: return Key.F10; - case Keycode.F11: return Key.F11; - case Keycode.F12: return Key.F12; - case Keycode.Grave: return Key.Tilde; - case Keycode.Minus: return Key.Minus; - case Keycode.Equals: return Key.Plus; - case Keycode.LeftBracket: return Key.BracketLeft; - case Keycode.RightBracket: return Key.BracketRight; - case Keycode.Backslash: return Key.BackSlash; - case Keycode.Semicolon: return Key.Semicolon; - case Keycode.Apostrophe: return Key.Quote; - case Keycode.Comma: return Key.Comma; - case Keycode.Period: return Key.Period; - case Keycode.Slash: return Key.Slash; - default: return Key.Unknown; - } - } } } diff --git a/osu.Android/Input/AndroidMouseHandler.cs b/osu.Android/Input/AndroidMouseHandler.cs index 7a5fe5b9a881..792b2c372838 100644 --- a/osu.Android/Input/AndroidMouseHandler.cs +++ b/osu.Android/Input/AndroidMouseHandler.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Runtime.CompilerServices; using Android.Views; using osu.Framework.Input.Handlers; using osu.Framework.Input.StateChanges; @@ -15,7 +16,11 @@ public class AndroidMouseHandler : InputHandler public override string Description => "Mouse (Low Latency)"; public override bool IsActive => Enabled.Value; - public View? View { get; set; } + private bool lastLeft; + private bool lastRight; + private bool lastMiddle; + private bool lastBack; + private bool lastForward; public AndroidMouseHandler() { @@ -25,6 +30,7 @@ public AndroidMouseHandler() public override bool Initialize(GameHost host) => true; + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HandleMotionEvent(MotionEvent e) { if (!Enabled.Value) return false; @@ -41,14 +47,13 @@ public bool HandleMotionEvent(MotionEvent e) } for (int i = 0; i < e.HistorySize; i++) - { handlePointer(e, i); - } - handlePointer(e, -1); - return true; // We consume movement/buttons to prevent system from doing weird things with our cursor + handlePointer(e, -1); + return true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void handlePointer(MotionEvent e, int historyIndex) { const int pointer_index = 0; @@ -57,18 +62,6 @@ private void handlePointer(MotionEvent e, int historyIndex) float x = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex); float y = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex); - // In windowed mode (DeX), raw coordinates might be needed for consistency, but view-relative is usually better. - // If the view offset is weird, we could calculate it here: - /* - if (View != null) - { - int[] location = new int[2]; - View.GetLocationOnScreen(location); - x = (historyIndex < 0 ? e.RawX : e.GetHistoricalRawX(pointer_index, historyIndex)) - location[0]; - y = (historyIndex < 0 ? e.RawY : e.GetHistoricalRawY(pointer_index, historyIndex)) - location[1]; - } - */ - PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(x, y) }); bool left = (e.ButtonState & MotionEventButtonState.Primary) != 0; @@ -86,11 +79,5 @@ private void handlePointer(MotionEvent e, int historyIndex) if (back != lastBack) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Button1, back)); lastBack = back; } if (forward != lastForward) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Button2, forward)); lastForward = forward; } } - - private bool lastLeft; - private bool lastRight; - private bool lastMiddle; - private bool lastBack; - private bool lastForward; } } diff --git a/osu.Android/Input/AndroidStylusHandler.cs b/osu.Android/Input/AndroidStylusHandler.cs index 07f8a6dcd4e6..70683b9d1caa 100644 --- a/osu.Android/Input/AndroidStylusHandler.cs +++ b/osu.Android/Input/AndroidStylusHandler.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Runtime.CompilerServices; using Android.Views; using osu.Framework.Bindables; using osu.Framework.Input.Handlers; @@ -13,13 +14,16 @@ namespace osu.Android.Input { + /// + /// Handles Samsung S Pen / stylus input as a true tablet device with area mapping. + /// Provides the same coordinate transformation as desktop Wacom tablets: + /// raw digitizer coordinates → area selection → output area on screen. + /// public class AndroidStylusHandler : InputHandler, ITabletHandler { - public override string Description => "S Pen / Stylus (Low Latency)"; + public override string Description => "S Pen / Stylus"; public override bool IsActive => Enabled.Value; - public View? View { get; set; } - public Bindable AreaOffset { get; } = new Bindable(); public Bindable AreaSize { get; } = new Bindable(); public Bindable OutputAreaSize { get; } = new Bindable(); @@ -30,6 +34,7 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler { MinValue = 0.01f, MaxValue = 0.9f, + Precision = 0.01f, }; private readonly Bindable tablet = new Bindable(); @@ -38,6 +43,13 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler private bool lastRightDown; private bool lastEraserDown; + // Cached area values for hot path (avoids bindable access per event). + private float areaLeft, areaTop, areaWidth, areaHeight; + private float outLeft, outTop, outWidth, outHeight; + private float rotSin, rotCos; + + private const float deg_to_rad = MathF.PI / 180f; + public AndroidStylusHandler() { Enabled.Default = true; @@ -46,10 +58,71 @@ public AndroidStylusHandler() public override bool Initialize(GameHost host) { - tablet.Value = new TabletInfo("S Pen", new Vector2(2000, 1000)); + // Default size will be updated by SetDisplaySize once the display metrics are known. + tablet.Value = new TabletInfo("S Pen", new Vector2(1920, 1080)); + + AreaSize.BindValueChanged(_ => updateCachedTransform()); + AreaOffset.BindValueChanged(_ => updateCachedTransform()); + OutputAreaSize.BindValueChanged(_ => updateCachedTransform()); + OutputAreaOffset.BindValueChanged(_ => updateCachedTransform()); + Rotation.BindValueChanged(_ => updateCachedTransform()); + return base.Initialize(host); } + /// + /// Sets the digitizer/display dimensions. Must be called after the display is known. + /// This sets the full tablet area and default output area. + /// + public void SetDisplaySize(int width, int height) + { + var size = new Vector2(width, height); + tablet.Value = new TabletInfo("S Pen", size); + + // Default: full digitizer area mapped to full screen (1:1 passthrough). + AreaSize.Default = size; + AreaOffset.Default = size / 2; + OutputAreaSize.Default = size; + OutputAreaOffset.Default = size / 2; + + // Only set current values if they haven't been configured by the user yet. + if (AreaSize.Value == default || AreaSize.Value == new Vector2(1920, 1080)) + { + AreaSize.Value = size; + AreaOffset.Value = size / 2; + } + + if (OutputAreaSize.Value == default || OutputAreaSize.Value == new Vector2(1920, 1080)) + { + OutputAreaSize.Value = size; + OutputAreaOffset.Value = size / 2; + } + + updateCachedTransform(); + } + + private void updateCachedTransform() + { + var aSize = AreaSize.Value; + var aOff = AreaOffset.Value; + areaLeft = aOff.X - aSize.X / 2; + areaTop = aOff.Y - aSize.Y / 2; + areaWidth = aSize.X; + areaHeight = aSize.Y; + + var oSize = OutputAreaSize.Value; + var oOff = OutputAreaOffset.Value; + outLeft = oOff.X - oSize.X / 2; + outTop = oOff.Y - oSize.Y / 2; + outWidth = oSize.X; + outHeight = oSize.Y; + + float radians = deg_to_rad * Rotation.Value; + rotSin = MathF.Sin(radians); + rotCos = MathF.Cos(radians); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HandleMotionEvent(MotionEvent e) { if (!Enabled.Value) return false; @@ -63,43 +136,65 @@ public bool HandleMotionEvent(MotionEvent e) return true; } + // Process all batched historical events for maximum accuracy. for (int i = 0; i < e.HistorySize; i++) - { handlePointer(e, i); - } + handlePointer(e, -1); return true; } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private void handlePointer(MotionEvent e, int historyIndex) { const int pointer_index = 0; if (e.PointerCount <= pointer_index) return; - float x = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex); - float y = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex); + float rawX = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex); + float rawY = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex); float pressure = historyIndex < 0 ? e.GetPressure(pointer_index) : e.GetHistoricalPressure(pointer_index, historyIndex); - float tiltX = e.GetAxisValue(Axis.Tilt, pointer_index); - float tiltY = e.GetAxisValue(Axis.Orientation, pointer_index); - // DeX windowed mode offset correction - if (View != null) + // Auto-expand tablet size if the digitizer reports coordinates beyond current bounds. + if (tablet.Value == null || rawX > tablet.Value.Size.X || rawY > tablet.Value.Size.Y) { - // On some DeX versions, GetX/Y might be screen-relative if the window isn't focused. - // Using GetX/Y is generally safer for windowed mode as Android handles the subtraction, - // but we ensure the View is passed for future coordinate scaling needs. + var currentSize = tablet.Value?.Size ?? Vector2.Zero; + var newSize = new Vector2(Math.Max(rawX + 1, currentSize.X), Math.Max(rawY + 1, currentSize.Y)); + tablet.Value = new TabletInfo("S Pen", newSize); } - if (tablet.Value == null || x > tablet.Value.Size.X || y > tablet.Value.Size.Y) + // Apply tablet area → output area coordinate mapping. + float mappedX, mappedY; + + if (areaWidth > 0 && areaHeight > 0) { - var currentSize = tablet.Value?.Size ?? Vector2.Zero; - var newSize = new Vector2(Math.Max(x, currentSize.X), Math.Max(y, currentSize.Y)); - tablet.Value = new TabletInfo("S Pen", newSize); + // Normalize to [0, 1] within the configured tablet area. + float normX = (rawX - areaLeft) / areaWidth; + float normY = (rawY - areaTop) / areaHeight; + + // Apply rotation around center of normalized space. + if (Rotation.Value != 0) + { + float cx = normX - 0.5f; + float cy = normY - 0.5f; + normX = cx * rotCos - cy * rotSin + 0.5f; + normY = cx * rotSin + cy * rotCos + 0.5f; + } + + // Map to output area. + mappedX = outLeft + normX * outWidth; + mappedY = outTop + normY * outHeight; + } + else + { + // Fallback: raw passthrough if area is invalid. + mappedX = rawX; + mappedY = rawY; } - PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(x, y) }); + PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(mappedX, mappedY) }); + // Button state: pressure-based click (primary) with action overrides. bool isLeftDown = pressure >= PressureThreshold.Value; if (e.ActionMasked == MotionEventActions.Down || e.ActionMasked == MotionEventActions.ButtonPress) isLeftDown = true; else if (e.ActionMasked == MotionEventActions.Up || e.ActionMasked == MotionEventActions.ButtonRelease || e.ActionMasked == MotionEventActions.Cancel) isLeftDown = false; @@ -111,6 +206,7 @@ private void handlePointer(MotionEvent e, int historyIndex) lastLeftDown = isLeftDown; } + // S Pen button → right click. bool isRightDown = (e.ButtonState & MotionEventButtonState.StylusPrimary) != 0; if (isRightDown != lastRightDown) { @@ -118,10 +214,10 @@ private void handlePointer(MotionEvent e, int historyIndex) lastRightDown = isRightDown; } + // Eraser → middle click. bool isEraserDown = (e.ButtonState & MotionEventButtonState.StylusSecondary) != 0 || e.GetToolType(pointer_index) == MotionEventToolType.Eraser; if (isEraserDown != lastEraserDown) { - // Map eraser to Middle Click or a specific tablet button if framework supports it PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Middle, isEraserDown)); lastEraserDown = isEraserDown; } diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index 290c3408ac06..4accff85578e 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -1,9 +1,9 @@ -using System.Runtime.CompilerServices; // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using Debug = System.Diagnostics.Debug; @@ -52,9 +52,29 @@ static OboeAudioBridge() public static OboeAudioBridge? Create(int sampleRate = 0) { - if (!native_loaded) return null; - try { IntPtr ptr = nOboeCreate(sampleRate); return ptr == IntPtr.Zero ? null : new OboeAudioBridge(ptr); } - catch { return null; } + if (!native_loaded) + { + Debug.WriteLine("[osu!] Oboe Create() skipped — native library not loaded"); + return null; + } + + try + { + IntPtr ptr = nOboeCreate(sampleRate); + + if (ptr == IntPtr.Zero) + { + Debug.WriteLine($"[osu!] nOboeCreate({sampleRate}) returned null — stream open failed"); + return null; + } + + return new OboeAudioBridge(ptr); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] nOboeCreate failed: {e.Message}"); + return null; + } } private OboeAudioBridge(IntPtr ptr) => nativePtr = ptr; diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 174f276152ec..13aaa480edfc 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -60,6 +60,7 @@ bool OboeBridge::open(int32_t sampleRate) { if (result != oboe::Result::OK) { LOGE("AAudio open failed (%s), falling back to unspecified API", oboe::convertToText(result)); + { std::lock_guard eLock(errorLock_); lastError_ = std::string("AAudio: ") + oboe::convertToText(result); } builder.setAudioApi(oboe::AudioApi::Unspecified); builder.setSharingMode(oboe::SharingMode::Shared); result = builder.openStream(stream_); @@ -67,6 +68,7 @@ bool OboeBridge::open(int32_t sampleRate) { if (result != oboe::Result::OK) { LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result)); + { std::lock_guard eLock(errorLock_); lastError_ = std::string("Open failed: ") + oboe::convertToText(result); } return false; } @@ -106,6 +108,7 @@ bool OboeBridge::start() { if (result != oboe::Result::OK) { LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result)); + { std::lock_guard eLock(errorLock_); lastError_ = std::string("Start failed: ") + oboe::convertToText(result); } return false; } @@ -170,6 +173,11 @@ void OboeBridge::setProvider(OboeAudioProvider provider) { provider_.store(provider, std::memory_order_release); } +const char* OboeBridge::getLastError() const { + std::lock_guard lock(errorLock_); + return lastError_.empty() ? nullptr : lastError_.c_str(); +} + oboe::DataCallbackResult OboeBridge::onAudioReady( oboe::AudioStream* stream, void* audioData, int32_t numFrames) { @@ -368,6 +376,11 @@ OSU_EXPORT void nOboeSetProvider(intptr_t ptr, OboeAudioProvider provider) { if (bridge) bridge->setProvider(provider); } +OSU_EXPORT const char* nOboeGetLastErrorMessage(intptr_t ptr) { + auto* bridge = reinterpret_cast(ptr); + return bridge ? bridge->getLastError() : nullptr; +} + } // extern "C" extern "C" { diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index 4b8f08222ecf..29341717e0da 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -33,6 +33,7 @@ class OboeBridge : public oboe::AudioStreamCallback { bool isAAudio() const; bool isMMap() const; void setProvider(OboeAudioProvider provider); + const char* getLastError() const; // oboe::AudioStreamCallback oboe::DataCallbackResult onAudioReady( @@ -53,6 +54,8 @@ class OboeBridge : public oboe::AudioStreamCallback { std::atomic provider_{nullptr}; std::atomic affinitySet_{false}; int32_t requestedSampleRate_{0}; + std::string lastError_; + mutable std::mutex errorLock_; void updateLatency(); bool reopenAndRestart(); diff --git a/osu.Android/OboeAudioRedirector.cs b/osu.Android/OboeAudioRedirector.cs index 9e5bf7ed5c9f..bd7580ec5502 100644 --- a/osu.Android/OboeAudioRedirector.cs +++ b/osu.Android/OboeAudioRedirector.cs @@ -17,6 +17,7 @@ namespace osu.Android { /// /// Redirects audio from BASS mixers into an unmanaged callback (Oboe). + /// Discovers mixer handles via reflection since BassAudioMixer is internal to the framework. /// public class OboeAudioRedirector : IDisposable { @@ -58,12 +59,14 @@ public void RefreshMixers(int hardwareSampleRate) sampleRate = lastHardwareSampleRate; + // Collect mixer handles using the public BassAudioMixer.Handle property. addRootMixer(audioManager.TrackMixer); addRootMixer(audioManager.SampleMixer); foreach (var mixer in getActiveMixers()) addRootMixer(mixer); + // Fallback: add direct handles if root traversal found nothing. if (mixerHandles.Count == 0) { addMixer(audioManager.TrackMixer); @@ -75,10 +78,12 @@ public void RefreshMixers(int hardwareSampleRate) if (mixerHandles.Count == 0) { - Console.WriteLine("[osu!] Oboe redirector: No BASS mixers discovered yet, deferring redirection."); + Console.WriteLine("[osu!] Oboe redirector: No BASS mixers discovered. Audio may not have initialized yet."); return; } + Console.WriteLine($"[osu!] Oboe redirector: Found {mixerHandles.Count} mixer handle(s): {string.Join(", ", mixerHandles)}"); + if (!silenceDefaultAudio()) { Console.WriteLine("[osu!] Oboe redirector: Failed to silence default audio, aborting redirection."); @@ -253,10 +258,11 @@ private void addRootMixer(AudioMixer? mixer) { if (mixer == null) return; - int handle = findHandle(mixer); + int handle = getHandle(mixer); if (handle == 0) return; + // Walk up the mixer chain to find the root mixer handle. int current = handle; int parent; @@ -271,73 +277,32 @@ private void addMixer(AudioMixer? mixer) { if (mixer == null) return; - int handle = findHandle(mixer); + int handle = getHandle(mixer); if (handle != 0 && !mixerHandles.Contains(handle)) mixerHandles.Add(handle); } - private int findHandle(object obj) + /// + /// Gets the BASS handle from an AudioMixer via reflection. + /// BassAudioMixer is internal to the framework, so we access its Handle property via reflection. + /// + private static int getHandle(AudioMixer mixer) { - Type? type = obj.GetType(); - - while (type != null && type != typeof(object)) + try { - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (isHandleType(field.FieldType)) - { - string name = field.Name.ToLowerInvariant(); - if (name == "mixerhandle" || name == "handle" || name == "_handle" || name.Contains("handle") || name.Contains("mixer") || name.Contains("id") || name.Contains("stream") || name.Contains("channel") || name.Contains("source")) - { - int h = convertToHandle(field.GetValue(obj)); - if (h != 0) return h; - } - } - } - - foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) - { - if (isHandleType(prop.PropertyType)) - { - string name = prop.Name.ToLowerInvariant(); - if (name.Contains("handle") || name.Contains("mixer") || name.Contains("id") || name.Contains("stream") || name.Contains("channel") || name.Contains("source")) - { - int h = convertToHandle(prop.GetValue(obj)); - if (h != 0) return h; - } - } - } - - if (type.Name.Contains("Mixer") || type.Name.Contains("Channel") || type.Name.Contains("Stream")) - { - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic)) - { - if (field.FieldType == typeof(int)) - { - int h = (int)field.GetValue(obj)!; - if (h > 0 && h < 1000000) return h; - } - } - } - - type = type.BaseType; + var handleProp = mixer.GetType().GetProperty("Handle", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (handleProp?.GetValue(mixer) is int h) + return h; + } + catch + { + // Reflection failed — return 0 to indicate no handle found. } return 0; } - private bool isHandleType(Type type) => type == typeof(int) || type == typeof(IntPtr) || type == typeof(long); - - private int convertToHandle(object? val) - { - if (val == null) return 0; - if (val is int ih) return ih; - if (val is long lh) return (int)lh; - if (val is IntPtr ph) return (int)ph.ToInt64(); - return 0; - } - [UnmanagedCallersOnly(EntryPoint = "provideAudio", CallConvs = new[] { typeof(CallConvCdecl) })] private static int provideAudio(IntPtr audioData, int numFrames) { diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 0e6b03cd6ebc..a9973669e0e3 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -1,4 +1,3 @@ -using osu.Android.Input; // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. @@ -17,6 +16,7 @@ using System.Threading.Tasks; using System; using Uri = Android.Net.Uri; +using osu.Android.Input; using osu.Framework.Android; using osu.Game.Database; using osu.Android.Native; @@ -93,12 +93,31 @@ protected override void OnCreate(Bundle? savedInstanceState) Window.AddFlags(WindowManagerFlags.Fullscreen); Window.AddFlags(WindowManagerFlags.KeepScreenOn); + // Use full display area including camera cutout/notch for maximum render space. + if (OperatingSystem.IsAndroidVersionAtLeast(28) && Window.Attributes != null) + Window.Attributes.LayoutInDisplayCutoutMode = LayoutInDisplayCutoutMode.ShortEdges; + + // Request unbuffered touch dispatch early for minimum input latency. + if (OperatingSystem.IsAndroidVersionAtLeast(21)) + { + try + { + var dummy = MotionEvent.Obtain(0, 0, MotionEventActions.Down, 0, 0, 0); + Window.DecorView?.RequestUnbufferedDispatch(dummy); + dummy?.Recycle(); + } + catch { /* best-effort; will also be requested per-event in dispatch methods */ } + } + // Hide the system pointer icon to prevent double cursors in DeX or with mouse. if (OperatingSystem.IsAndroidVersionAtLeast(24)) { try { - Window.DecorView.PointerIcon = PointerIcon.GetSystemIcon(this, PointerIconType.Null); + var decorView = Window.DecorView; + + if (decorView != null) + decorView.PointerIcon = PointerIcon.GetSystemIcon(this, PointerIconType.Null); } catch (Exception e) { @@ -153,60 +172,51 @@ public override bool DispatchTouchEvent(MotionEvent? e) { if (e == null) return base.DispatchTouchEvent(e); - bool handled = false; + bool isStylus = isStylusEvent(e); - if (isStylusEvent(e)) + if (isStylus) { if (e.ActionMasked == MotionEventActions.Down || e.ActionMasked == MotionEventActions.HoverEnter) Window?.DecorView?.RequestUnbufferedDispatch(e); - handled = StylusHandler?.HandleMotionEvent(e) ?? false; + bool handled = StylusHandler?.HandleMotionEvent(e) ?? false; + return handled; } - else if (e.Source.HasFlag(InputSourceType.Mouse)) + + if (e.Source.HasFlag(InputSourceType.Mouse)) { if (e.ActionMasked == MotionEventActions.Down) Window?.DecorView?.RequestUnbufferedDispatch(e); - handled = MouseHandler?.HandleMotionEvent(e) ?? false; + if (MouseHandler?.HandleMotionEvent(e) ?? false) + return true; } - // Stylus events should NEVER be passed to base.DispatchTouchEvent, as it triggers - // Android's touch-mode which hides the cursor and shows touch effects. - if (isStylusEvent(e)) - return handled; - - // In DeX mode, we MUST call base even if "handled" to ensure window focus and system gestures work. - // However, if we fully consumed it (e.g. gameplay), we return true to prevent UI double-clicks. - return base.DispatchTouchEvent(e) || handled; + return base.DispatchTouchEvent(e); } public override bool DispatchGenericMotionEvent(MotionEvent? e) { if (e == null) return base.DispatchGenericMotionEvent(e); - bool handled = false; + bool isStylus = isStylusEvent(e); - if (isStylusEvent(e)) + if (isStylus) { - if (e.ActionMasked == MotionEventActions.Down || e.ActionMasked == MotionEventActions.HoverEnter) + if (e.ActionMasked == MotionEventActions.HoverEnter) Window?.DecorView?.RequestUnbufferedDispatch(e); - handled = StylusHandler?.HandleMotionEvent(e) ?? false; + bool handled = StylusHandler?.HandleMotionEvent(e) ?? false; + return handled; } - else if (e.Source.HasFlag(InputSourceType.Mouse)) - { - if (e.ActionMasked == MotionEventActions.Down) - Window?.DecorView?.RequestUnbufferedDispatch(e); - handled = MouseHandler?.HandleMotionEvent(e) ?? false; + if (e.Source.HasFlag(InputSourceType.Mouse)) + { + if (MouseHandler?.HandleMotionEvent(e) ?? false) + return true; } - // Stylus hover events should not be passed to base to avoid system-level hover effects - // and touch-mode triggers. - if (isStylusEvent(e)) - return handled; - - return base.DispatchGenericMotionEvent(e) || handled; + return base.DispatchGenericMotionEvent(e); } public override bool OnTouchEvent(MotionEvent? e) @@ -229,19 +239,21 @@ public override bool OnGenericMotionEvent(MotionEvent? e) return base.OnGenericMotionEvent(e); } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] private bool isStylusEvent(MotionEvent e) { - // Check source first, as it's the most reliable indicator on some devices. + // Source flag check is cheapest and short-circuits for the common case. if ((e.Source & InputSourceType.Stylus) == InputSourceType.Stylus) return true; - // Check tool type for each pointer. + // Fallback: check tool type per pointer for devices that don't set the source flag. for (int i = 0; i < e.PointerCount; i++) { var toolType = e.GetToolType(i); if (toolType == MotionEventToolType.Stylus || toolType == MotionEventToolType.Eraser) return true; } + return false; } @@ -326,11 +338,17 @@ public void SurfaceCreated(ISurfaceHolder holder) { IntPtr handle = surface.Handle; if (handle == IntPtr.Zero) return; - { - surfaceGlobalRef = global::Android.Runtime.JNIEnv.NewGlobalRef(handle); - surfaceEvent.Set(); - Debug.WriteLine("[osu!] Native surface JNI global reference created"); - } + + IntPtr newRef = global::Android.Runtime.JNIEnv.NewGlobalRef(handle); + + // Atomically swap the old reference to prevent race with SurfaceDestroyed. + IntPtr oldRef = System.Threading.Interlocked.Exchange(ref surfaceGlobalRef, newRef); + + if (oldRef != IntPtr.Zero) + global::Android.Runtime.JNIEnv.DeleteGlobalRef(oldRef); + + surfaceEvent.Set(); + Debug.WriteLine("[osu!] Native surface JNI global reference created"); } } @@ -340,11 +358,11 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma public void SurfaceDestroyed(ISurfaceHolder holder) { - if (surfaceGlobalRef != IntPtr.Zero) - { - global::Android.Runtime.JNIEnv.DeleteGlobalRef(surfaceGlobalRef); - surfaceGlobalRef = IntPtr.Zero; - } + IntPtr oldRef = System.Threading.Interlocked.Exchange(ref surfaceGlobalRef, IntPtr.Zero); + + if (oldRef != IntPtr.Zero) + global::Android.Runtime.JNIEnv.DeleteGlobalRef(oldRef); + surfaceEvent.Reset(); } @@ -353,8 +371,18 @@ public void SurfaceDestroyed(ISurfaceHolder holder) public override void OnConfigurationChanged(Configuration newConfig) { base.OnConfigurationChanged(newConfig); + bool wasDeX = IsDeX; updateDeXStatus(newConfig); + + // Re-query display modes when the display configuration changes (e.g. DeX connect/disconnect, + // external monitor change, rotation). (game as OsuGameAndroid)?.SelectHighestRefreshRate(); + + // When entering DeX mode, apply immersive mode and auto-enable performance mode. + if (!wasDeX && IsDeX) + { + (game as OsuGameAndroid)?.OnDeXConnected(); + } } private void updateDeXStatus(Configuration? config) diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 8ebc7e0eff0a..aae84583ba71 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -88,12 +88,12 @@ public partial class OsuGameAndroid : OsuGame private object? activeMixersList; private object? nativeBridges; + private int currentRefreshRate; public OsuGameAndroid(OsuGameActivity activity) : base(null) { gameActivity = activity; - startVulkanProbe(); } public override string Version @@ -142,19 +142,34 @@ private void load() stylusHandler = new AndroidStylusHandler(); Host.AvailableInputHandlers.Add(stylusHandler); gameActivity.StylusHandler = stylusHandler; - stylusHandler.View = gameActivity.Window?.DecorView; + + // Pass actual display dimensions to the stylus handler so the tablet area + // matches the real digitizer/screen size (not a hardcoded placeholder). + try + { + if (gameActivity.WindowManager?.DefaultDisplay != null) + { + var displaySize = new global::Android.Graphics.Point(); +#pragma warning disable CA1422 + gameActivity.WindowManager.DefaultDisplay.GetRealSize(displaySize); +#pragma warning restore CA1422 + if (displaySize.X > 0 && displaySize.Y > 0) + stylusHandler.SetDisplaySize(displaySize.X, displaySize.Y); + } + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to get display size for stylus handler: {e.Message}"); + } mouseHandler = new AndroidMouseHandler(); Host.AvailableInputHandlers.Add(mouseHandler); gameActivity.MouseHandler = mouseHandler; - mouseHandler.View = gameActivity.Window?.DecorView; keyboardHandler = new AndroidKeyboardHandler(); Host.AvailableInputHandlers.Add(keyboardHandler); gameActivity.KeyboardHandler = keyboardHandler; - startVulkanProbe(); - audioRedirector = new OboeAudioRedirector(Audio); try @@ -182,31 +197,89 @@ private void load() protected override void LoadComplete() { + // Calculate big-core affinity mask dynamically based on device core count. + // On big.LITTLE architectures, the upper half of cores are typically performance cores. + int coreCount = System.Environment.ProcessorCount; + int bigCoreStart = Math.Max(coreCount / 2, 1); + int affinityMask = 0; + + for (int i = bigCoreStart; i < Math.Min(coreCount, 32); i++) + affinityMask |= 1 << i; + + if (affinityMask == 0) + affinityMask = (1 << Math.Min(coreCount, 31)) - 1; + try { - if (OboeAudioBridge.nSetThreadAffinity(0xF8) != 0) - Debug.WriteLine("[osu!] Update thread pinned to big cores"); + if (OboeAudioBridge.nSetThreadAffinity(affinityMask) != 0) + Logger.Log($"[osu!] Update thread pinned to big cores (mask=0x{affinityMask:X}, cores {bigCoreStart}-{coreCount - 1})", LoggingTarget.Performance); + + // Set update thread to urgent display priority (-8) for minimum scheduling latency. + global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay); + + int mask = affinityMask; Scheduler.Add(() => { Host.DrawThread.Scheduler.Add(() => { - try { if (OboeAudioBridge.nSetThreadAffinity(0xF8) != 0) Debug.WriteLine("[osu!] Render thread pinned to big cores"); } catch { } + try + { + if (OboeAudioBridge.nSetThreadAffinity(mask) != 0) Logger.Log("[osu!] Render thread pinned to big cores", LoggingTarget.Performance); + global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay); + } + catch { } }); Host.InputThread.Scheduler.Add(() => { - try { if (OboeAudioBridge.nSetThreadAffinity(0xF8) != 0) Debug.WriteLine("[osu!] Input thread pinned to big cores"); } catch { } + try + { + if (OboeAudioBridge.nSetThreadAffinity(mask) != 0) Logger.Log("[osu!] Input thread pinned to big cores", LoggingTarget.Performance); + global::Android.OS.Process.SetThreadPriority(global::Android.OS.ThreadPriority.UrgentDisplay); + } + catch { } }); }); } catch (Exception e) { - Debug.WriteLine($"[osu!] Failed to pin update thread: {e.Message}"); + Logger.Log($"[osu!] Failed to pin threads: {e.Message}", LoggingTarget.Performance); } + // Always enable sustained performance mode for consistent frame delivery. + // This prevents thermal throttling from causing sudden FPS drops. + try { gameActivity.Window?.SetSustainedPerformanceMode(true); } + catch { } + base.LoadComplete(); - System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency; + + // Always select the highest refresh rate on startup, regardless of performance mode. + // This ensures 120Hz+ displays are used at their native rate. + SelectHighestRefreshRate(); + + // When the user selects a different refresh rate from the settings dropdown, apply it. + SelectedDisplayRefreshRate.BindValueChanged(e => + { + try + { + applyRefreshRate(e.NewValue); + } + catch (Exception ex) + { + Debug.WriteLine($"[osu!] Failed to apply selected refresh rate: {ex.Message}"); + } + }); + + // In DeX mode, auto-enable performance mode and immersive fullscreen for best desktop experience. + if (gameActivity.IsDeX && !performanceMode.Value) + { + performanceMode.Value = true; + Logger.Log("[osu!] DeX detected — auto-enabled performance mode", LoggingTarget.Performance); + } + + if (gameActivity.IsDeX) + applyDeXImmersiveMode(); try { @@ -235,18 +308,6 @@ protected override void LoadComplete() { if (e.NewValue) { - int hardwareSampleRate = 0; - try - { - if (gameActivity.GetSystemService(global::Android.Content.Context.AudioService) is global::Android.Media.AudioManager audioManager) - { - string? rateStr = audioManager.GetProperty(global::Android.Media.AudioManager.PropertyOutputSampleRate); - if (!string.IsNullOrEmpty(rateStr)) - hardwareSampleRate = int.Parse(rateStr); - } - } - catch { } - try { startOboeBridge(latency => @@ -256,7 +317,7 @@ protected override void LoadComplete() Debug.WriteLine($"[osu!] Audio offset auto-suggested: {suggested:F1}ms (hardware latency={latency:F1}ms)"); }, audioRedirector != null ? audioRedirector.Provider : IntPtr.Zero, sampleRate => { - audioRedirector?.RefreshMixers(sampleRate > 0 ? sampleRate : hardwareSampleRate); + audioRedirector?.RefreshMixers(sampleRate); Debug.WriteLine("[osu!] Audio redirector refreshed with hardware sample rate: " + sampleRate); }); } @@ -287,7 +348,7 @@ protected override void LoadComplete() { Debug.WriteLine($"[osu!] Failed to toggle Vulkan probe: {ex.Message}"); } - }, false); + }, true); try { @@ -319,8 +380,8 @@ private void applyPerformanceOptimizations(bool enabled) { try { - gameActivity.Window?.SetSustainedPerformanceMode(enabled); - + // Sustained performance mode is always on (set in LoadComplete). + // The performance toggle controls the high-perf GC session only. if (enabled) { highPerformanceSession ??= highPerformanceSessionManager.BeginSession(); @@ -330,9 +391,6 @@ private void applyPerformanceOptimizations(bool enabled) highPerformanceSession?.Dispose(); highPerformanceSession = null; } - - if (enabled) - SelectHighestRefreshRate(); } catch (Exception e) { @@ -341,6 +399,71 @@ private void applyPerformanceOptimizations(bool enabled) }); } + /// + /// Called when DeX mode is connected at runtime (e.g. phone plugged into external monitor). + /// Re-queries display modes, enables performance mode, and applies immersive fullscreen. + /// + public void OnDeXConnected() + { + Schedule(() => + { + if (!performanceMode.Value) + { + performanceMode.Value = true; + Logger.Log("[osu!] DeX connected — auto-enabled performance mode", LoggingTarget.Performance); + } + + applyDeXImmersiveMode(); + }); + } + + /// + /// Applies immersive fullscreen on the DeX external display by hiding system bars. + /// This maximises the usable screen area and reduces input latency from system UI overlays. + /// + private void applyDeXImmersiveMode() + { + gameActivity.RunOnUiThread(() => + { + try + { + var window = gameActivity.Window; + + if (window == null) + return; + + if (OperatingSystem.IsAndroidVersionAtLeast(30)) + { + var controller = window.InsetsController; + + if (controller != null) + { + controller.Hide(global::Android.Views.WindowInsets.Type.SystemBars()); + controller.SystemBarsBehavior = (int)global::Android.Views.WindowInsetsControllerBehavior.ShowTransientBarsBySwipe; + } + } + else + { +#pragma warning disable CA1422 + window.DecorView.SystemUiVisibility = (StatusBarVisibility)( + SystemUiFlags.ImmersiveSticky + | SystemUiFlags.LayoutStable + | SystemUiFlags.LayoutHideNavigation + | SystemUiFlags.LayoutFullscreen + | SystemUiFlags.HideNavigation + | SystemUiFlags.Fullscreen); +#pragma warning restore CA1422 + } + + Logger.Log("[osu!] DeX immersive fullscreen applied", LoggingTarget.Performance); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to apply DeX immersive mode: {e.Message}"); + } + }); + } + public void SelectHighestRefreshRate() { try @@ -362,45 +485,48 @@ public void SelectHighestRefreshRate() if (gameActivity.IsFinishing || gameActivity.IsDestroyed) return; - var window = gameActivity.Window; - var windowManager = gameActivity.WindowManager; + var display = getActiveDisplay(); - if (window == null || windowManager == null) + if (display == null) return; - global::Android.Views.Display? display = null; + var modes = display.GetSupportedModes(); - if (OperatingSystem.IsAndroidVersionAtLeast(30)) - { - // Prefer the display associated with the activity (which would be the external monitor in DeX) - display = gameActivity.Display; - } + if (modes == null || modes.Length == 0) + return; - if (display == null) + // Populate the available refresh rates for the settings dropdown. + var rates = modes.Select(m => (int)m.RefreshRate) + .Distinct() + .OrderByDescending(r => r) + .ToList(); + + Schedule(() => { - // Fallback to DisplayManager to find an external display - if (gameActivity.GetSystemService(global::Android.Content.Context.DisplayService) is global::Android.Hardware.Display.DisplayManager dm) - { - var displays = dm.GetDisplays(); + AvailableDisplayRefreshRates.Clear(); + AvailableDisplayRefreshRates.Add(0); // 0 = "Auto (highest)" + AvailableDisplayRefreshRates.AddRange(rates); - if (gameActivity.IsDeX) - { - // Find the largest external display (most likely the monitor) - var displayList = displays?.ToList(); - if (displayList != null) - { - display = displayList.Where(d => d.DisplayId != 0) - .OrderByDescending(d => d.GetSupportedModes()?.FirstOrDefault()?.RefreshRate ?? 0) - .ThenByDescending(d => d.GetSupportedModes()?.FirstOrDefault()?.PhysicalWidth ?? 0) - .FirstOrDefault() ?? displayList.FirstOrDefault(d => d.DisplayId == 0); - } - } - else - { - display = displays?.FirstOrDefault(d => d.DisplayId == 0); - } - } - } + // If user hasn't selected a rate, auto-select highest. + if (SelectedDisplayRefreshRate.Value == 0) + applyDisplayMode(display, modes.OrderByDescending(m => m.RefreshRate).First()); + else + applyRefreshRate(SelectedDisplayRefreshRate.Value); + }); + + Logger.Log($"[osu!] Display modes queried: {string.Join(", ", rates.Select(r => $"{r}Hz"))} (DeX={gameActivity.IsDeX})", LoggingTarget.Performance); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to query supported display modes: {e.Message}"); + } + } + + private void applyRefreshRate(int targetHz) + { + try + { + var display = getActiveDisplay(); if (display == null) return; @@ -410,29 +536,92 @@ public void SelectHighestRefreshRate() if (modes == null || modes.Length == 0) return; - var preferred = modes.OrderByDescending(m => m.RefreshRate).First(); + global::Android.Views.Display.Mode preferred; - gameActivity.RunOnUiThread(() => + if (targetHz <= 0) { - try - { - if (window.Attributes is WindowManagerLayoutParams layoutParams) - { - layoutParams.PreferredDisplayModeId = preferred.ModeId; - window.Attributes = layoutParams; - Debug.WriteLine($"[osu!] Highest refresh rate selected: {preferred.RefreshRate}Hz (mode {preferred.ModeId})"); - } - } - catch (Exception e) + // Auto: pick highest refresh rate + preferred = modes.OrderByDescending(m => m.RefreshRate).First(); + } + else + { + // Find best match for the requested rate + preferred = modes.OrderBy(m => Math.Abs(m.RefreshRate - targetHz)) + .ThenByDescending(m => m.PhysicalWidth) + .First(); + } + + applyDisplayMode(display, preferred); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to apply refresh rate {targetHz}Hz: {e.Message}"); + } + } + + private void applyDisplayMode(global::Android.Views.Display display, global::Android.Views.Display.Mode mode) + { + var window = gameActivity.Window; + + if (window == null) + return; + + gameActivity.RunOnUiThread(() => + { + try + { + if (window.Attributes is WindowManagerLayoutParams layoutParams) { - Debug.WriteLine($"[osu!] Failed to apply preferred display mode: {e.Message}"); + layoutParams.PreferredDisplayModeId = mode.ModeId; + window.Attributes = layoutParams; + currentRefreshRate = (int)mode.RefreshRate; + Logger.Log($"[osu!] Display mode applied: {mode.RefreshRate}Hz (mode {mode.ModeId}, {mode.PhysicalWidth}x{mode.PhysicalHeight})", LoggingTarget.Performance); } - }); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to apply display mode: {e.Message}"); + } + }); + } + + private global::Android.Views.Display? getActiveDisplay() + { + if (gameActivity.IsFinishing || gameActivity.IsDestroyed) + return null; + + global::Android.Views.Display? display = null; + + if (OperatingSystem.IsAndroidVersionAtLeast(30)) + { + // On API 30+, Activity.Display returns the display the activity is currently on. + // In DeX, this is the external monitor. + display = gameActivity.Display; } - catch (Exception e) + + if (display == null) { - Debug.WriteLine($"[osu!] Failed to query supported display modes: {e.Message}"); + if (gameActivity.GetSystemService(global::Android.Content.Context.DisplayService) is global::Android.Hardware.Display.DisplayManager dm) + { + var displays = dm.GetDisplays(); + + if (gameActivity.IsDeX && displays != null) + { + // In DeX, prefer external displays (ID != 0) sorted by highest refresh rate. + var displayList = displays.ToList(); + display = displayList.Where(d => d.DisplayId != 0) + .OrderByDescending(d => d.GetSupportedModes()?.Max(m => m.RefreshRate) ?? 0) + .FirstOrDefault() + ?? displayList.FirstOrDefault(d => d.DisplayId == 0); + } + else + { + display = displays?.FirstOrDefault(d => d.DisplayId == 0); + } + } } + + return display; } public override bool IsVulkanRecommended => (nativeBridges as AndroidNativeBridgeManager)?.IsVulkanRecommended() ?? false; @@ -458,6 +647,8 @@ public override string OboeStatus public override double OboeLatency => (nativeBridges as AndroidNativeBridgeManager)?.GetMeasuredAudioLatencyMs() ?? -1; + public override int DisplayRefreshRate => currentRefreshRate; + public double GetMeasuredAudioLatencyMs() => getMeasuredAudioLatencyFromBridge(); [MethodImpl(MethodImplOptions.NoInlining)] @@ -560,28 +751,6 @@ private void updateOrientation() public override void SetHost(GameHost host) { - // Apply Vulkan environment overrides before the graphics device is initialized. - if (nativeBridges is AndroidNativeBridgeManager mgr && mgr.IsVulkanAvailable()) - { - try - { - string status = mgr.GetVulkanStatus(); - if (status.Contains("MAILBOX")) - System.Environment.SetEnvironmentVariable("VULKAN_PRESENT_MODE", "MAILBOX"); - - var disabledExtensions = new System.Collections.Generic.List(); - if (status.Contains("NoID")) disabledExtensions.Add("VK_KHR_present_id"); - if (status.Contains("NoWait")) disabledExtensions.Add("VK_KHR_present_wait"); - if (status.Contains("NoGPL")) disabledExtensions.Add("VK_EXT_graphics_pipeline_library"); - - if (disabledExtensions.Count > 0) - System.Environment.SetEnvironmentVariable("VULKAN_DISABLE_EXTENSIONS", string.Join(",", disabledExtensions)); - - Debug.WriteLine($"[osu!] Vulkan overrides applied: MODE={System.Environment.GetEnvironmentVariable("VULKAN_PRESENT_MODE")}, DISABLE={System.Environment.GetEnvironmentVariable("VULKAN_DISABLE_EXTENSIONS")}"); - } - catch (Exception e) { Debug.WriteLine($"[osu!] Failed to set Vulkan overrides: {e.Message}"); } - } - base.SetHost(host); if (host.Window != null) diff --git a/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs b/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs index 34d04eb0a4f2..da51a0c4cd96 100644 --- a/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs +++ b/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs @@ -35,10 +35,7 @@ private void enterSession() Logger.Log("Starting high performance session (Android)"); originalGCMode = GCSettings.LatencyMode; - // On Android, SustainedLowLatency is generally better for stable framerates. GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency; - - GC.Collect(0); } private void exitSession() diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index e06d0db5f281..64a90d71002d 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -27,10 +27,8 @@ + When absent, native features (Oboe audio, Vulkan probe) are gracefully disabled at runtime. --> - diff --git a/osu.Desktop/MacOS/MacOsAppLocationChecker.cs b/osu.Desktop/MacOS/MacOSAppLocationChecker.cs similarity index 100% rename from osu.Desktop/MacOS/MacOsAppLocationChecker.cs rename to osu.Desktop/MacOS/MacOSAppLocationChecker.cs diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs index a22abcb76d57..9dee127c36f9 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs @@ -18,6 +18,7 @@ public partial class ScrollingPath : CompositeDrawable private readonly Path drawablePath; private readonly List<(double Time, float X)> vertices = new List<(double, float)>(); + private readonly List sliderVertices = new List(); public ScrollingPath() { @@ -47,9 +48,8 @@ public void UpdatePathFrom(ScrollingHitObjectContainer hitObjectContainer, Juice private void computeTimeXs(JuiceStream hitObject) { vertices.Clear(); - - var sliderVertices = new List(); - hitObject.Path.GetPathToProgress(sliderVertices, 0, 1); + sliderVertices.Clear(); + sliderVertices.AddRange(hitObject.Path.CalculatedPath); if (sliderVertices.Count == 0) return; diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs index 57acf7cee2d8..9a4ecdfe74b6 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs @@ -175,8 +175,7 @@ public void ResampleVertices(IEnumerable sampleTimes) /// public void ConvertFromSliderPath(SliderPath sliderPath, double velocity) { - var sliderPathVertices = new List(); - sliderPath.GetPathToProgress(sliderPathVertices, 0, 1); + var sliderPathVertices = sliderPath.CalculatedPath; double time = 0; diff --git a/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj b/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj index d149d78d411b..fc26d7deeb4d 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj +++ b/osu.Game.Rulesets.Mania.Tests.Android/osu.Game.Rulesets.Mania.Tests.Android.csproj @@ -19,6 +19,7 @@ + diff --git a/osu.iOS.props b/osu.iOS.props index 12c5c45bb07b..2b7eb557c57e 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -29,6 +29,6 @@ true - +