Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,21 @@ jobs:
# - { prettyname: macOS, fullname: macos-latest }
- { prettyname: Linux, fullname: ubuntu-latest }
threadingMode: ['MultiThreaded']
timeout-minutes: 120
# Split tests into two parallel groups so Windows runners (which are ~2-3× slower than
# Linux) finish each group in ~60 min rather than timing out on the full suite at 120 min.
testSuite:
- name: game
dlls: >-
osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll
osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll
Templates/**/*.Tests/bin/Debug/**/*.Tests.dll
- name: rulesets
dlls: >-
osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll
osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -116,14 +130,8 @@ jobs:
continue-on-error: true
run: >
dotnet test
osu.Game.Tests/bin/Debug/**/osu.Game.Tests.dll
osu.Game.Rulesets.Osu.Tests/bin/Debug/**/osu.Game.Rulesets.Osu.Tests.dll
osu.Game.Rulesets.Taiko.Tests/bin/Debug/**/osu.Game.Rulesets.Taiko.Tests.dll
osu.Game.Rulesets.Catch.Tests/bin/Debug/**/osu.Game.Rulesets.Catch.Tests.dll
osu.Game.Rulesets.Mania.Tests/bin/Debug/**/osu.Game.Rulesets.Mania.Tests.dll
osu.Game.Tournament.Tests/bin/Debug/**/osu.Game.Tournament.Tests.dll
Templates/**/*.Tests/bin/Debug/**/*.Tests.dll
--logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx"
${{matrix.testSuite.dlls}}
--logger "trx;LogFileName=TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.testSuite.name}}.trx"
--
NUnit.ConsoleOut=0

Expand All @@ -133,8 +141,8 @@ jobs:
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}
path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx
name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.testSuite.name}}
path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}-${{matrix.testSuite.name}}.trx

test-results:
name: Test results
Expand Down
40 changes: 33 additions & 7 deletions osu.Android/OsuGameActivity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,29 @@ protected override void OnCreate(Bundle? savedInstanceState)
LogManagement.WipeShaderCacheOnceForVersion();
CrashDiagnostics.WriteAliveMarker("LogManagement.WipeShaderCacheOnceForVersion (returned)");

// Stamp RGBA8888 at the Window level BEFORE SDL creates its SurfaceView inside
// base.OnCreate(). Android's default SurfaceView pixel format on many high-density
// Samsung / Qualcomm panels is RGB565. SDL3 only calls SurfaceHolder.setFormat(
// RGBA8888) for the OpenGL path — the Vulkan path inherits the window default.
// Setting the format here, before SDL attaches its SurfaceView, ensures the
// SurfaceView is born with RGBA8888 and eliminates the format-change teardown
// (SurfaceHolder.SetFormat in DecorView.Post) that otherwise fires mid-Vulkan-init
// and can produce the "Draw thread did not acknowledge teardown within 250ms" warning.
// The DecorView.Post call and the SurfaceChanged reactive guard are retained as
// belt-and-braces fallbacks for timing windows or OEM variants where this hint is
// not honoured by the SurfaceView allocation path.
if (LogManagement.IsVulkanConfigured())
{
try
{
Window?.SetFormat(global::Android.Graphics.Format.Rgba8888);
}
catch (Exception e)
{
Debug.WriteLine($"[osu!] Pre-SDL Window.SetFormat(RGBA8888) failed (non-fatal): {e.Message}");
}
}

base.OnCreate(savedInstanceState);

// Wrap Platform.Init defensively: MAUI Essentials pulls in workload-version-sensitive
Expand Down Expand Up @@ -660,13 +683,16 @@ public void SurfaceChanged(ISurfaceHolder holder, global::Android.Graphics.Forma
// picks up the new ANativeWindow and negotiates a proper BGRA/RGBA 8-bit swapchain.
if (format == global::Android.Graphics.Format.Rgb565 && LogManagement.IsVulkanConfigured())
{
Logger.Log(
"[osu!] Android surface pixel format RGB565 is incompatible with the Vulkan rendering pipeline " +
"— requesting RGBA8888 and triggering a surface recreate. " +
"This is the root cause of the Vulkan black-screen crash on Adreno (SDL_PIXELFORMAT_RGB565 in runtime log). " +
"The next SurfaceChanged will carry the corrected format.",
LoggingTarget.Performance,
LogLevel.Important);
// 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. " +
"If this fires after startup an OEM display-mode change has reset the surface format, " +
"which would cause a mid-session swapchain rebuild at wrong dimensions.";
Logger.Log(rgb565Message, LoggingTarget.Runtime, LogLevel.Important);
Logger.Log(rgb565Message, LoggingTarget.Performance, LogLevel.Important);

try
{
Expand Down
68 changes: 27 additions & 41 deletions osu.Android/OsuGameAndroid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,8 @@ protected override void LoadComplete()
//
// Pinning Update + Draw + Input to a 5-core subset (mask 0xF8 on SD8G2) is the
// ONLY unconditional Android-specific synchronous mutation we still perform
// during the cold-start window — every other customisation (SustainedPerformanceMode,
// RequestUnbufferedDispatch, refresh-rate selection, Oboe / Vulkan-probe init,
// during the cold-start window — every other customisation (RequestUnbufferedDispatch,
// refresh-rate selection, Oboe / Vulkan-probe init,
// performance-mode GC-latency flip) is already deferred behind the
// refreshRateDelayMs scheduler below. Field logs.zip on v2026.423.176 show both
// a normal launch and a safe-mode launch dying silently mid-Toolbar load
Expand Down Expand Up @@ -569,21 +569,28 @@ protected override void LoadComplete()
Debug.WriteLine($"[osu!] TameBackgroundThreads (initial) failed: {e.Message}");
}

// Sustained performance mode is applied LATER, together with the deferred
// display-mode / GC-latency work below. See the Scheduler.AddDelayed block
// further down (after base.LoadComplete()) that schedules the first apply
// on a refreshRateDelayMs timer. Running
// Window.SetSustainedPerformanceMode(true) synchronously here — during the
// Toolbar cold-start texture-upload burst and the Vulkan swapchain bring-up —
// has been observed to race the Draw thread on Samsung One UI / Adreno panels:
// the window-flag mutation round-trips through ViewRootImpl.setPrivateFlags
// and can partially reconfigure the Surface while vkAcquireNextImageKHR is in
// flight, stalling the present queue. Update keeps ticking (so neither the
// managed nor the native watchdog ever dumps), the screen never updates, and
// ~10 s later Android raises a MotionEvent input-dispatch ANR — the exact
// cold-start "black screen → no touch → ANR" fingerprint reported across
// multiple v174 launches in logs.zip. Deferring to the same window used by
// SelectHighestRefreshRate moves the mutation behind the texture-upload burst.
// Window.SetSustainedPerformanceMode is intentionally NOT called anywhere.
//
// On Samsung One UI / Adreno devices, calling SetSustainedPerformanceMode(true)
// triggers a non-seamless display-mode transition (even when deferred behind the
// texture-upload burst). The transition momentarily destroys the SurfaceView,
// which resets the surface pixel format back to the Android default (RGB565 on
// high-density Samsung panels). Our SurfaceChanged reactive guard then calls
// SurfaceHolder.SetFormat(RGBA8888), causing a second surface-destroy/recreate
// cycle. During this second cycle the ANativeWindow transiently reports the
// display's scaled (dp) dimensions — 1029×480 on a 3088×1440 3×-density panel —
// instead of the physical pixel dimensions. Veldrid reads those dimensions from
// vkGetPhysicalDeviceSurfaceCapabilitiesKHR during its VkSurfaceKHR-loss
// recovery, creates a permanent swapchain at 1029×480, and SurfaceFlinger tiles
// that sub-screen image 3×3 to fill the display. The result is the "9 screens"
// artifact, blurry/flashing textures, and a sustained FPS drop observed on
// Galaxy S24 Ultra (Adreno 740, One UI 7, Android 15) with Vulkan enabled.
//
// Removing the call eliminates the mid-session surface teardown. ADPF performance
// hinting is already provided by Oboe's setPerformanceHintEnabled(true) (set
// during stream open in oboe_bridge.cpp), and GC low-latency is handled by
// AndroidHighPerformanceSessionManager (SustainedLowLatency GCSettings) which
// covers the same thermal/responsiveness goals without touching the Surface.

base.LoadComplete();

Expand Down Expand Up @@ -688,27 +695,6 @@ protected override void LoadComplete()
Debug.WriteLine($"[osu!] Deferred SelectHighestRefreshRate failed: {ex.Message}");
}

// Deferred sustained-performance-mode apply. See the comment block
// before base.LoadComplete() above for the rationale (Samsung One UI /
// Adreno Surface reconfigure race with vkAcquireNextImageKHR during the
// cold-start texture-upload burst). By the time this fires the
// swapchain has long since stabilised.
try
{
gameActivity.RunOnUiThread(() =>
{
try { gameActivity.Window?.SetSustainedPerformanceMode(true); }
catch (Exception e)
{
Debug.WriteLine($"[osu!] Failed to enable sustained performance mode: {e.Message}");
}
});
}
catch (Exception e)
{
Debug.WriteLine($"[osu!] Failed to dispatch sustained performance mode toggle to UI thread: {e.Message}");
}

// Deferred initial application of the user's performance-mode setting.
// The BindValueChanged registration below is WITHOUT the immediate-fire
// flag, so the very first apply (which may flip GCSettings.LatencyMode
Expand Down Expand Up @@ -1033,8 +1019,7 @@ protected override void LoadComplete()
// the BDL load thread, in the silent cold-start window — exactly
// when we are debugging a startup hang. Deferring the initial
// fire via Scheduler.AddDelayed onto the same refreshRateDelayMs
// timer that gates SustainedPerformanceMode / the initial refresh-
// rate apply / the initial performance-mode apply keeps the cold-
// timer that gates the initial refresh-rate apply / performance-mode apply keeps the cold-
// start path free of synchronous native init even when a saved-
// true setting would otherwise force it, AND ensures the native
// init actually lands AFTER the cold-start Toolbar texture-upload
Expand Down Expand Up @@ -1149,8 +1134,9 @@ private void applyPerformanceOptimizations(bool enabled)
{
try
{
// Sustained performance mode is always on (set in LoadComplete).
// The performance toggle controls the high-perf GC session only.
// (Window.SetSustainedPerformanceMode is intentionally not called —
// see the comment before base.LoadComplete() for the full rationale.)
if (enabled)
{
highPerformanceSession ??= highPerformanceSessionManager.BeginSession();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,19 @@ private async Task fetchFinalScores()
};

// Should complete instantaneously due to prior lookups.
APIBeatmap beatmap = (await beatmapLookupCache.GetBeatmapAsync(globalBeatmap.Value.BeatmapInfo.OnlineID).ConfigureAwait(false))!;
// GetBeatmapAsync can return null if the online ID is unknown (e.g. in tests or
// when the API is unavailable); fall back to a placeholder rather than crashing.
APIBeatmap? beatmap = await beatmapLookupCache.GetBeatmapAsync(globalBeatmap.Value.BeatmapInfo.OnlineID).ConfigureAwait(false);
beatmap ??= new APIBeatmap
{
BeatmapSet = new APIBeatmapSet
{
Title = "unknown beatmap",
TitleUnicode = "unknown beatmap",
Artist = "unknown artist",
ArtistUnicode = "unknown artist",
}
};

Schedule(() =>
{
Expand Down
Loading