@@ -367,31 +367,21 @@ protected override void LoadComplete()
367367 Logger . Log ( $ "[osu!] Failed to pin threads: { e . Message } ", LoggingTarget . Performance ) ;
368368 }
369369
370- // Always enable sustained performance mode for consistent frame delivery.
371- // This prevents thermal throttling from causing sudden FPS drops.
372- //
373- // This MUST run on the Android UI thread: SetSustainedPerformanceMode mutates
374- // window state through ViewRootImpl, which enforces single-threaded access via
375- // checkThread() and throws CalledFromWrongThreadException otherwise. On some
376- // OEM frameworks (observed on Samsung One UI / Adreno) that exception unwinds
377- // through setPrivateFlags after the underlying Surface has already been
378- // partially reconfigured, invalidating the active VkSurfaceKHR and crashing the
379- // Vulkan driver inside vkCmdBeginRendering on the next frame.
380- try
381- {
382- gameActivity . RunOnUiThread ( ( ) =>
383- {
384- try { gameActivity . Window ? . SetSustainedPerformanceMode ( true ) ; }
385- catch ( Exception e )
386- {
387- Debug . WriteLine ( $ "[osu!] Failed to enable sustained performance mode: { e . Message } ") ;
388- }
389- } ) ;
390- }
391- catch ( Exception e )
392- {
393- Debug . WriteLine ( $ "[osu!] Failed to dispatch sustained performance mode toggle to UI thread: { e . Message } ") ;
394- }
370+ // Sustained performance mode is applied LATER, together with the deferred
371+ // display-mode / GC-latency work below. See the Scheduler.AddDelayed block
372+ // further down (after base.LoadComplete()) that schedules the first apply
373+ // on a refreshRateDelayMs timer. Running
374+ // Window.SetSustainedPerformanceMode(true) synchronously here — during the
375+ // Toolbar cold-start texture-upload burst and the Vulkan swapchain bring-up —
376+ // has been observed to race the Draw thread on Samsung One UI / Adreno panels:
377+ // the window-flag mutation round-trips through ViewRootImpl.setPrivateFlags
378+ // and can partially reconfigure the Surface while vkAcquireNextImageKHR is in
379+ // flight, stalling the present queue. Update keeps ticking (so neither the
380+ // managed nor the native watchdog ever dumps), the screen never updates, and
381+ // ~10 s later Android raises a MotionEvent input-dispatch ANR — the exact
382+ // cold-start "black screen → no touch → ANR" fingerprint reported across
383+ // multiple v174 launches in logs.zip. Deferring to the same window used by
384+ // SelectHighestRefreshRate moves the mutation behind the texture-upload burst.
395385
396386 base . LoadComplete ( ) ;
397387
@@ -433,6 +423,78 @@ protected override void LoadComplete()
433423 {
434424 Debug . WriteLine ( $ "[osu!] Deferred SelectHighestRefreshRate failed: { ex . Message } ") ;
435425 }
426+
427+ // Deferred sustained-performance-mode apply. See the comment block
428+ // before base.LoadComplete() above for the rationale (Samsung One UI /
429+ // Adreno Surface reconfigure race with vkAcquireNextImageKHR during the
430+ // cold-start texture-upload burst). By the time this fires the
431+ // swapchain has long since stabilised.
432+ try
433+ {
434+ gameActivity . RunOnUiThread ( ( ) =>
435+ {
436+ try { gameActivity . Window ? . SetSustainedPerformanceMode ( true ) ; }
437+ catch ( Exception e )
438+ {
439+ Debug . WriteLine ( $ "[osu!] Failed to enable sustained performance mode: { e . Message } ") ;
440+ }
441+ } ) ;
442+ }
443+ catch ( Exception e )
444+ {
445+ Debug . WriteLine ( $ "[osu!] Failed to dispatch sustained performance mode toggle to UI thread: { e . Message } ") ;
446+ }
447+
448+ // Deferred initial application of the user's performance-mode setting.
449+ // The BindValueChanged registration below is WITHOUT the immediate-fire
450+ // flag, so the very first apply (which may flip GCSettings.LatencyMode
451+ // to SustainedLowLatency via AndroidHighPerformanceSessionManager) is
452+ // done here, after the Toolbar texture-upload burst has drained. Running
453+ // it synchronously during LoadComplete suppresses gen-2 GCs while the
454+ // Draw thread is churning through hundreds of queued texture uploads,
455+ // causing the managed heap to balloon, the kernel to start paging
456+ // (VmSwap ~22 MB / RSS ~695 MB / memory-pressure avg10=1.34 observed in
457+ // the ANR dump), the Draw thread to stall on a page-fault burst, and
458+ // the main thread to miss its input-channel ACK deadline — another
459+ // contributor to the MotionEvent ANR fingerprint.
460+ try
461+ {
462+ applyPerformanceOptimizations ( performanceMode . Value ) ;
463+ }
464+ catch ( Exception ex )
465+ {
466+ Debug . WriteLine ( $ "[osu!] Deferred initial performance-mode apply failed: { ex . Message } ") ;
467+ }
468+
469+ // Deferred UI-thread RequestUnbufferedDispatch(sources). Moved here from
470+ // the bottom of LoadComplete so the DecorView attribute mutation no
471+ // longer races the cold-start Toolbar texture-upload burst. OnCreate
472+ // already requested unbuffered dispatch once (with a dummy MotionEvent),
473+ // and every per-pointer DispatchTouchEvent / DispatchGenericMotionEvent
474+ // re-requests it as needed, so this global set-sources call is only a
475+ // latency polish for the first few real touches after the burst — it
476+ // brings no benefit during the black-screen window but does take a
477+ // binder IPC round-trip through ViewRootImpl, which we do not want
478+ // competing with swapchain settle work.
479+ try
480+ {
481+ gameActivity . RunOnUiThread ( ( ) =>
482+ {
483+ try
484+ {
485+ int sources = ( int ) ( InputSourceType . Touchscreen | InputSourceType . Stylus | InputSourceType . Mouse | InputSourceType . Touchpad ) ;
486+ gameActivity . Window ? . DecorView ? . RequestUnbufferedDispatch ( sources ) ;
487+ }
488+ catch ( Exception e )
489+ {
490+ Debug . WriteLine ( $ "[osu!] Failed to request unbuffered touch dispatch: { e . Message } ") ;
491+ }
492+ } ) ;
493+ }
494+ catch ( Exception e )
495+ {
496+ Debug . WriteLine ( $ "[osu!] Failed to dispatch unbuffered-dispatch request to UI thread: { e . Message } ") ;
497+ }
436498 } , refreshRateDelayMs ) ;
437499
438500 // Clear the "startup in progress" sentinel once the current launch has
@@ -472,6 +534,11 @@ protected override void LoadComplete()
472534
473535 UserPlayingState . BindValueChanged ( _ => updateOrientation ( ) ) ;
474536
537+ // NOTE: no `true` (immediate-fire) flag — the initial apply is done inside
538+ // the refreshRateDelayMs scheduler above, so the cold-start texture-upload
539+ // burst completes under default GC latency. User-driven changes from the
540+ // settings dropdown (and the DeX auto-flip above, whose value is picked up
541+ // at defer-fire time) still take effect immediately via this subscription.
475542 performanceMode . BindValueChanged ( e =>
476543 {
477544 try
@@ -482,7 +549,7 @@ protected override void LoadComplete()
482549 {
483550 Debug . WriteLine ( $ "[osu!] Failed to toggle performance mode: { ex . Message } ") ;
484551 }
485- } , true ) ;
552+ } ) ;
486553
487554 // Layer 3b — Oboe and Vulkan-probe initial-bind handling.
488555 //
@@ -492,10 +559,18 @@ protected override void LoadComplete()
492559 // local config), that synchronous fire would do native init on
493560 // the BDL load thread, in the silent cold-start window — exactly
494561 // when we are debugging a startup hang. Deferring the initial
495- // fire via Scheduler (i.e. moving it onto the next Update tick on
496- // the Update thread, after the game has finished loading) keeps
497- // the cold-start path free of synchronous native init even when a
498- // saved-true setting would otherwise force it.
562+ // fire via Scheduler.AddDelayed onto the same refreshRateDelayMs
563+ // timer that gates SustainedPerformanceMode / the initial refresh-
564+ // rate apply / the initial performance-mode apply keeps the cold-
565+ // start path free of synchronous native init even when a saved-
566+ // true setting would otherwise force it, AND ensures the native
567+ // init actually lands AFTER the cold-start Toolbar texture-upload
568+ // burst has drained.
569+ //
570+ // (Prior implementations used plain Schedule(...), which only
571+ // defers to the next Update tick — milliseconds, still well inside
572+ // the burst. The comment correctly described the intent — "after
573+ // the game has finished loading" — but the code under-delivered.)
499574 //
500575 // Default: defer (safe). Toggle off in settings to restore the
501576 // original immediate-init behaviour for A/B testing.
@@ -512,7 +587,7 @@ protected override void LoadComplete()
512587
513588 if ( deferInit )
514589 {
515- Schedule ( ( ) =>
590+ Scheduler . AddDelayed ( ( ) =>
516591 {
517592 try
518593 {
@@ -523,7 +598,7 @@ protected override void LoadComplete()
523598 {
524599 Debug . WriteLine ( $ "[osu!] Deferred startup native init failed: { ex . Message } ") ;
525600 }
526- } ) ;
601+ } , refreshRateDelayMs ) ;
527602 }
528603 else
529604 {
@@ -538,25 +613,11 @@ protected override void LoadComplete()
538613 }
539614 }
540615
541- try
542- {
543- gameActivity . RunOnUiThread ( ( ) =>
544- {
545- try
546- {
547- int sources = ( int ) ( InputSourceType . Touchscreen | InputSourceType . Stylus | InputSourceType . Mouse | InputSourceType . Touchpad ) ;
548- gameActivity . Window ? . DecorView ? . RequestUnbufferedDispatch ( sources ) ;
549- }
550- catch ( Exception e )
551- {
552- Debug . WriteLine ( $ "[osu!] Failed to request unbuffered touch dispatch: { e . Message } ") ;
553- }
554- } ) ;
555- }
556- catch ( Exception e )
557- {
558- Debug . WriteLine ( $ "[osu!] Failed to schedule unbuffered dispatch: { e . Message } ") ;
559- }
616+ // NOTE: the trailing RequestUnbufferedDispatch(sources) that used to live
617+ // here has been moved into the refreshRateDelayMs Scheduler.AddDelayed block
618+ // above, so the DecorView attribute mutation lands after the cold-start
619+ // Toolbar texture-upload burst has drained. See the deferred block for the
620+ // full rationale.
560621 }
561622
562623 private void applyPerformanceOptimizations ( bool enabled )
0 commit comments