@@ -81,19 +81,34 @@ internal async Task<long> CalculatePatchSizeAsync(GameInstallerKind kind, Cancel
8181
8282 ulong total = 0 ;
8383 int krpCount = 0 ;
84+ int fullCount = 0 ;
8485 foreach ( var entry in patchIndex . Resource )
8586 {
86- if ( ! string . IsNullOrEmpty ( entry . Dest ) &&
87- entry . Dest . EndsWith ( ".krpdiff" , StringComparison . OrdinalIgnoreCase ) )
88- {
89- total += entry . Size ;
87+ if ( string . IsNullOrEmpty ( entry . Dest ) )
88+ continue ;
89+
90+ if ( entry . Dest . EndsWith ( ".krpdiff" , StringComparison . OrdinalIgnoreCase ) )
9091 krpCount ++ ;
91- }
92+ else
93+ fullCount ++ ;
94+ }
95+
96+ // If there are krpdiff entries, sum only those (small diffs).
97+ // If there are none (old-style patch), sum the full-replacement entries instead.
98+ bool hasKrpdiffs = krpCount > 0 ;
99+ foreach ( var entry in patchIndex . Resource )
100+ {
101+ if ( string . IsNullOrEmpty ( entry . Dest ) )
102+ continue ;
103+
104+ bool isKrpdiff = entry . Dest . EndsWith ( ".krpdiff" , StringComparison . OrdinalIgnoreCase ) ;
105+ if ( hasKrpdiffs ? isKrpdiff : ! isKrpdiff )
106+ total += entry . Size ;
92107 }
93108
94109 SharedStatic . InstanceLogger . LogInformation (
95- "[WuwaGameInstaller::CalculatePatchSizeAsync] Computed patch size: {Size} bytes from {Count } krpdiff entries (version {Version})" ,
96- total , krpCount , currentVersion ) ;
110+ "[WuwaGameInstaller::CalculatePatchSizeAsync] Computed patch size: {Size} bytes — {KrpCount } krpdiff, {FullCount} full-replacement entries (version {Version})" ,
111+ total , krpCount , fullCount , currentVersion ) ;
97112
98113 return total > long . MaxValue ? long . MaxValue : ( long ) total ;
99114 }
@@ -373,14 +388,26 @@ public async Task RunAsync(
373388
374389 // Initialize progress tracking
375390 var installProgress = new InstallProgress ( ) ;
391+ var currentProgressState = InstallProgressState . Idle ;
376392
377393 void ReportProgress ( )
378394 {
379- progressDelegate ? . Invoke ( in installProgress ) ;
395+ // Build a snapshot so the host/COM layer sees fully-consistent memory
396+ InstallProgress snap = default ;
397+ snap . StateCount = Volatile . Read ( ref installProgress . StateCount ) ;
398+ snap . TotalStateToComplete = Volatile . Read ( ref installProgress . TotalStateToComplete ) ;
399+ snap . DownloadedCount = Volatile . Read ( ref installProgress . DownloadedCount ) ;
400+ snap . TotalCountToDownload = Volatile . Read ( ref installProgress . TotalCountToDownload ) ;
401+ snap . DownloadedBytes = Interlocked . Read ( ref installProgress . DownloadedBytes ) ;
402+ snap . TotalBytesToDownload = Interlocked . Read ( ref installProgress . TotalBytesToDownload ) ;
403+
404+ progressDelegate ? . Invoke ( in snap ) ;
405+ progressStateDelegate ? . Invoke ( currentProgressState ) ;
380406 }
381407
382408 // ── Step 1: Resolve the correct patch config ──
383- progressStateDelegate ? . Invoke ( InstallProgressState . Preparing ) ;
409+ currentProgressState = InstallProgressState . Preparing ;
410+ ReportProgress ( ) ;
384411
385412 manager . GetCurrentGameVersion ( out GameVersion currentVersion ) ;
386413
@@ -435,6 +462,42 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
435462 // externally) but all files on disk are already at the target version.
436463 if ( ! onlyDownload && patchIndex . GroupInfos . Length > 0 )
437464 {
465+ currentProgressState = InstallProgressState . Verify ;
466+
467+ // Count total file pairs and total bytes (via fast metadata stat)
468+ // for smooth progress during large-file hashing.
469+ int totalPreflightPairs = 0 ;
470+ long totalPreflightBytes = 0 ;
471+ foreach ( var g in patchIndex . GroupInfos )
472+ {
473+ int pairs = Math . Min ( g . SrcFiles . Length , g . DstFiles . Length ) ;
474+ totalPreflightPairs += pairs ;
475+ for ( int pi = 0 ; pi < pairs ; pi ++ )
476+ {
477+ var dst = g . DstFiles [ pi ] ;
478+ if ( string . IsNullOrEmpty ( dst . Dest ) || string . IsNullOrEmpty ( dst . Md5 ) )
479+ continue ;
480+ string p = Path . Combine ( installPath ,
481+ dst . Dest . Replace ( '/' , Path . DirectorySeparatorChar ) ) ;
482+ if ( File . Exists ( p ) )
483+ totalPreflightBytes += new FileInfo ( p ) . Length ;
484+ }
485+ }
486+
487+ // State tracks file count (displayed by host as counter text).
488+ // Bytes track hashed data with mid-file granularity for smooth progress.
489+ installProgress . TotalCountToDownload = totalPreflightPairs ;
490+ installProgress . DownloadedCount = 0 ;
491+ installProgress . TotalStateToComplete = totalPreflightPairs ;
492+ installProgress . StateCount = 0 ;
493+ installProgress . TotalBytesToDownload = totalPreflightBytes ;
494+ installProgress . DownloadedBytes = 0 ;
495+ ReportProgress ( ) ;
496+
497+ SharedStatic . InstanceLogger . LogInformation (
498+ "[Patch::RunAsync] Pre-flight validation: checking {FileCount} files across {GroupCount} groups against target hashes..." ,
499+ totalPreflightPairs , patchIndex . GroupInfos . Length ) ;
500+
438501 bool allDestinationsMatch = true ;
439502 int checkedCount = 0 ;
440503
@@ -454,6 +517,8 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
454517
455518 if ( ! File . Exists ( dstPath ) )
456519 {
520+ SharedStatic . InstanceLogger . LogDebug (
521+ "[Patch::RunAsync] Pre-flight: file missing, will patch: {File}" , dstRef . Dest ) ;
457522 allDestinationsMatch = false ;
458523 break ;
459524 }
@@ -462,22 +527,48 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
462527 var fi = new FileInfo ( dstPath ) ;
463528 if ( dstRef . Size > 0 && ( ulong ) fi . Length != dstRef . Size )
464529 {
530+ SharedStatic . InstanceLogger . LogDebug (
531+ "[Patch::RunAsync] Pre-flight: size mismatch for {File} (expected={Expected}, actual={Actual}), will patch." ,
532+ dstRef . Dest , dstRef . Size , fi . Length ) ;
465533 allDestinationsMatch = false ;
466534 break ;
467535 }
468536
469537 // MD5 check
538+ SharedStatic . InstanceLogger . LogDebug (
539+ "[Patch::RunAsync] Pre-flight: hashing {File} ({Size})..." ,
540+ dstRef . Dest , fi . Length ) ;
541+
470542 await using ( var fs = File . OpenRead ( dstPath ) )
471543 {
472- string md5 = await WuwaUtils . ComputeMd5HexAsync ( fs , token ) . ConfigureAwait ( false ) ;
544+ long hashBytesAccum = 0 ;
545+ const long reportThreshold = 4 << 20 ; // report every ~4 MiB
546+
547+ string md5 = await WuwaUtils . ComputeMd5HexAsync ( fs , bytesRead =>
548+ {
549+ Interlocked . Add ( ref installProgress . DownloadedBytes , bytesRead ) ;
550+ hashBytesAccum += bytesRead ;
551+ if ( hashBytesAccum >= reportThreshold )
552+ {
553+ ReportProgress ( ) ;
554+ hashBytesAccum = 0 ;
555+ }
556+ } , token ) . ConfigureAwait ( false ) ;
557+
473558 if ( ! string . Equals ( md5 , dstRef . Md5 , StringComparison . OrdinalIgnoreCase ) )
474559 {
560+ SharedStatic . InstanceLogger . LogDebug (
561+ "[Patch::RunAsync] Pre-flight: MD5 mismatch for {File}, will patch." ,
562+ dstRef . Dest ) ;
475563 allDestinationsMatch = false ;
476564 break ;
477565 }
478566 }
479567
480568 checkedCount ++ ;
569+ Interlocked . Increment ( ref installProgress . DownloadedCount ) ;
570+ Interlocked . Increment ( ref installProgress . StateCount ) ;
571+ ReportProgress ( ) ;
481572 }
482573
483574 if ( ! allDestinationsMatch )
@@ -499,6 +590,16 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
499590 manager . GetApiGameVersion ( out preflightTargetVer ) ;
500591
501592 manager . SetCurrentGameVersion ( preflightTargetVer ) ;
593+
594+ // Clear DEBUG downgrade flags so the spoofed version doesn't
595+ // re-trigger another update cycle on next init/LoadConfig.
596+ if ( manager . DEBUG_AllowDowngrade )
597+ {
598+ SharedStatic . InstanceLogger . LogInformation (
599+ "[Patch::RunAsync] Clearing DEBUG_AllowDowngrade after successful pre-flight." ) ;
600+ manager . DEBUG_AllowDowngrade = false ;
601+ }
602+
502603 manager . SaveConfig ( ) ;
503604
504605 // Clean up any leftover preload temp files
@@ -509,7 +610,8 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
509610 }
510611 catch { /* best-effort */ }
511612
512- progressStateDelegate ? . Invoke ( InstallProgressState . Completed ) ;
613+ currentProgressState = InstallProgressState . Completed ;
614+ ReportProgress ( ) ;
513615 return ;
514616 }
515617
@@ -549,18 +651,39 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
549651 patchTempPath , krpdiffEntries . Length ) ;
550652 }
551653
552- // ── Step 5: Download krpdiff files (if not pre-downloaded) ──
553- if ( ! hasPredownloadedFiles && krpdiffEntries . Length > 0 )
654+ // ── Step 5: Download patch files ──
655+ // If krpdiff entries exist, download only those (small diffs applied via groupInfos).
656+ // If no krpdiff entries exist (old-style patch), download the full replacement entries.
657+ WuwaApiResponseResourceEntry [ ] downloadEntries ;
658+ if ( krpdiffEntries . Length > 0 )
659+ {
660+ downloadEntries = hasPredownloadedFiles
661+ ? [ ] // already pre-downloaded
662+ : krpdiffEntries ;
663+ }
664+ else
665+ {
666+ // Old-style patch: all resources are full replacement files
667+ downloadEntries = patchIndex. Resource
668+ . Where( e => ! string . IsNullOrEmpty ( e . Dest ) )
669+ . ToArray ( ) ;
670+
671+ SharedStatic . InstanceLogger . LogInformation (
672+ "[Patch::RunAsync] No krpdiff entries found — old-style patch. Downloading {Count} full replacement files." ,
673+ downloadEntries . Length ) ;
674+ }
675+
676+ if ( downloadEntries . Length > 0 )
554677 {
555- progressStateDelegate ? . Invoke ( InstallProgressState . Download ) ;
678+ currentProgressState = InstallProgressState . Download ;
556679
557680 // Calculate total bytes and set progress
558681 ulong totalBytes = 0 ;
559- foreach ( var e in krpdiffEntries )
682+ foreach ( var e in downloadEntries )
560683 totalBytes += e . Size ;
561684
562685 installProgress . TotalBytesToDownload = totalBytes > long . MaxValue ? long . MaxValue : ( long ) totalBytes ;
563- installProgress . TotalCountToDownload = krpdiffEntries . Length ;
686+ installProgress . TotalCountToDownload = downloadEntries . Length ;
564687 installProgress . DownloadedBytes = 0 ;
565688 installProgress . DownloadedCount = 0 ;
566689 ReportProgress ( ) ;
@@ -574,7 +697,7 @@ await installer.RunAsync(kind, progressDelegate, progressStateDelegate, token)
574697
575698 Directory . CreateDirectory ( patchTempPath ) ;
576699
577- await Parallel . ForEachAsync ( krpdiffEntries ,
700+ await Parallel . ForEachAsync ( downloadEntries ,
578701 new ParallelOptions { MaxDegreeOfParallelism = Environment . ProcessorCount , CancellationToken = token } ,
579702 async ( entry , ct ) =>
580703 {
@@ -617,13 +740,14 @@ await _owner.TryDownloadWholeFileWithFallbacksAsync(
617740 } ) . ConfigureAwait ( false ) ;
618741
619742 SharedStatic . InstanceLogger . LogInformation (
620- "[Patch::RunAsync] Download phase complete. Downloaded {Count} krpdiff files." ,
621- krpdiffEntries . Length ) ;
743+ "[Patch::RunAsync] Download phase complete. Downloaded {Count} files." ,
744+ downloadEntries . Length ) ;
622745 }
623746
624747 // ── Step 6: Verify downloaded files ──
625- progressStateDelegate ? . Invoke ( InstallProgressState . Verify ) ;
626- foreach ( var entry in krpdiffEntries )
748+ currentProgressState = InstallProgressState . Verify ;
749+ ReportProgress ( ) ;
750+ foreach ( var entry in downloadEntries )
627751 {
628752 token . ThrowIfCancellationRequested ( ) ;
629753 if ( string . IsNullOrEmpty ( entry . Dest ) )
@@ -635,7 +759,7 @@ await _owner.TryDownloadWholeFileWithFallbacksAsync(
635759 if ( ! File . Exists ( filePath ) )
636760 {
637761 throw new FileNotFoundException (
638- $ "KRPDiff file missing after download: { entry . Dest } ", filePath ) ;
762+ $ "Patch file missing after download: { entry . Dest } ", filePath ) ;
639763 }
640764
641765 var fileInfo = new FileInfo ( filePath ) ;
@@ -654,7 +778,7 @@ await _owner.TryDownloadWholeFileWithFallbacksAsync(
654778 if ( ! string . Equals ( computedMd5 , entry . Md5 , StringComparison . OrdinalIgnoreCase ) )
655779 {
656780 throw new InvalidOperationException (
657- $ "MD5 mismatch for downloaded krpdiff { entry . Dest } : expected={ entry . Md5 } , computed={ computedMd5 } ") ;
781+ $ "MD5 mismatch for downloaded file { entry . Dest } : expected={ entry . Md5 } , computed={ computedMd5 } ") ;
658782 }
659783 }
660784 }
@@ -670,7 +794,8 @@ await _owner.TryDownloadWholeFileWithFallbacksAsync(
670794 string markerPath = Path . Combine ( patchTempPath , ".version" ) ;
671795 await File . WriteAllTextAsync ( markerPath , targetVersion . ToString ( ) , token ) . ConfigureAwait ( false ) ;
672796
673- progressStateDelegate ? . Invoke ( InstallProgressState . Completed ) ;
797+ currentProgressState = InstallProgressState . Completed ;
798+ ReportProgress ( ) ;
674799 SharedStatic . InstanceLogger . LogInformation (
675800 "[Patch::RunAsync] Preload download complete. Files saved to {Path}. Target version: {Version}" ,
676801 patchTempPath , targetVersion ) ;
@@ -680,7 +805,8 @@ await _owner.TryDownloadWholeFileWithFallbacksAsync(
680805 // ── Step 8: Delete files from deleteFiles list ──
681806 if ( patchIndex . DeleteFiles . Length > 0 )
682807 {
683- progressStateDelegate ? . Invoke ( InstallProgressState . Removing ) ;
808+ currentProgressState = InstallProgressState . Removing ;
809+ ReportProgress ( ) ;
684810 foreach ( var deleteEntry in patchIndex . DeleteFiles )
685811 {
686812 if ( string . IsNullOrEmpty ( deleteEntry . Dest ) )
@@ -710,7 +836,7 @@ await _owner.TryDownloadWholeFileWithFallbacksAsync(
710836 // ── Step 9: Apply patches from groupInfos ──
711837 if ( patchIndex . GroupInfos . Length > 0 )
712838 {
713- progressStateDelegate ? . Invoke ( InstallProgressState . Updating ) ;
839+ currentProgressState = InstallProgressState . Updating ;
714840
715841 // Count total file pairs across all groups for accurate progress tracking
716842 int totalFilePairs = 0 ;
@@ -907,9 +1033,20 @@ await File.WriteAllTextAsync(progressMarkerPath, currentPairIndex.ToString(), to
9071033 }
9081034
9091035 manager . SetCurrentGameVersion ( targetVer ) ;
1036+
1037+ // Clear DEBUG downgrade flags so the spoofed version doesn't re-trigger
1038+ // another update cycle on next init.
1039+ if ( manager . DEBUG_AllowDowngrade )
1040+ {
1041+ SharedStatic . InstanceLogger . LogInformation (
1042+ "[Patch::RunAsync] Clearing DEBUG_AllowDowngrade after successful patch." ) ;
1043+ manager . DEBUG_AllowDowngrade = false ;
1044+ }
1045+
9101046 manager . SaveConfig ( ) ;
9111047
912- progressStateDelegate ? . Invoke ( InstallProgressState . Completed ) ;
1048+ currentProgressState = InstallProgressState . Completed ;
1049+ ReportProgress ( ) ;
9131050 SharedStatic . InstanceLogger . LogInformation (
9141051 "[Patch::RunAsync] Patch complete. Game updated to version {Version}." , targetVer ) ;
9151052 }
0 commit comments