@@ -399,35 +399,32 @@ protected override void LoadComplete()
399399 // out CPU pinning and the next iteration can target the next suspect with
400400 // the heartbeat data captured below.
401401 //
402- // Vulkan-renderer override: when the user has selected Vulkan, ALSO skip
403- // big-core affinity pinning of the Draw + Input threads, and ALSO skip
404- // the LITTLE-core affinity pin of background workers further below
405- // (only the renice-to-zero pass runs). Rationale: the Adreno / Mali /
406- // Xclipse Vulkan driver spawns its own internal worker pool during
407- // vkCreateInstance / vkCreateSwapchainKHR, and those workers are NOT
408- // in our keep-alone list (we only know a subset of vendor-specific
409- // comm names). Pinning the Draw thread to a fixed 5-core subset while
410- // the driver workers are simultaneously demoted to the inverse 3-core
411- // LITTLE subset reliably stalls vkQueuePresentKHR — the field-observed
412- // failure mode is exactly "Update tick 1, Draw tick 0" with the Draw
413- // thread blocked inside Veldrid's swapchain present queue and never
414- // reaching the post-LoadComplete heartbeat lambda. On OpenGL/ANGLE the
415- // same pinning is harmless (ANGLE is single-threaded on the GL driver
416- // side) — hence the renderer-conditional override here rather than a
417- // blanket policy change.
402+ // Vulkan background-worker affinity note: the LITTLE-core affinity pin
403+ // of background workers is still skipped for Vulkan (littleMask = 0
404+ // below). The Adreno / Mali / Xclipse driver spawns internal worker
405+ // threads whose comm names are not in our keep-alone list; if we push
406+ // all unknown workers to the LITTLE subset those driver threads end up
407+ // on slow cores and stall vkQueuePresentKHR. Renice-to-zero only is the
408+ // correct policy for background workers on Vulkan.
418409 //
419- // The framework-side root cause (vkAcquireNextImageKHR with no timeout
420- // → indefinite block when the swapchain is in a transient lost state)
421- // lives in winnerspiros/veldrid `copilot/fix-vkacquirenextimage-deadlock`
422- // and needs to be published as a new ppy.Veldrid NuGet for upstream
423- // consumption. These overrides are the largest application-layer
424- // mitigation we can apply until that lands.
410+ // Draw + Input threads CAN now be pinned to big cores, for two reasons:
411+ // 1. The workers are NOT pushed to little cores (littleMask = 0), so
412+ // Adreno driver threads remain free to run on any core. The original
413+ // stall was specifically the combination of Draw-on-big + workers-
414+ // on-little; with only the Draw pin active the driver workers are
415+ // unaffected.
416+ // 2. Veldrid now has a 100 ms bounded vkAcquireNextImageKHR timeout
417+ // (since ppy.osu.Framework 2026.503.1). Any residual contention
418+ // is capped to one 100 ms stall rather than an indefinite hang.
419+ // Pinning Draw to big cores significantly improves GPU command-recording
420+ // throughput and texture-upload burst performance — the primary cause of
421+ // the 35-40 fps observed in steady-state Vulkan gameplay.
425422 bool vulkanConfigured = false ;
426423 try { vulkanConfigured = LogManagement . IsVulkanConfigured ( ) ; }
427424 catch ( Exception e ) { Debug . WriteLine ( $ "[osu!] IsVulkanConfigured probe failed: { e . Message } ") ; }
428425
429426 if ( vulkanConfigured )
430- Logger . Log ( "[osu!] Vulkan renderer detected from framework.ini — backing off Draw/Input big-core pinning and background- worker LITTLE-core pinning to keep Adreno/Mali driver workers schedulable." , LoggingTarget . Performance ) ;
427+ Logger . Log ( "[osu!] Vulkan renderer detected from framework.ini — pinning Draw/Input to big cores ( worker LITTLE-core pin still skipped to keep Adreno/Mali driver workers schedulable) ." , LoggingTarget . Performance ) ;
431428
432429 int affinityMask ;
433430
@@ -477,11 +474,10 @@ protected override void LoadComplete()
477474 // elevation is what causes the inversion. Default SDL-set priorities are
478475 // sufficient and match upstream osu! / osu-framework behaviour.
479476
480- // On Vulkan, do NOT pin the Draw or Input threads to big cores — see the
481- // top-of-LoadComplete comment. The Update thread alone keeps its pin (it
482- // doesn't directly contend with the GPU driver workers) so we still benefit
483- // from kernel-clock stability on the game-loop tick.
484- int mask = vulkanConfigured ? 0 : affinityMask ;
477+ // Pin Draw + Input to big cores on all renderers.
478+ // For Vulkan, see the comment above: workers are NOT pushed to little
479+ // cores, so Adreno driver threads remain schedulable on any core.
480+ int mask = affinityMask ;
485481
486482 if ( mask != 0 )
487483 {
@@ -594,21 +590,15 @@ protected override void LoadComplete()
594590 // Always select the highest refresh rate on startup, regardless of performance mode.
595591 // This ensures 120Hz+ displays are used at their native rate.
596592 //
597- // Deferred by 5 s after LoadComplete so the initial display-mode change runs
598- // AFTER the Vulkan swapchain has stabilised, the loader screen is up, and the
599- // first burst of texture uploads (Toolbar et al.) has drained off the Draw
600- // thread. On Samsung One UI / Adreno panels, writing PreferredDisplayModeId
601- // and Surface.SetFrameRate during the cold-start swapchain bring-up can force
602- // a non-seamless mode change that destroys the SurfaceView and stalls
603- // vkAcquireNextImageKHR on the Draw thread; Update keeps ticking (so neither
604- // the managed nor the native watchdog ever dumps), the screen never updates,
605- // and ~10 s later Android raises a MotionEvent input-dispatch ANR — the
606- // exact "cold-start black screen, no sound, no touch, ANR" pattern observed
607- // in field reports. Deferring the initial call moves the mode change
608- // out of the cold-start critical window; user-driven changes via the
609- // SelectedDisplayRefreshRate dropdown and OnConfigurationChanged (DeX
610- // connect/disconnect, rotation) remain immediate because they happen long
611- // after the swapchain has settled.
593+ // Deferred by 5 s after LoadComplete so the initial Surface.setFrameRate call
594+ // runs AFTER the Vulkan swapchain has stabilised and the first burst of texture
595+ // uploads (Toolbar et al.) has drained off the Draw thread.
596+ //
597+ // Note: applyDisplayMode no longer writes window.Attributes.PreferredDisplayModeId
598+ // (see that method's comment). Previously that write was the main reason for the
599+ // cold-start ANR (non-seamless SurfaceView destruction mid-swapchain); the delay
600+ // is retained as a safety margin for Surface.setFrameRate even though its
601+ // ONLY_IF_SEAMLESS flag makes surface destruction unlikely.
612602 //
613603 // Under crash-loop safe-mode (previous launch died during startup) the delay
614604 // is extended to 15 s so a slow-loading device that needed >5 s to drain
@@ -1357,55 +1347,43 @@ private void applyRefreshRate(int targetHz)
13571347
13581348 private void applyDisplayMode ( global ::Android . Views . Display display , global ::Android . Views . Display . Mode mode )
13591349 {
1360- var window = gameActivity . Window ;
1361-
1362- if ( window == null )
1363- return ;
1364-
13651350 gameActivity . RunOnUiThread ( ( ) =>
13661351 {
13671352 try
13681353 {
1369- if ( window . Attributes is WindowManagerLayoutParams layoutParams )
1370- {
1371- layoutParams . PreferredDisplayModeId = mode . ModeId ;
1372- window . Attributes = layoutParams ;
1373- currentRefreshRate = ( int ) mode . RefreshRate ;
1374-
1375- // Set frame rate at the surface level for better compositor scheduling.
1376- // FRAME_RATE_COMPATIBILITY_FIXED_SOURCE tells Android we render at a
1377- // fixed rate; CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS restricts the request
1378- // to mode changes the platform can perform without blanking the display
1379- // and recreating the SurfaceView's backing buffers.
1380- //
1381- // We previously passed CHANGE_FRAME_RATE_ALWAYS, which permits the
1382- // compositor to perform a non-seamless transition. On Samsung One UI /
1383- // Adreno panels that path momentarily destroys the SurfaceView and
1384- // invalidates the active VkSurfaceKHR; if it lands while the Draw
1385- // thread is mid-swapchain (e.g. during the cold-start texture-upload
1386- // burst), vkAcquireNextImageKHR can stall the present queue
1387- // indefinitely. Update keeps ticking (heartbeats fire, neither the
1388- // managed nor the native watchdog ever dumps), the screen never
1389- // updates, and ~10 s later Android raises a MotionEvent input-dispatch
1390- // ANR — the "cold-start black screen, no sound, no touch, ANR" pattern
1391- // observed in field reports. The seamless-only restriction keeps the
1392- // 120 Hz request honoured when the panel can do it without a surface
1393- // tear, and silently no-ops otherwise; either outcome is visually
1394- // unchanged but the swapchain stays alive.
1395- try
1396- {
1397- var surface = gameActivity . GetSurface ( ) ? . Holder ? . Surface ;
1354+ currentRefreshRate = ( int ) mode . RefreshRate ;
13981355
1399- if ( surface != null && surface . IsValid )
1400- surface . SetFrameRate ( mode . RefreshRate , FRAME_RATE_COMPATIBILITY_FIXED_SOURCE , CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS ) ;
1401- }
1402- catch
1403- {
1404- // Surface.SetFrameRate may not be available on all binding versions.
1405- }
1356+ // Request the refresh rate via Surface.setFrameRate() ONLY.
1357+ //
1358+ // We deliberately do NOT touch window.Attributes.PreferredDisplayModeId.
1359+ // Setting PreferredDisplayModeId asks the compositor to switch the display
1360+ // to a specific hardware mode. On Samsung One UI / Adreno devices this
1361+ // triggers a non-seamless transition even when CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
1362+ // is passed to SetFrameRate — it momentarily destroys the SurfaceView and
1363+ // invalidates the active VkSurfaceKHR. When that surface loss lands while
1364+ // the Draw thread is mid-render it causes the Veldrid swapchain to enter
1365+ // its surface-lost recovery path, producing visual corruption (multiple
1366+ // overlaid layers, missing textures, tiled frames) and a sustained FPS
1367+ // drop until the swapchain is fully rebuilt.
1368+ //
1369+ // Surface.setFrameRate(FIXED_SOURCE, ONLY_IF_SEAMLESS) is the correct
1370+ // API on Android 11+ (minSdkVersion=33) for requesting a refresh-rate
1371+ // change: the platform honours it without a surface tear when possible
1372+ // and silently no-ops when a seamless switch isn't available — the
1373+ // swapchain is never touched either way.
1374+ try
1375+ {
1376+ var surface = gameActivity . GetSurface ( ) ? . Holder ? . Surface ;
14061377
1407- Logger . Log ( $ "[osu!] Display mode applied: { mode . RefreshRate } Hz (mode { mode . ModeId } , { mode . PhysicalWidth } x{ mode . PhysicalHeight } )", LoggingTarget . Performance ) ;
1378+ if ( surface != null && surface . IsValid )
1379+ surface . SetFrameRate ( mode . RefreshRate , FRAME_RATE_COMPATIBILITY_FIXED_SOURCE , CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS ) ;
14081380 }
1381+ catch
1382+ {
1383+ // Surface.SetFrameRate may not be available on all binding versions.
1384+ }
1385+
1386+ Logger . Log ( $ "[osu!] Display mode applied: { mode . RefreshRate } Hz (mode { mode . ModeId } , { mode . PhysicalWidth } x{ mode . PhysicalHeight } )", LoggingTarget . Performance ) ;
14091387 }
14101388 catch ( Exception e )
14111389 {
0 commit comments