diff --git a/archive.zip b/archive.zip
deleted file mode 100644
index ffb794c5ac3d..000000000000
Binary files a/archive.zip and /dev/null differ
diff --git a/native_crash.log b/native_crash.log
deleted file mode 100644
index f5b347eb902c..000000000000
--- a/native_crash.log
+++ /dev/null
@@ -1,97 +0,0 @@
-=== ALIVE [2026-04-22T20:07:05.4355817Z] Activity.OnCreate entry ===
-=== INSTALL_STATE sentinel=present log_path=/data/user/0/sh.ppy.osulazer/files/native_crash.log internal_dir=/data/user/0/sh.ppy.osulazer/files external_dir=/storage/emulated/0/Android/data/sh.ppy.osulazer/files ===
-=== ALIVE [2026-04-22T20:06:53.1523889Z] Activity.OnCreate exit ===
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (TypeInitializationException)
- utc_time = 2026-04-22T20:06:53.6451589Z
- thread_id = 16
- thread_name= .NET TP Worker
-
-System.TypeInitializationException: TypeInitialization_Type, HidSharp.Platform.MacOS.NativeMethods
- ---> System.EntryPointNotFoundException: CFStringCreateWithCharacters
- at HidSharp.Platform.MacOS.NativeMethods.CFStringCreateWithCharacters(String str)
- at HidSharp.Platform.MacOS.NativeMethods..cctor()
- Exception_EndOfInnerExceptionStack
-=== END OF MANAGED EXCEPTION ===
-
-=== ALIVE [2026-04-22T20:06:53.7134269Z] OsuGameAndroid.SetHost (GameHost.Run entry) ===
-=== ALIVE [2026-04-22T20:06:53.7140169Z] CrashDiagnostics.ReinstallNativeHandler (chained on top of Mono) ===
-=== ALIVE [2026-04-22T20:06:53.8160453Z] OsuGameAndroid.SetHost (base.SetHost returned) ===
-=== ALIVE [2026-04-22T20:07:05.4355817Z] Activity.OnCreate entry ===
-=== INSTALL_STATE sentinel=present log_path=/data/user/0/sh.ppy.osulazer/files/native_crash.log internal_dir=/data/user/0/sh.ppy.osulazer/files external_dir=/storage/emulated/0/Android/data/sh.ppy.osulazer/files ===
-=== ALIVE [2026-04-22T20:07:05.4686997Z] Activity.OnCreate exit ===
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (TypeInitializationException)
- utc_time = 2026-04-22T20:07:05.9448287Z
- thread_id = 17
- thread_name= .NET TP Worker
-
-System.TypeInitializationException: TypeInitialization_Type, HidSharp.Platform.MacOS.NativeMethods
- ---> System.EntryPointNotFoundException: CFStringCreateWithCharacters
- at HidSharp.Platform.MacOS.NativeMethods.CFStringCreateWithCharacters(String str)
- at HidSharp.Platform.MacOS.NativeMethods..cctor()
- Exception_EndOfInnerExceptionStack
-=== END OF MANAGED EXCEPTION ===
-
-=== ALIVE [2026-04-22T20:07:06.0273611Z] OsuGameAndroid.SetHost (GameHost.Run entry) ===
-=== ALIVE [2026-04-22T20:07:06.0279055Z] CrashDiagnostics.ReinstallNativeHandler (chained on top of Mono) ===
-=== ALIVE [2026-04-22T20:07:06.1205503Z] OsuGameAndroid.SetHost (base.SetHost returned) ===
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (VeldridException)
- utc_time = 2026-04-22T20:07:16.1441478Z
- thread_id = 44
- thread_name= Draw (GameThread)
-
-Veldrid.VeldridException: The Swapchain's underlying surface has been lost.
-=== END OF MANAGED EXCEPTION ===
-
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (VeldridException)
- utc_time = 2026-04-22T20:07:16.1982648Z
- thread_id = 44
- thread_name= Draw (GameThread)
-
-Veldrid.VeldridException: The Swapchain's underlying surface has been lost.
-=== END OF MANAGED EXCEPTION ===
-
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (VeldridException)
- utc_time = 2026-04-22T20:07:16.2001849Z
- thread_id = 44
- thread_name= Draw (GameThread)
-
-Veldrid.VeldridException: The Swapchain's underlying surface has been lost.
-=== END OF MANAGED EXCEPTION ===
-
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (VeldridException)
- utc_time = 2026-04-22T20:07:16.2132417Z
- thread_id = 44
- thread_name= Draw (GameThread)
-
-Veldrid.VeldridException: The Swapchain's underlying surface has been lost.
-=== END OF MANAGED EXCEPTION ===
-
-
-=========================================================
-=== MANAGED EXCEPTION ===
- source = FirstChanceException (VeldridException)
- utc_time = 2026-04-22T20:07:16.2136365Z
- thread_id = 44
- thread_name= Draw (GameThread)
-
-Veldrid.VeldridException: The Swapchain's underlying surface has been lost.
-=== END OF MANAGED EXCEPTION ===
-
diff --git a/osu.Android/CrashDiagnostics.cs b/osu.Android/CrashDiagnostics.cs
index db5d393012bf..6f5b9359cd25 100644
--- a/osu.Android/CrashDiagnostics.cs
+++ b/osu.Android/CrashDiagnostics.cs
@@ -69,6 +69,12 @@ internal static class CrashDiagnostics
/// Any ā typically the host Activity.
public static void InstallNativeHandler(Context context)
{
+ // Idempotent at the managed level: the native handler dedupes via its own
+ // g_installed flag, but we also avoid re-writing the sentinel and re-running
+ // the directory-resolution / P-Invoke path on repeat calls.
+ if (Interlocked.Exchange(ref initialised, 1) != 0)
+ return;
+
try
{
resolveDirs(context);
@@ -106,8 +112,6 @@ public static void InstallNativeHandler(Context context)
{
Debug.WriteLine($"[osu!] CrashDiagnostics.InstallNativeHandler outer failure: {e.Message}");
}
-
- Interlocked.Exchange(ref initialised, 1);
}
///
diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp
index 814ed330ee21..c326317a9991 100644
--- a/osu.Android/Native/oboe_bridge.cpp
+++ b/osu.Android/Native/oboe_bridge.cpp
@@ -196,6 +196,10 @@ bool OboeBridge::start() {
}
void OboeBridge::stop() {
+ // Signal any in-flight error-callback recovery (onErrorAfterClose ā
+ // reopenAndRestart) to bail out, so the bridge cannot be reopened from
+ // Oboe's internal thread while we are tearing it down from .NET.
+ disposing_.store(true);
active_.store(false);
std::lock_guard lock(streamLock_);
@@ -360,6 +364,16 @@ void OboeBridge::onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error
oboe::convertToText(error));
active_.store(false);
+ // Bail out immediately if a teardown is in flight: stop() has signalled
+ // that the bridge is being destroyed by .NET, and proceeding with the
+ // recovery path could leave us inside open()/requestStart() while the
+ // OboeBridge object is freed by the destructor.
+ if (disposing_.load()) {
+ std::lock_guard lock(streamLock_);
+ stream_.reset();
+ return;
+ }
+
if (error == oboe::Result::ErrorDisconnected) {
{
std::lock_guard lock(streamLock_);
@@ -378,9 +392,18 @@ void OboeBridge::onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error
}
bool OboeBridge::reopenAndRestart() {
+ // Re-check teardown after acquiring no-lock fast path: stop() may have
+ // been called between onErrorAfterClose's check and now.
+ if (disposing_.load()) return false;
+
if (open(requestedSampleRate_)) {
std::lock_guard lock(streamLock_);
+ if (disposing_.load()) {
+ stream_.reset();
+ return false;
+ }
+
if (stream_) {
oboe::Result result = stream_->requestStart();
diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h
index 0bb9a3b4744d..59bb57a98e19 100644
--- a/osu.Android/Native/oboe_bridge.h
+++ b/osu.Android/Native/oboe_bridge.h
@@ -53,6 +53,7 @@ class OboeBridge : public oboe::AudioStreamCallback {
mutable std::mutex streamLock_;
std::atomic active_{false};
+ std::atomic disposing_{false};
std::atomic latencyMs_{-1.0};
std::atomic callbackCount_{0};
std::atomic provider_{nullptr};
diff --git a/osu.Android/Native/vulkan_bridge.cpp b/osu.Android/Native/vulkan_bridge.cpp
index 9de3969e9e10..6a3d2f00a0e5 100644
--- a/osu.Android/Native/vulkan_bridge.cpp
+++ b/osu.Android/Native/vulkan_bridge.cpp
@@ -77,11 +77,12 @@ bool VulkanProbe::createInstance() {
bool VulkanProbe::queryDevice() {
uint32_t deviceCount = 0;
- vkEnumeratePhysicalDevices(instance_, &deviceCount, nullptr);
+ if (vkEnumeratePhysicalDevices(instance_, &deviceCount, nullptr) != VK_SUCCESS) return false;
if (deviceCount == 0) return false;
std::vector devices(deviceCount);
if (vkEnumeratePhysicalDevices(instance_, &deviceCount, devices.data()) != VK_SUCCESS) return false;
+ if (deviceCount == 0) return false;
VkPhysicalDevice selected = devices[0];
for (const auto& dev : devices) {
@@ -131,6 +132,7 @@ void VulkanProbe::queryQueueFamilies(VkPhysicalDevice device) {
uint32_t count = 0;
vkGetPhysicalDeviceQueueFamilyProperties(device, &count, nullptr);
deviceInfo_.queueFamilyCount = count;
+ if (count == 0) return;
std::vector families(count);
vkGetPhysicalDeviceQueueFamilyProperties(device, &count, families.data());
for (const auto& family : families) {
@@ -143,9 +145,10 @@ void VulkanProbe::queryQueueFamilies(VkPhysicalDevice device) {
void VulkanProbe::queryModernExtensions(VkPhysicalDevice device) {
uint32_t count = 0;
- vkEnumerateDeviceExtensionProperties(device, nullptr, &count, nullptr);
+ if (vkEnumerateDeviceExtensionProperties(device, nullptr, &count, nullptr) != VK_SUCCESS) return;
+ if (count == 0) return;
std::vector exts(count);
- vkEnumerateDeviceExtensionProperties(device, nullptr, &count, exts.data());
+ if (vkEnumerateDeviceExtensionProperties(device, nullptr, &count, exts.data()) != VK_SUCCESS) return;
for (const auto& ext : exts) {
if (strcmp(ext.extensionName, VK_KHR_SWAPCHAIN_EXTENSION_NAME) == 0) deviceInfo_.supportsSwapchain = true;
if (strcmp(ext.extensionName, VK_KHR_PRESENT_ID_EXTENSION_NAME) == 0) deviceInfo_.supportsPresentId = true;
diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs
index 645cf11df115..285de8590374 100644
--- a/osu.Android/OsuGameAndroid.cs
+++ b/osu.Android/OsuGameAndroid.cs
@@ -90,7 +90,7 @@ public partial class OsuGameAndroid : OsuGame
private object? nativeBridges;
///
- /// Last value passed to by
+ /// Last value passed to by
/// . Cached locally so we can short-circuit
/// redundant updates without round-tripping through the activity getter, which
/// itself performs a binder IPC on modern Android.
diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondController.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondController.cs
index fe5819bf1691..8809298f26a1 100644
--- a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondController.cs
+++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondController.cs
@@ -36,29 +36,42 @@ protected override void Update()
double latestValidTime = clock.CurrentTime;
double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate();
- // Timestamps are added in chronological order (from clock.CurrentTime),
- // so we can use binary-search-style trimming instead of per-element RemoveAt.
+ // Timestamps are appended at clock.CurrentTime which is *usually* monotonic, but
+ // gameplay rewinds (and replay seeks) can append a smaller value after a larger
+ // one ā so the list is not strictly sorted. We still scan from the end (where
+ // newly-appended entries live) to match the access pattern of the previous
+ // implementation, but we cannot stop early on either bound because an older
+ // out-of-order entry may live anywhere in the list.
+
+ // First pass: drop any timestamps now in the future (caused by rewinding).
+ // Walk backwards and shift surviving entries down in-place; this is O(n) and
+ // avoids the O(n²) RemoveAt-in-loop pattern of the original code.
+ int write = 0;
+
+ for (int read = 0; read < timestamps.Count; read++)
+ {
+ double t = timestamps[read];
+
+ if (t > latestValidTime)
+ continue;
- // Trim future timestamps caused by rewinding (remove from the end in one batch).
- // RemoveRange from the end is a single operation vs repeated RemoveAt calls.
- int trimStart = timestamps.Count;
+ if (write != read)
+ timestamps[write] = t;
- while (trimStart > 0 && timestamps[trimStart - 1] > latestValidTime)
- trimStart--;
+ write++;
+ }
- if (trimStart < timestamps.Count)
- timestamps.RemoveRange(trimStart, timestamps.Count - trimStart);
+ if (write < timestamps.Count)
+ timestamps.RemoveRange(write, timestamps.Count - write);
- // Count timestamps within the valid 1-second window.
- // Since the list is in chronological order, scan backwards until we leave the window.
+ // Count entries inside the 1-second window. Cannot break early because the list
+ // is not guaranteed sorted (see above), so scan all surviving timestamps.
int count = 0;
- for (int i = timestamps.Count - 1; i >= 0; i--)
+ for (int i = 0; i < timestamps.Count; i++)
{
- if (timestamps[i] < earliestTimeValid)
- break;
-
- count++;
+ if (timestamps[i] >= earliestTimeValid)
+ count++;
}
Value = count;