From 90dc6446bd77dc72a3c6c20a91f8ab0653943da7 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 23 May 2026 18:36:38 +0000 Subject: [PATCH 1/4] fix: prevent black screen by setting surface event before format change When the surface is born as RGB565 on Vulkan, the RGB565 guard in SurfaceChanged calls SetFormat(RGBA8888) to trigger a surface recreate. Previously, this reset surfaceEvent and returned early, blocking the draw thread on surfaceEvent.Wait(5000) while the UI thread was stuck in the synchronous SetFormat call. This blocked the entire managed runtime from sending heartbeats, causing the native watchdog to fire at 10s and producing a black screen. The fix sets surfaceEvent BEFORE calling SetFormat, so the draw thread can proceed with the current (soon-to-be-recreated) surface. The new surface triggers another SurfaceChanged which sets surfaceEvent again with the correct RGBA8888 format. Fixes the issue where the APK shows a black screen after the last build. --- osu.Android/OsuGameActivity.cs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index b707046ca54c..62e6e17911ca 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -891,9 +891,6 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma setFormatPending = true; setFormatAttempts = 1; - // Log to Runtime so the mid-session RGB565 reset is visible in the main log - // (and therefore in the notification overlay). Performance log gets the same - // entry for correlation with display-mode and frame-timing data. string rgb565Message = "[osu!] Android surface pixel format RGB565 detected (Vulkan path) — " + "requesting RGBA8888 and triggering a surface recreate. " + @@ -902,6 +899,20 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma Logger.Log(rgb565Message, LoggingTarget.Runtime, LogLevel.Important); Logger.Log(rgb565Message, LoggingTarget.Performance, LogLevel.Important); + // Set the surface event BEFORE calling SetFormat so the draw thread can + // proceed with the current (soon-to-be-recreated) surface. Without this, + // the draw thread blocks on surfaceEvent.Wait(5000) while SetFormat triggers + // a synchronous surface teardown on the UI thread, blocking the entire + // managed runtime from sending heartbeats. The native watchdog then fires + // at 10s because no managed heartbeat is observed. + // The new surface will trigger another SurfaceChanged which will set + // surfaceEvent again with the correct RGBA8888 format. + if (width > 0 && height > 0) + { + surfaceEvent.Set(); + Debug.WriteLine($"[osu!] Native surface signal set before format change (size: {width}x{height})"); + } + try { holder.SetFormat(global::Android.Graphics.Format.Rgba8888); @@ -911,17 +922,7 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma Debug.WriteLine($"[osu!] Failed to request RGBA8888 format change for Vulkan: {e.Message}"); } - // The SetFormat call above queues a SurfaceDestroyed→SurfaceCreated cycle. - // Reset the surface event so GetSurfaceGlobalRef() does NOT unblock yet — - // the current Surface handle is about to be invalidated, and any caller that - // receives it would forward a dangling pointer into the Vulkan driver. - // The event will be re-set when SurfaceChanged fires again for the new - // RGBA8888 Surface; the normal-path surfaceEvent.Set() at the end of this - // method (lines below the if/else-if guard) handles that on the next call. - // We must NOT fall through to the width/height check, because that would - // signal the event with the old (about-to-die) surface dimensions. - surfaceEvent.Reset(); - Debug.WriteLine("[osu!] Native surface signal reset (RGB565→RGBA8888 format change pending)"); + Debug.WriteLine("[osu!] Native surface format change requested (RGB565→RGBA8888)"); return; } From 3acf7c6fe7ec0aeb778ad69c29daa5c5a87810bc Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 23 May 2026 18:58:45 +0000 Subject: [PATCH 2/4] fix: tick native watchdog before SetFormat to prevent black screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the surface is born as RGB565 on Vulkan, the RGB565 guard in SurfaceChanged calls SetFormat(RGBA8888) which triggers a synchronous surface teardown on the UI thread. This blocks the UI thread for hundreds of milliseconds, preventing the Update thread from sending heartbeats to the native watchdog. The native watchdog (10s default) then fires, killing the process and producing a black screen. The fix calls NativeWatchdog.Heartbeat() right before SetFormat to reset the watchdog timer from the UI thread. The native watchdog checks g_lastHeartbeatMonotonicSec which is updated by a simple atomic store — safe to call from any thread and never throws. This is safer than the previous approach of setting surfaceEvent before SetFormat, which introduced a data race between the draw thread reading surfaceGlobalRef and SurfaceDestroyed freeing it. --- osu.Android/OsuGameActivity.cs | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 62e6e17911ca..1eeab7fe2ced 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -21,6 +21,7 @@ using osu.Framework.Android; using osu.Game.Database; using osu.Framework.Logging; +using osu.Android.Native; namespace osu.Android { @@ -899,18 +900,25 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma Logger.Log(rgb565Message, LoggingTarget.Runtime, LogLevel.Important); Logger.Log(rgb565Message, LoggingTarget.Performance, LogLevel.Important); - // Set the surface event BEFORE calling SetFormat so the draw thread can - // proceed with the current (soon-to-be-recreated) surface. Without this, - // the draw thread blocks on surfaceEvent.Wait(5000) while SetFormat triggers - // a synchronous surface teardown on the UI thread, blocking the entire - // managed runtime from sending heartbeats. The native watchdog then fires - // at 10s because no managed heartbeat is observed. - // The new surface will trigger another SurfaceChanged which will set - // surfaceEvent again with the correct RGBA8888 format. - if (width > 0 && height > 0) + // Tick the native watchdog BEFORE calling SetFormat. SetFormat triggers a + // synchronous surface teardown on the UI thread that can block for hundreds + // of milliseconds. During this window the Update thread may not get a chance + // to send a heartbeat (especially during startup when the runtime is under + // heavy load), and the native watchdog (10s default) would fire, killing the + // process and producing a black screen. + // + // Calling Heartbeat() here resets the watchdog timer from the UI thread, + // giving SetFormat time to complete without triggering a false positive. + // The native watchdog checks g_lastHeartbeatMonotonicSec which is updated + // by a simple atomic store — safe to call from any thread. + try + { + NativeWatchdog.Heartbeat(); + Debug.WriteLine("[osu!] Native watchdog heartbeat ticked before SetFormat(RGBA8888)"); + } + catch (Exception e) { - surfaceEvent.Set(); - Debug.WriteLine($"[osu!] Native surface signal set before format change (size: {width}x{height})"); + Debug.WriteLine($"[osu!] NativeWatchdog.Heartbeat failed (non-fatal): {e.Message}"); } try From 2a91c3506e70c07bd066d8ac3565bf6738781d9c Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 23 May 2026 19:03:59 +0000 Subject: [PATCH 3/4] fix: restore surfaceEvent.Reset() after watchdog tick The previous commit removed surfaceEvent.Reset() which introduced a dangling pointer race: the draw thread could read surfaceGlobalRef while SurfaceDestroyed is freeing it. The fix keeps the NativeWatchdog.Heartbeat() call before SetFormat (to prevent the watchdog timeout) but restores the surfaceEvent.Reset() to prevent the draw thread from proceeding with a stale surface handle. The sequence is now: 1. Tick native watchdog (resets 10s timer) 2. surfaceEvent.Reset() (prevents draw thread from using stale handle) 3. SetFormat(RGBA8888) (triggers surface teardown) 4. return (prevents fall-through) The new surface triggers SurfaceChanged again which sets surfaceEvent with the correct RGBA8888 format. --- osu.Android/OsuGameActivity.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 1eeab7fe2ced..2b4d06ab73bf 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -931,6 +931,12 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma } Debug.WriteLine("[osu!] Native surface format change requested (RGB565→RGBA8888)"); + + // Reset the surface event so GetSurfaceGlobalRef() does NOT unblock with the + // old (about-to-be-invalidated) surface handle. The event will be re-set when + // SurfaceChanged fires again for the new RGBA8888 surface. + surfaceEvent.Reset(); + return; } From 1734d2c36f9e5c8c2e89cd7b788574ae5b64ac9e Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 23 May 2026 19:18:58 +0000 Subject: [PATCH 4/4] fix: default to OpenGL on fresh installs to prevent Vulkan black screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On fresh installs where framework.ini doesn't exist yet, write 'Renderer = OpenGL' as the default instead of letting the framework fall through to Vulkan (which is the default for Automatic on Android when the GPU reports Vulkan support). Vulkan causes black screens on several Adreno GPU families (7xx series in particular) because the Veldrid Vulkan backend either times out its 5s SurfaceHandle poll or hands a stale ANativeWindow to vkCreateAndroidSurfaceKHR. Plus the synchronous SetFormat(RGBA8888) call blocks the UI thread, preventing managed heartbeats and triggering the native watchdog. OpenGL ES is the safer default; users can switch to Vulkan in Settings → Graphics → Renderer if their device handles it well. Also includes the NativeWatchdog.Heartbeat() tick before SetFormat (from previous commit) as a safety net for devices that already have Vulkan configured. --- osu.Android/LogManagement.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/osu.Android/LogManagement.cs b/osu.Android/LogManagement.cs index a53af9f76d45..bf9305bb84e7 100644 --- a/osu.Android/LogManagement.cs +++ b/osu.Android/LogManagement.cs @@ -315,6 +315,25 @@ public static void NormaliseFrameworkIniRendererDefault() string sentinelPath = Path.Combine(root, renderer_migration_sentinel); if (File.Exists(sentinelPath)) return; + // On fresh installs (no framework.ini yet), default to OpenGL instead of Vulkan. + // Vulkan causes black screens on several Adreno GPU families (7xx series in + // particular) because the Veldrid Vulkan backend either times out its 5s + // SurfaceHandle poll or hands a stale ANativeWindow to vkCreateAndroidSurfaceKHR. + // OpenGL ES is the safer default; users can switch to Vulkan in Settings. + string iniPath = Path.Combine(root, "framework.ini"); + if (!File.Exists(iniPath)) + { + try + { + File.WriteAllText(iniPath, "Renderer = OpenGL" + Environment.NewLine); + Debug.WriteLine("[osu!] Fresh install — defaulted renderer to OpenGL."); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Could not write fresh-install renderer default: {e.Message}"); + } + } + tryDropSentinel(sentinelPath); } catch (Exception e)