diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d36849248797..dc6a4e41691c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index 89d3941afc4f..394080d04c78 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -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 @@ -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 { diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 10f9e37654fa..e28b3f9926de 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -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 @@ -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(); @@ -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 @@ -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 @@ -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(); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs index 48149e5054b7..310e2559dcc8 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/RankedPlay/ResultsScreen.cs @@ -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(() => {