@@ -746,6 +746,117 @@ public static bool IsVulkanConfigured()
746746 return renderer != null && string . Equals ( renderer , "Vulkan" , StringComparison . OrdinalIgnoreCase ) ;
747747 }
748748
749+ /// <summary>
750+ /// Temporarily forces <c>FrameSync = VSync</c> in <c>framework.ini</c> when
751+ /// Vulkan is configured and the persisted value maps to IMMEDIATE present mode
752+ /// (i.e. <c>ActualUnlimited</c> or <c>Unlimited</c>).
753+ ///
754+ /// <para>
755+ /// <b>Why:</b> On Adreno 7xx (Snapdragon 8 Gen 2/3), applying
756+ /// <c>VK_PRESENT_MODE_IMMEDIATE_KHR</c> during the cold-start texture-upload burst
757+ /// triggers a swapchain recreation (FIFO → IMMEDIATE) while hundreds of textures
758+ /// are being uploaded. The Vulkan driver stalls in <c>vkQueuePresentKHR</c>,
759+ /// blocking the Draw thread indefinitely and producing a black screen + ANR.
760+ /// </para>
761+ ///
762+ /// <para>
763+ /// <b>How:</b> Before the framework reads <c>framework.ini</c> (in OnCreate,
764+ /// before <c>base.OnCreate</c>), we rewrite <c>FrameSync</c> to <c>VSync</c> so
765+ /// the swapchain is created in FIFO mode (safe). The original value is saved to
766+ /// <see cref="AndroidStartupFlags.FLAG_VULKAN_COLD_START_FRAME_SYNC_RESTORE"/>
767+ /// and restored by <see cref="OsuGameAndroid"/> after the Draw thread presents
768+ /// its first frame (via <c>FrameworkConfigManager.SetValue</c>, which applies
769+ /// in-memory AND persists to disk).
770+ /// </para>
771+ ///
772+ /// <para>
773+ /// <b>Safety:</b> If the process dies before restoration, next launch finds
774+ /// <c>FrameSync = VSync</c> in the ini (safe FIFO cold start) plus the restore
775+ /// flag still on disk, so the same deferred-switch cycle repeats. No user-visible
776+ /// permanent change to the config.
777+ /// </para>
778+ /// </summary>
779+ public static void ForceVSyncDuringVulkanColdStart ( )
780+ {
781+ try
782+ {
783+ if ( ! IsVulkanConfigured ( ) )
784+ return ;
785+
786+ // If safe-mode is active, ForceOpenGLRendererIfSafeMode already switched
787+ // to OpenGL — no Vulkan swapchain will be created, so no override needed.
788+ if ( AndroidStartupSafeMode . IsActive )
789+ return ;
790+
791+ string ? root = resolveStorageRoot ( ) ;
792+ if ( root == null ) return ;
793+
794+ string iniPath = Path . Combine ( root , "framework.ini" ) ;
795+ if ( ! File . Exists ( iniPath ) ) return ;
796+
797+ string [ ] lines ;
798+
799+ try
800+ {
801+ lines = File . ReadAllLines ( iniPath ) ;
802+ }
803+ catch ( Exception e )
804+ {
805+ Debug . WriteLine ( $ "[osu!] LogManagement: could not read framework.ini for Vulkan cold-start VSync override: { e . Message } ") ;
806+ return ;
807+ }
808+
809+ bool changed = false ;
810+ string ? originalValue = null ;
811+
812+ for ( int i = 0 ; i < lines . Length ; i ++ )
813+ {
814+ string line = lines [ i ] ;
815+ int eq = line . IndexOf ( '=' ) ;
816+ if ( eq <= 0 ) continue ;
817+
818+ string key = line . Substring ( 0 , eq ) . Trim ( ) ;
819+ string value = line . Substring ( eq + 1 ) . Trim ( ) ;
820+
821+ if ( ! string . Equals ( key , "FrameSync" , StringComparison . Ordinal ) )
822+ continue ;
823+
824+ // Only override values that map to IMMEDIATE present mode.
825+ // VSync and Limit2x use FIFO, which is safe during cold start.
826+ if ( string . Equals ( value , "ActualUnlimited" , StringComparison . Ordinal )
827+ || string . Equals ( value , "Unlimited" , StringComparison . Ordinal ) )
828+ {
829+ originalValue = value ;
830+ lines [ i ] = "FrameSync = VSync" ;
831+ changed = true ;
832+ }
833+
834+ break ;
835+ }
836+
837+ if ( ! changed || originalValue == null ) return ;
838+
839+ // Save the original value so OsuGameAndroid can restore it after first frame.
840+ AndroidStartupFlags . WriteValue ( AndroidStartupFlags . FLAG_VULKAN_COLD_START_FRAME_SYNC_RESTORE , originalValue ) ;
841+
842+ try
843+ {
844+ File . WriteAllLines ( iniPath , lines ) ;
845+ Logger . Log ( $ "[osu!] Vulkan cold-start protection: FrameSync { originalValue } → VSync (FIFO) until first frame presents", LoggingTarget . Performance ) ;
846+ }
847+ catch ( Exception e )
848+ {
849+ Debug . WriteLine ( $ "[osu!] LogManagement: could not rewrite framework.ini for Vulkan cold-start VSync override: { e . Message } ") ;
850+ // Clean up the flag since we couldn't apply the override
851+ AndroidStartupFlags . Set ( AndroidStartupFlags . FLAG_VULKAN_COLD_START_FRAME_SYNC_RESTORE , false ) ;
852+ }
853+ }
854+ catch ( Exception e )
855+ {
856+ Debug . WriteLine ( $ "[osu!] LogManagement: ForceVSyncDuringVulkanColdStart failed: { e . Message } ") ;
857+ }
858+ }
859+
749860 // Sentinel file dropped after a successful one-shot shader-cache wipe.
750861 // Stored alongside the cache itself (not in the cache directory, which
751862 // we delete) so the marker survives the wipe. The file payload is the
0 commit comments