diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 815cd512067e..4ce4f4b79884 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -86,6 +86,7 @@ public void StopOboeBridge() } [MethodImpl(MethodImplOptions.NoInlining)] + public static bool SetThreadAffinity(int coreMask) => OboeAudioBridge.nSetThreadAffinity(coreMask) != 0; public double GetMeasuredAudioLatencyMs() { return (oboeBridge as OboeAudioBridge)?.GetOutputLatencyMs() ?? -1; @@ -115,6 +116,7 @@ public void StartVulkanProbe() } [MethodImpl(MethodImplOptions.NoInlining)] + public bool IsVulkanRecommended() => (vulkanProbe as VulkanProbe)?.IsRecommended ?? false; public void StopVulkanProbe() { (vulkanProbe as VulkanProbe)?.Dispose(); diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index a083a489e5e5..d86c52764c32 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -337,5 +337,10 @@ public void Dispose() [DllImport(lib_name)] private static extern void nOboeSetProvider(IntPtr ptr, IntPtr provider); + [DllImport(lib_name)] public static extern byte nSetThreadAffinity(int coreMask); + [DllImport(lib_name)] public static extern IntPtr nADPFCreateSession(long targetDurationNanos); + [DllImport(lib_name)] public static extern void nADPFReportActualDuration(IntPtr sessionPtr, long actualDurationNanos); + [DllImport(lib_name)] public static extern void nADPFUpdateTargetDuration(IntPtr sessionPtr, long targetDurationNanos); + [DllImport(lib_name)] public static extern void nADPFCloseSession(IntPtr sessionPtr); } } diff --git a/osu.Android/Native/VulkanProbe.cs b/osu.Android/Native/VulkanProbe.cs index a9f915584dcd..4bb8d9f94883 100644 --- a/osu.Android/Native/VulkanProbe.cs +++ b/osu.Android/Native/VulkanProbe.cs @@ -47,6 +47,8 @@ static VulkanProbe() public bool SupportsGlobalPriority => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsGlobalPriority(nativePtr) != 0; public bool SupportsMemoryBudget => !disposed && nativePtr != IntPtr.Zero && nVulkanSupportsMemoryBudget(nativePtr) != 0; + public bool IsRecommended => IsAvailable && MeetsVulkan13 && SupportsDynamicRendering && SupportsSynchronization2; + public void Dispose() { if (disposed) return; diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index e831cb08b749..6e23f27e931f 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. #include "oboe_bridge.h" -#include "vulkan_bridge.h" #include #include #include @@ -368,3 +367,53 @@ OSU_EXPORT void nOboeSetProvider(intptr_t ptr, OboeAudioProvider provider) { } } // extern "C" + +extern "C" { +OSU_EXPORT void nLog(int level, const char* tag, const char* msg) { + __android_log_print(level, tag, "%s", msg); +} +} + +extern "C" { +OSU_EXPORT byte nSetThreadAffinity(int coreMask) { + cpu_set_t cpuset; + CPU_ZERO(&cpuset); + for (int i = 0; i < 32; i++) { + if ((coreMask >> i) & 1) { + CPU_SET(i, &cpuset); + } + } + return (sched_setaffinity(0, sizeof(cpu_set_t), &cpuset) == 0) ? 1 : 0; +} +} + +#include + +extern "C" { +OSU_EXPORT intptr_t nADPFCreateSession(int64_t targetDurationNanos) { + auto manager = APerformanceHint_getManager(); + if (!manager) return 0; + + // We use the current thread as the initial thread for the session. + int32_t thread_id = gettid(); + return reinterpret_cast(APerformanceHint_createSession(manager, &thread_id, 1, targetDurationNanos)); +} + +OSU_EXPORT void nADPFReportActualDuration(intptr_t sessionPtr, int64_t actualDurationNanos) { + if (sessionPtr) { + APerformanceHint_reportActualWorkDuration(reinterpret_cast(sessionPtr), actualDurationNanos); + } +} + +OSU_EXPORT void nADPFUpdateTargetDuration(intptr_t sessionPtr, int64_t targetDurationNanos) { + if (sessionPtr) { + APerformanceHint_updateTargetWorkDuration(reinterpret_cast(sessionPtr), targetDurationNanos); + } +} + +OSU_EXPORT void nADPFCloseSession(intptr_t sessionPtr) { + if (sessionPtr) { + APerformanceHint_closeSession(reinterpret_cast(sessionPtr)); + } +} +} diff --git a/osu.Android/OboeAudioRedirector.cs b/osu.Android/OboeAudioRedirector.cs index 9dc29b251853..78b5fe61d955 100644 --- a/osu.Android/OboeAudioRedirector.cs +++ b/osu.Android/OboeAudioRedirector.cs @@ -15,10 +15,6 @@ namespace osu.Android { - /// - /// A bridge between BASS and Oboe that redirects mixed PCM audio from BASS mixers - /// into an Oboe/AAudio stream for low-latency output on Android. - /// public class OboeAudioRedirector : IDisposable { private readonly AudioManager audioManager; @@ -27,7 +23,7 @@ public class OboeAudioRedirector : IDisposable private int masterMixer; private bool devicesSilenced; - private int sampleRate = 44100; // Default, will be updated from bridge. + private int sampleRate = 44100; public OboeAudioRedirector(AudioManager audioManager) { @@ -38,39 +34,32 @@ public OboeAudioRedirector(AudioManager audioManager) public void RefreshMixers(int hardwareSampleRate) { - // Ensure we are in a clean state before re-initialising. - // This restores any previous hijacks if Oboe is being toggled or refreshed. + Console.WriteLine($"[osu!] Oboe redirector: Refreshing mixers with rate {hardwareSampleRate}Hz"); restoreDefaultAudio(); sampleRate = hardwareSampleRate > 0 ? hardwareSampleRate : 44100; - mixerHandles.Clear(); - // Try to find the root mixer of the framework. - // By capturing the root, we get UI sounds, music, and SFX in one go, - // and we bypass the framework's final output stages for even lower latency. addRootMixer(audioManager.TrackMixer); addRootMixer(audioManager.SampleMixer); - // If we failed to find a shared root, fallback to individual mixers. if (mixerHandles.Count == 0) { addMixer(audioManager.TrackMixer); addMixer(audioManager.SampleMixer); } - // User requested Low-Latency Oboe: we MUST use Oboe. - // Silence the default device and setup routing. - // Order is critical: Device init -> Create Master -> Move Sources -> Add to Master. + if (mixerHandles.Count == 0) + { + Console.WriteLine("[osu!] Oboe redirector: CRITICAL - No BASS mixers discovered."); + return; + } + silenceDefaultAudio(); setupMasterMixer(); ActiveMasterMixer = masterMixer; - - if (mixerHandles.Count == 0) - Debug.WriteLine("[osu!] Oboe redirector: CRITICAL WARNING - no mixer handles found via reflection. Audio WILL BE SILENT until handled."); - else - Debug.WriteLine($"[osu!] Oboe redirector initialized: rate={sampleRate}Hz, mixers={mixerHandles.Count}, master={masterMixer}"); + Console.WriteLine($"[osu!] Oboe redirector initialized: master={masterMixer}, sources={string.Join(',', mixerHandles)}"); } private void setupMasterMixer() @@ -83,52 +72,35 @@ private void setupMasterMixer() if (!devicesSilenced) return; - // Create a BASS master mixer that matches the Oboe hardware format (Stereo Float). - // We use BASS_MIXER_NONSTOP to ensure the mixer doesn't stall if sources are empty. - // BASS_STREAM_DECODE means we pull data manually via ChannelGetData. + // Ensure we are working with the correct device context. + Bass.CurrentDevice = 0; + masterMixer = BassMix.CreateMixerStream(sampleRate, 2, BassFlags.Float | BassFlags.Decode | BassFlags.MixerNonStop); if (masterMixer == 0) { - Debug.WriteLine($"[osu!] Failed to create BASS master mixer: {Bass.LastError}"); + Console.WriteLine($"[osu!] Failed to create BASS master mixer: {Bass.LastError}"); return; } - // Move the master mixer to the silent device immediately. - // This ensures all following operations happen on the same device context. - if (!Bass.ChannelSetDevice(masterMixer, 0)) - Debug.WriteLine($"[osu!] Failed to move master mixer to silent device: {Bass.LastError}"); - - // Disable BASS-internal buffering for the master mixer. - // This ensures BASS renders as fast as possible when we call ChannelGetData. - // This is key for "lowest possible latency" as requested. - if (!Bass.ChannelSetAttribute(masterMixer, ChannelAttribute.Buffer, 0)) - Debug.WriteLine($"[osu!] Failed to disable BASS buffering on master mixer: {Bass.LastError}"); + Bass.ChannelSetAttribute(masterMixer, ChannelAttribute.Buffer, 0); foreach (int handle in mixerHandles) { - // BASS only allows a channel to have one parent mixer at a time. - // The framework's mixers are already attached to a master mixer, so we MUST hijack them. int parent = BassMix.ChannelGetMixer(handle); if (parent != 0) { originalParents[handle] = parent; - if (!BassMix.MixerRemoveChannel(handle)) - Debug.WriteLine($"[osu!] Failed to hijack mixer {handle} from parent {parent}: {Bass.LastError}"); + BassMix.MixerRemoveChannel(handle); } - // Move source mixer to the silent device before adding to master. - // Changing device automatically removes it from any existing mixer. if (!Bass.ChannelSetDevice(handle, 0)) - Debug.WriteLine($"[osu!] Failed to move source mixer {handle} to silent device: {Bass.LastError}"); + Console.WriteLine($"[osu!] Failed to move source mixer {handle} to silent device: {Bass.LastError}"); - // Add redirected mixers as sources to our master mixer. - // We remove BASS_MIXER_BUFFER to eliminate internal BASS buffering latency, - // relying entirely on the Oboe callback timing for rock-solid sync. if (!BassMix.MixerAddChannel(masterMixer, handle, BassFlags.MixerChanNoRampin)) { - Debug.WriteLine($"[osu!] Failed to add mixer {handle} to master mixer: {Bass.LastError}"); + Console.WriteLine($"[osu!] Failed to add mixer {handle} to master mixer: {Bass.LastError}"); } } } @@ -137,11 +109,9 @@ private void silenceDefaultAudio() { try { - // Initialize BASS "No Sound" device (0) with the hardware sample rate. - // This minimizes resampling overhead within BASS. if (!Bass.Init(0, sampleRate) && Bass.LastError != Errors.Already) { - Debug.WriteLine($"[osu!] Failed to initialize BASS No Sound device: {Bass.LastError}"); + Console.WriteLine($"[osu!] Failed to initialize BASS No Sound device: {Bass.LastError}"); return; } @@ -149,7 +119,7 @@ private void silenceDefaultAudio() } catch (Exception e) { - Debug.WriteLine($"[osu!] Failed to silence default audio: {e.Message}"); + Console.WriteLine($"[osu!] Failed to silence default audio: {e.Message}"); } } @@ -167,35 +137,25 @@ private void restoreDefaultAudio() foreach (int handle in mixerHandles) { - // Unplug from our Oboe master mixer. BassMix.MixerRemoveChannel(handle); - // Restore to framework's original parent mixer if we hijacked it. if (originalParents.TryGetValue(handle, out int parent)) { - // MUST move back to default device (1) before re-adding to framework parent. - if (!Bass.ChannelSetDevice(handle, 1)) - Debug.WriteLine($"[osu!] Failed to restore mixer {handle} to default device: {Bass.LastError}"); - - if (BassMix.MixerAddChannel(parent, handle, BassFlags.MixerChanNoRampin)) - Debug.WriteLine($"[osu!] Restored mixer {handle} to framework parent {parent}"); - else - Debug.WriteLine($"[osu!] Failed to restore mixer {handle} to framework parent {parent}: {Bass.LastError}"); + Bass.ChannelSetDevice(handle, 1); + BassMix.MixerAddChannel(parent, handle, BassFlags.MixerChanNoRampin); } else { - // Even if no parent, restore to default device. Bass.ChannelSetDevice(handle, 1); } } originalParents.Clear(); devicesSilenced = false; - Debug.WriteLine($"[osu!] BASS mixers restored to default device 1 and framework parents"); } catch (Exception e) { - Debug.WriteLine($"[osu!] Failed to restore default audio: {e.Message}"); + Console.WriteLine($"[osu!] Failed to restore default audio: {e.Message}"); } } @@ -204,10 +164,9 @@ private void addRootMixer(AudioMixer? mixer) if (mixer == null) return; int handle = findHandle(mixer); + if (handle == 0) return; - // Walk up the mixer tree using BASS calls directly to find the absolute root. - // This is safer than reflection because it queries the actual BASS engine state. int current = handle; int parent; @@ -215,72 +174,52 @@ private void addRootMixer(AudioMixer? mixer) current = parent; if (!mixerHandles.Contains(current)) - { mixerHandles.Add(current); - Debug.WriteLine($"[osu!] Oboe redirector: discovered root mixer {current} from source {handle}"); - } } private void addMixer(AudioMixer? mixer) { if (mixer == null) return; - try - { - int handle = findHandle(mixer); + int handle = findHandle(mixer); - if (handle != 0) - { - if (!mixerHandles.Contains(handle)) - { - mixerHandles.Add(handle); - Debug.WriteLine($"[osu!] Oboe redirector: added mixer handle {handle} for {mixer.GetType().Name}"); - } - } - else - { - Debug.WriteLine($"[osu!] Oboe redirector: WARNING - could not find BASS handle for {mixer.GetType().Name} via reflection. Audio might be silent on Oboe."); - } - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Oboe redirector: failed to get mixer handle via reflection: {e.Message}"); - } + if (handle != 0 && !mixerHandles.Contains(handle)) + mixerHandles.Add(handle); } private int findHandle(object obj) { Type? type = obj.GetType(); - // Broad search for BASS handles across the entire inheritance chain. - // Framework changes often move or rename these internal fields. while (type != null && type != typeof(object)) { - // Try common names used in ppy/osu and ppy/osu-framework - foreach (string name in new[] { "mixerHandle", "handle", "Handle", "mixer_handle", "_handle", "m_handle", "handlePtr" }) + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { - var field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (field != null) + if (field.FieldType == typeof(int) || field.FieldType == typeof(IntPtr)) { - int h = convertToHandle(field.GetValue(obj)); - if (h != 0) return h; - } + string name = field.Name.ToLowerInvariant(); - var prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - if (prop != null) - { - int h = convertToHandle(prop.GetValue(obj)); - if (h != 0) return h; + if (name.Contains("handle") || name.Contains("mixer")) + { + int h = convertToHandle(field.GetValue(obj)); + + if (h != 0) return h; + } } } - // Last resort: scan all fields for anything mentioning "handle" - foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + foreach (var prop in type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { - if (field.Name.Contains("handle", StringComparison.OrdinalIgnoreCase)) + if (prop.PropertyType == typeof(int) || prop.PropertyType == typeof(IntPtr)) { - int h = convertToHandle(field.GetValue(obj)); - if (h != 0) return h; + string name = prop.Name.ToLowerInvariant(); + + if (name.Contains("handle") || name.Contains("mixer")) + { + int h = convertToHandle(prop.GetValue(obj)); + + if (h != 0) return h; + } } } @@ -293,24 +232,24 @@ private int findHandle(object obj) 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(CallConvs = new[] { typeof(CallConvCdecl) })] private static int provideAudio(IntPtr audioData, int numFrames) { - // We use a static method with [UnmanagedCallersOnly] to eliminate delegate marshalling overhead. - // Since this is static, we need a way to find the active mixer. int mixer = ActiveMasterMixer; if (mixer == 0) return 0; - // Zero-copy: Tell BASS to render directly into the memory provided by Oboe. - // BASS_DATA_FLOAT is implied by the mixer stream flags. - int bytesToRead = numFrames * 8; // 2 channels * 4 bytes/sample + int bytesToRead = numFrames * 8; int bytesRead = Bass.ChannelGetData(mixer, audioData, bytesToRead); if (bytesRead <= 0) return 0; @@ -318,12 +257,11 @@ private static int provideAudio(IntPtr audioData, int numFrames) return bytesRead / 8; } - internal static int ActiveMasterMixer; + internal static volatile int ActiveMasterMixer; public void Dispose() { restoreDefaultAudio(); - mixerHandles.Clear(); } } } diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 272559bb31e3..b8cd05b76799 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -1,28 +1,30 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; using Android.App; -using Android.Content; using Android.Content.PM; +using Android.Content; using Android.Graphics; using Android.OS; +using Android.Runtime; using Android.Views; -using osu.Framework.Android; -using osu.Game.Database; using Debug = System.Diagnostics.Debug; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using System; using Uri = Android.Net.Uri; +using osu.Framework.Android; +using osu.Game.Database; +using osu.Android.Native; namespace osu.Android { [Activity(ConfigurationChanges = DEFAULT_CONFIG_CHANGES, Exported = true, LaunchMode = DEFAULT_LAUNCH_MODE, MainLauncher = true)] - [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")] - [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")] - [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*", DataMimeType = "*/*")] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osz", DataHost = "*", DataMimeType = "*/*")] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osk", DataHost = "*", DataMimeType = "*/*")] + [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\.osr", DataHost = "*", DataMimeType = "*/*")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")] @@ -39,14 +41,10 @@ namespace osu.Android "application/x-osu-replay", })] [IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryBrowsable, Intent.CategoryDefault }, DataSchemes = new[] { "osu", "osump" })] - public class OsuGameActivity : AndroidGameActivity + public class OsuGameActivity : AndroidGameActivity, ISurfaceHolderCallback { private static readonly string[] osu_url_schemes = { "osu", "osump" }; - /// - /// The default screen orientation. - /// - /// Adjusted on startup to match expected UX for the current device type (phone/tablet). public ScreenOrientation DefaultOrientation = ScreenOrientation.Unspecified; public new bool IsTablet { get; private set; } @@ -55,7 +53,7 @@ public class OsuGameActivity : AndroidGameActivity private bool gameCreated; - protected override Framework.Game CreateGame() + protected override osu.Framework.Game CreateGame() { if (gameCreated) throw new InvalidOperationException("Framework tried to create a game twice."); @@ -76,18 +74,9 @@ protected override void OnCreate(Bundle? savedInstanceState) { base.OnCreate(savedInstanceState); - // Initialise MAUI Essentials so that Battery, Connectivity and other platform - // APIs can resolve the current Activity/context. Without this call the - // BroadcastReceivers registered in the merged manifest (BatteryBroadcastReceiver, - // EnergySaverBroadcastReceiver, ConnectivityBroadcastReceiver) will crash on - // first use because the internal Platform.CurrentActivity is null. Microsoft.Maui.ApplicationModel.Platform.Init(this, savedInstanceState); + Window?.DecorView.Post(() => GetSurface()?.Holder?.AddCallback(this)); - - - // OnNewIntent() only fires for an activity if it's *re-launched* while it's on top of the activity stack. - // on first launch we still have to fire manually. - // reference: https://developer.android.com/reference/android/app/Activity#onNewIntent(android.content.Intent) handleIntent(Intent); if (Window != null) @@ -99,7 +88,7 @@ protected override void OnCreate(Bundle? savedInstanceState) if (WindowManager?.DefaultDisplay != null && Resources?.DisplayMetrics != null) { Point displaySize = new Point(); -#pragma warning disable CA1422 // GetSize is deprecated +#pragma warning disable CA1422 WindowManager.DefaultDisplay.GetSize(displaySize); #pragma warning restore CA1422 float smallestWidthDp = Math.Min(displaySize.X, displaySize.Y) / Resources.DisplayMetrics.Density; @@ -108,21 +97,10 @@ protected override void OnCreate(Bundle? savedInstanceState) RequestedOrientation = DefaultOrientation = IsTablet ? ScreenOrientation.FullUser : ScreenOrientation.SensorLandscape; - // Currently (SDK 6.0.200), BundleAssemblies is not runnable for net6-android. - // The assembly files are not available as files either after native AOT. - // Manually load them so that they can be loaded by RulesetStore.loadFromAppDomain. - // REMEMBER to fully uninstall previous version every time when investigating this! - // Don't forget osu.Game.Tests.Android too. foreach (string asm in new[] { "osu.Game.Rulesets.Osu", "osu.Game.Rulesets.Taiko", "osu.Game.Rulesets.Catch", "osu.Game.Rulesets.Mania" }) { - try - { - Assembly.Load(asm); - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Failed to load ruleset assembly {asm}: {e.Message}"); - } + try { Assembly.Load(asm); } + catch (Exception e) { Debug.WriteLine($"[osu!] Failed to load ruleset assembly {asm}: {e.Message}"); } } } @@ -136,64 +114,96 @@ public override void OnRequestPermissionsResult(int requestCode, string[] permis private void handleIntent(Intent? intent) { - if (intent == null) - return; + if (intent == null) return; switch (intent.Action) { case Intent.ActionDefault: if (intent.Scheme == ContentResolver.SchemeContent) { - if (intent.Data != null) - handleImportFromUris(intent.Data); + if (intent.Data != null) handleImportFromUris(intent.Data); } else if (osu_url_schemes.Contains(intent.Scheme)) { - if (intent.DataString != null) - game?.HandleLink(intent.DataString); + if (intent.DataString != null) game?.HandleLink(intent.DataString); } - break; case Intent.ActionSend: case Intent.ActionSendMultiple: - { - if (intent.ClipData == null) - break; - + if (intent.ClipData == null) break; var uris = new List(); - for (int i = 0; i < intent.ClipData.ItemCount; i++) { var item = intent.ClipData.GetItemAt(i); - if (item?.Uri != null) - uris.Add(item.Uri); + if (item?.Uri != null) uris.Add(item.Uri); } - handleImportFromUris(uris.ToArray()); break; - } } } private void handleImportFromUris(params Uri[] uris) => Task.Factory.StartNew(async () => { var tasks = new List(); - await Task.WhenAll(uris.Select(async uri => { var task = await AndroidImportTask.Create(ContentResolver!, uri).ConfigureAwait(false); + if (task != null) { lock (tasks) { tasks.Add(task); } } + })).ConfigureAwait(false); + if (game != null) await game.Import(tasks.ToArray()).ConfigureAwait(false); + }, TaskCreationOptions.LongRunning); + + private readonly System.Threading.ManualResetEventSlim surfaceEvent = new System.Threading.ManualResetEventSlim(false); + private IntPtr surfaceGlobalRef; + + public IntPtr GetSurfaceGlobalRef() + { + if (!surfaceEvent.Wait(5000)) + Debug.WriteLine("[osu!] Warning: Wait for surface timed out"); + return surfaceGlobalRef; + } + + public SurfaceView? GetSurface() => findSurfaceView(Window?.DecorView); - if (task != null) + private static SurfaceView? findSurfaceView(View? view) + { + if (view is SurfaceView surfaceView) return surfaceView; + if (view is ViewGroup group) + { + for (int i = 0; i < group.ChildCount; i++) { - lock (tasks) - { - tasks.Add(task); - } + var result = findSurfaceView(group.GetChildAt(i)); + if (result != null) return result; } - })).ConfigureAwait(false); + } + return null; + } - if (game != null) await game.Import(tasks.ToArray()).ConfigureAwait(false); - }, TaskCreationOptions.LongRunning); + public void SurfaceCreated(ISurfaceHolder holder) + { + var surface = holder.Surface; + if (surface != null && surface.Handle != IntPtr.Zero) + { + var handle = surface.Handle; + surfaceGlobalRef = global::Android.Runtime.JNIEnv.NewGlobalRef(handle); + surfaceEvent.Set(); + Debug.WriteLine("[osu!] Native surface JNI global reference created"); + } + } + + public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Format format, int width, int height) + { + } + + public void SurfaceDestroyed(ISurfaceHolder holder) + { + if (surfaceGlobalRef != IntPtr.Zero) + { + global::Android.Runtime.JNIEnv.DeleteGlobalRef(surfaceGlobalRef); + surfaceGlobalRef = IntPtr.Zero; + } + surfaceEvent.Reset(); + } } } diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index a1ed25af2fb6..28bd4b2cba12 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -1,27 +1,33 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using Android.Content; -using Android.Media; -using System; -using System.Linq; -using System.Runtime.CompilerServices; +#pragma warning disable CA1422 +#pragma warning restore CA1422 + using Android.App; using Android.Content.PM; +using Android.Content; +using Android.Media; using Android.Views; +using Debug = System.Diagnostics.Debug; using Microsoft.Maui.Devices; +using System.Linq; +using System.Runtime.CompilerServices; +using System; +using System.Diagnostics; +using osu.Android.Native; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Platform; -using osu.Game; using osu.Game.Configuration; using osu.Game.Screens; using osu.Game.Updater; using osu.Game.Utils; -using osu.Android.Native; +using osu.Game; using osuTK; -using Debug = System.Diagnostics.Debug; +using osu.Game.Performance; +using osu.Android.Performance; namespace osu.Android { @@ -67,7 +73,12 @@ public partial class OsuGameAndroid : OsuGame private readonly Bindable vulkanProbeEnabled = new Bindable(); private readonly BindableDouble audioOffset = new BindableDouble(); + [Cached(typeof(IHighPerformanceSessionManager))] + private readonly IHighPerformanceSessionManager highPerformanceSessionManager = new AndroidHighPerformanceSessionManager(); + private OboeAudioRedirector? audioRedirector; + private IntPtr updateAdpfSession; + private IntPtr renderAdpfSession; /// /// Boxed reference to the native bridge manager. @@ -128,9 +139,63 @@ private void load() audioRedirector = new OboeAudioRedirector(Audio); } + [MethodImpl(MethodImplOptions.AggressiveOptimization)] protected override void LoadComplete() { + // Pin the current thread (Update thread) to high-performance cores. + // On S23 Ultra, cores 3-7 are high-performance. Mask = 0xF8 (11111000 in binary) + try + { + if (OboeAudioBridge.nSetThreadAffinity(0xF8) != 0) + Debug.WriteLine("[osu!] Update thread pinned to big cores"); + + Scheduler.Add(() => + { + // Dispatch to the draw thread to pin it. + Host.DrawThread.Scheduler.Add(() => + { + try + { + if (OboeAudioBridge.nSetThreadAffinity(0xF8) != 0) + Debug.WriteLine("[osu!] Render thread pinned to big cores"); + } + catch { } + }); + }); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to pin update thread: {e.Message}"); + } + base.LoadComplete(); + System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency; + + try + { + // Target 1ms (1,000,000ns) for 1000 FPS target. + updateAdpfSession = OboeAudioBridge.nADPFCreateSession(1000000); + + Scheduler.Add(() => + { + Host.DrawThread.Scheduler.Add(() => + { + try + { + renderAdpfSession = OboeAudioBridge.nADPFCreateSession(1000000); + + if (renderAdpfSession != IntPtr.Zero) + Debug.WriteLine("[osu!] ADPF Performance Hint Session created for Render thread"); + } + catch { } + }); + }); + + if (updateAdpfSession != IntPtr.Zero) + Debug.WriteLine("[osu!] ADPF Performance Hint Session created for Update thread"); + } + catch { } + UserPlayingState.BindValueChanged(_ => updateOrientation()); performanceMode.BindValueChanged(e => @@ -147,17 +212,19 @@ protected override void LoadComplete() lowLatencyAudio.BindValueChanged(e => { - int hardwareSampleRate = 0; - try - { - if (gameActivity.GetSystemService(Context.AudioService) is AudioManager audioManager) + int hardwareSampleRate = 0; + try { - string? rateStr = audioManager.GetProperty(AudioManager.PropertyOutputSampleRate); - if (!string.IsNullOrEmpty(rateStr)) - hardwareSampleRate = int.Parse(rateStr); + if (gameActivity.GetSystemService(Context.AudioService) is AudioManager audioManager) + { + string? rateStr = audioManager.GetProperty(AudioManager.PropertyOutputSampleRate); + + if (!string.IsNullOrEmpty(rateStr)) + hardwareSampleRate = int.Parse(rateStr); + } } - } - catch { } + catch { } + try { if (e.NewValue) @@ -203,7 +270,7 @@ protected override void LoadComplete() } }, true); - // Apply unbuffered touch dispatch (deferred from Activity lifecycle to avoid early crash). + // Apply unbuffered touch dispatch. try { if (OperatingSystem.IsAndroidVersionAtLeast(31)) @@ -259,12 +326,11 @@ private void selectHighestRefreshRate() return; var display = windowManager.DefaultDisplay; + if (display == null) return; -#pragma warning disable CA1422 var modes = display.GetSupportedModes(); -#pragma warning restore CA1422 if (modes == null || modes.Length == 0) return; @@ -284,9 +350,6 @@ private void selectHighestRefreshRate() } catch (Exception e) { - // On some devices (e.g. Samsung S23 on Android 16), accessing display properties - // via the vendor property 'vendor.display.enable_optimal_refresh_rate' can trigger - // SELinux denials or crashes if the window is not yet fully trusted. Debug.WriteLine($"[osu!] Failed to apply preferred display mode: {e.Message}"); } }); @@ -297,19 +360,9 @@ private void selectHighestRefreshRate() } } - /// - /// Returns the measured audio output latency in milliseconds via the Oboe bridge, - /// or -1 if unavailable. Can be used to auto-suggest audio offset calibration. - /// - public double GetMeasuredAudioLatencyMs() - { - return getMeasuredAudioLatencyFromBridge(); - } - - // ── Native bridge helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━ - // Every method below is [MethodImplOptions.NoInlining] so that AndroidNativeBridgeManager - // (and its P/Invoke field types) are never resolved until explicitly called. + public bool IsVulkanRecommended() => (nativeBridges as AndroidNativeBridgeManager)?.IsVulkanRecommended() ?? false; + public double GetMeasuredAudioLatencyMs() => getMeasuredAudioLatencyFromBridge(); [MethodImpl(MethodImplOptions.NoInlining)] private void startOboeBridge(Action onLatencyMeasured, IntPtr provider, Action? onStarted = null) @@ -321,6 +374,7 @@ private void startOboeBridge(Action onLatencyMeasured, IntPtr provider, if (gameActivity.GetSystemService(Context.AudioService) is AudioManager audioManager) { string? rateStr = audioManager.GetProperty(AudioManager.PropertyOutputSampleRate); + if (!string.IsNullOrEmpty(rateStr)) hardwareSampleRate = int.Parse(rateStr); } @@ -332,6 +386,7 @@ private void startOboeBridge(Action onLatencyMeasured, IntPtr provider, if (nativeBridges is AndroidNativeBridgeManager mgr) mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider, hardwareSampleRate, onStarted); } + [MethodImpl(MethodImplOptions.NoInlining)] private void stopOboeBridge() { @@ -376,14 +431,11 @@ protected override void ScreenChanged(IOsuScreen? current, IOsuScreen? newScreen private void updateOrientation() { - // Read framework state on the update thread (the calling thread). - // ScreenStack may not be initialised yet during early LoadComplete callbacks. if (ScreenStack?.CurrentScreen is not IOsuScreen currentScreen) return; var orientation = MobileUtils.GetOrientation(this, currentScreen, gameActivity.IsTablet); - // Only the Android UI property assignment is dispatched to the main thread. gameActivity.RunOnUiThread(() => { try @@ -435,40 +487,43 @@ protected override void Dispose(bool isDisposing) if (nativeBridges != null) disposeNativeBridges(); - } - } - private class AndroidBatteryInfo : BatteryInfo - { - public override double? ChargeLevel - { - get + if (updateAdpfSession != IntPtr.Zero) { - try - { - return Battery.ChargeLevel; - } - catch (Exception) - { - return null; - } + OboeAudioBridge.nADPFCloseSession(updateAdpfSession); + updateAdpfSession = IntPtr.Zero; } - } - public override bool OnBattery - { - get + if (renderAdpfSession != IntPtr.Zero) { - try - { - return Battery.PowerSource == BatteryPowerSource.Battery; - } - catch (Exception) - { - return false; - } + OboeAudioBridge.nADPFCloseSession(renderAdpfSession); + renderAdpfSession = IntPtr.Zero; } } } + + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + protected override void UpdateAfterChildren() + { + if (updateAdpfSession == IntPtr.Zero) + { + base.UpdateAfterChildren(); + return; + } + + long startTime = Stopwatch.GetTimestamp(); + base.UpdateAfterChildren(); + long elapsedTicks = Stopwatch.GetTimestamp() - startTime; + long elapsedNanos = (elapsedTicks * 1000000000) / Stopwatch.Frequency; + + OboeAudioBridge.nADPFReportActualDuration(updateAdpfSession, elapsedNanos); + } + + } + + internal class AndroidBatteryInfo : BatteryInfo + { + public override double? ChargeLevel => Microsoft.Maui.Devices.Battery.ChargeLevel; + public override bool OnBattery => Microsoft.Maui.Devices.Battery.PowerSource == BatteryPowerSource.Battery; } } diff --git a/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs b/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs new file mode 100644 index 000000000000..34d04eb0a4f2 --- /dev/null +++ b/osu.Android/Performance/AndroidHighPerformanceSessionManager.cs @@ -0,0 +1,58 @@ +// 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.Runtime; +using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Logging; +using osu.Game.Performance; + +namespace osu.Android.Performance +{ + public class AndroidHighPerformanceSessionManager : IHighPerformanceSessionManager + { + public bool IsSessionActive => activeSessions > 0; + + private int activeSessions; + + private GCLatencyMode originalGCMode; + + public IDisposable BeginSession() + { + enterSession(); + return new InvokeOnDisposal(this, static m => m.exitSession()); + } + + private void enterSession() + { + if (Interlocked.Increment(ref activeSessions) > 1) + { + Logger.Log($"High performance session requested ({activeSessions} running in total)"); + return; + } + + 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() + { + if (Interlocked.Decrement(ref activeSessions) > 0) + { + Logger.Log($"High performance session finished ({activeSessions} others remain)"); + return; + } + + Logger.Log("Ending high performance session (Android)"); + + if (GCSettings.LatencyMode == GCLatencyMode.SustainedLowLatency) + GCSettings.LatencyMode = originalGCMode; + } + } +} diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index ba540580e88f..93996733889e 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -2,10 +2,10 @@ // See the LICENCE file in the repository root for full licence text. #nullable disable - using System; using System.Collections.Generic; using System.Diagnostics; + using System.IO; using System.Linq; using System.Threading; @@ -43,9 +43,9 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; +using osu.Game.Online.Chat; using osu.Game.Online; using osu.Game.Online.API.Requests; -using osu.Game.Online.Chat; using osu.Game.Online.Leaderboards; using osu.Game.Online.Rooms; using osu.Game.Overlays; @@ -81,6 +81,7 @@ using Sentry; using IntroScreen = osu.Game.Screens.Menu.IntroScreen; using MatchType = osu.Game.Online.Rooms.MatchType; +using System.Runtime.CompilerServices; namespace osu.Game { @@ -337,6 +338,7 @@ protected override UserInputManager CreateUserInputManager() { var userInputManager = base.CreateUserInputManager(); (userInputManager as OsuUserInputManager)?.PlayingState.BindTo(UserPlayingState); + return userInputManager; } @@ -485,6 +487,7 @@ public void HandleLink(LinkDetails link) => Schedule(() => break; case LinkAction.OpenBeatmapSet: + if (int.TryParse(argString, out int setId)) ShowBeatmapSet(setId); break; @@ -494,6 +497,7 @@ public void HandleLink(LinkDetails link) => Schedule(() => break; case LinkAction.SearchBeatmapSet: + if (link.Argument is LocalisableString localisable) SearchBeatmapSet(Localisation.GetLocalisedString(localisable)); else @@ -534,6 +538,7 @@ public void HandleLink(LinkDetails link) => Schedule(() => break; case LinkAction.OpenChangelog: + if (string.IsNullOrEmpty(argString)) ShowChangelogListing(); else @@ -545,6 +550,7 @@ public void HandleLink(LinkDetails link) => Schedule(() => break; case LinkAction.JoinRoom: + if (long.TryParse(argString, out long roomId)) JoinRoom(roomId); break; @@ -782,6 +788,7 @@ public void PresentMultiplayerMatch(Room room, string password) Activated = () => { OpenUrlExternally($@"/multiplayer/rooms/{room.RoomID}"); + return true; } }); @@ -1380,6 +1387,7 @@ private void forwardGeneralLogToNotifications(LogEntry entry) Activated = () => { Logger.Storage.PresentFileExternally(logFile); + return true; } })); @@ -1430,6 +1438,7 @@ private void forwardTabletLogToNotifications(LogEntry entry) Activated = () => { OpenUrlExternally("https://opentabletdriver.net/Tablets", LinkWarnMode.NeverWarn); + return true; } })); @@ -1510,11 +1519,13 @@ public bool OnPressed(KeyBindingPressEvent e) { case GlobalAction.DecreaseVolume: case GlobalAction.IncreaseVolume: + return volume.Adjust(e.Action); } // All actions below this point don't allow key repeat. if (e.Repeat) + return false; // Wait until we're loaded at least to the intro before allowing various interactions. @@ -1525,31 +1536,38 @@ public bool OnPressed(KeyBindingPressEvent e) case GlobalAction.ToggleMute: case GlobalAction.NextVolumeMeter: case GlobalAction.PreviousVolumeMeter: + return volume.Adjust(e.Action); case GlobalAction.ToggleFPSDisplay: fpsCounter.ToggleVisibility(); + return true; case GlobalAction.ToggleSkinEditor: skinEditor.ToggleVisibility(); + return true; case GlobalAction.ResetInputSettings: Host.ResetInputHandlers(); frameworkConfig.GetBindable(FrameworkSetting.ConfineMouseMode).SetDefault(); + return true; case GlobalAction.ToggleGameplayMouseButtons: var mouseDisableButtons = LocalConfig.GetBindable(OsuSetting.MouseDisableButtons); mouseDisableButtons.Value = !mouseDisableButtons.Value; + return true; case GlobalAction.ToggleProfile: + if (userProfile.State.Value == Visibility.Visible) userProfile.Hide(); else ShowUser(API.LocalUser.Value); + return true; case GlobalAction.RandomSkin: @@ -1557,23 +1575,31 @@ public bool OnPressed(KeyBindingPressEvent e) // This is mainly to stop many "osu! default (modified)" skins being created via the SkinManager.EnsureMutableSkin() path. // If people want this to work we can potentially avoid selecting default skins when the editor is open, or allow a maximum of one mutable skin somehow. if (skinEditor.State.Value == Visibility.Visible) + return false; SkinManager.SelectRandomSkin(); + return true; case GlobalAction.NextSkin: + if (skinEditor.State.Value == Visibility.Visible) + return false; SkinManager.SelectNextSkin(); + return true; case GlobalAction.PreviousSkin: + if (skinEditor.State.Value == Visibility.Visible) + return false; SkinManager.SelectPreviousSkin(); + return true; } @@ -1588,14 +1614,17 @@ public override bool OnPressed(KeyBindingPressEvent e) { case PlatformAction.ZoomIn: uiScale.Value += adjustment_increment; + return true; case PlatformAction.ZoomOut: uiScale.Value -= adjustment_increment; + return true; case PlatformAction.ZoomDefault: uiScale.SetDefault(); + return true; } @@ -1623,17 +1652,20 @@ public void OnReleased(KeyBindingReleaseEvent e) protected override bool OnExiting() { if (ScreenStack.CurrentScreen is Loader) + return false; if (introScreen?.DidLoadMenu == true && !(ScreenStack.CurrentScreen is IntroScreen)) { Scheduler.Add(introScreen.MakeCurrent); + return true; } return base.OnExiting(); } + [MethodImpl(MethodImplOptions.AggressiveOptimization)] protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); @@ -1648,6 +1680,7 @@ protected override void UpdateAfterChildren() // this avoids a visible jump in the positioning of the screen offset container. if (Settings.IsLoaded && Settings.IsPresent) horizontalOffset += Content.ToLocalSpace(Settings.ScreenSpaceDrawQuad.TopRight).X * SIDE_OVERLAY_OFFSET_RATIO; + if (Notifications.IsLoaded && Notifications.IsPresent) horizontalOffset += (Content.ToLocalSpace(Notifications.ScreenSpaceDrawQuad.TopLeft).X - Content.DrawWidth) * SIDE_OVERLAY_OFFSET_RATIO; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index f1cec99f38a0..ca258c4117c4 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -34,9 +34,9 @@ private void load(FrameworkConfigManager config, OsuConfigManager osuConfig, IDi Caption = GraphicsSettingsStrings.Renderer, Current = renderer, Items = host.GetPreferredRenderersForCurrentPlatform().Order() -#pragma warning disable CS0612 // Type or member is obsolete - .Where(t => t != RendererType.Vulkan && t != RendererType.OpenGLLegacy), -#pragma warning restore CS0612 // Type or member is obsolete +#pragma warning disable CS0612, CS0618 + .Where(t => t != RendererType.OpenGLLegacy), +#pragma warning restore CS0612, CS0618 }) { Keywords = new[] { @"compatibility", @"directx" }, diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index b4b039501f62..d3e483594600 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -226,17 +226,17 @@ protected override void LoadComplete() private void onListingReceived(Room[] result) { - Dictionary localRoomsById = roomListing.Rooms.ToDictionary(r => r.RoomID!.Value); - Dictionary resultRoomsById = result.ToDictionary(r => r.RoomID!.Value); + Dictionary localRoomsById = roomListing.Rooms.GroupBy(r => r.RoomID ?? -1).ToDictionary(g => g.Key, g => g.First()); + Dictionary resultRoomsById = result.GroupBy(r => r.RoomID ?? -1).ToDictionary(g => g.Key, g => g.First()); // Remove all local rooms no longer in the result set. - roomListing.Rooms.RemoveAll(r => !resultRoomsById.ContainsKey(r.RoomID!.Value)); + roomListing.Rooms.RemoveAll(r => !r.RoomID.HasValue || !resultRoomsById.ContainsKey(r.RoomID.Value)); // Add or update local rooms with the result set. foreach (var r in result) { - if (localRoomsById.TryGetValue(r.RoomID!.Value, out Room? existingRoom)) - existingRoom.CopyFrom(r); + if (localRoomsById.TryGetValue(r.RoomID ?? -1, out Room? existingRoom)) + existingRoom?.CopyFrom(r); else roomListing.Rooms.Add(r); }