diff --git a/CollapseLauncher/Classes/CachesManagement/StarRail/Check.cs b/CollapseLauncher/Classes/CachesManagement/StarRail/Check.cs deleted file mode 100644 index 5004e635c..000000000 --- a/CollapseLauncher/Classes/CachesManagement/StarRail/Check.cs +++ /dev/null @@ -1,149 +0,0 @@ -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; -using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable CommentTypo -// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault - -namespace CollapseLauncher -{ - internal partial class StarRailCache - { - private async Task> Check(List assetIndex, CancellationToken token) - { - // Initialize asset index for the return - List returnAsset = []; - - // Set Indetermined status as false - Status.IsProgressAllIndetermined = false; - - // Show the asset entry panel - Status.IsAssetEntryPanelShow = true; - - // Get persistent and streaming paths - string execName = Path.GetFileNameWithoutExtension(InnerGameVersionManager!.GamePreset!.GameExecutableName); - string baseDesignDataPathPersistent = Path.Combine(GamePath!, @$"{execName}_Data\Persistent\DesignData\Windows"); - string baseDesignDataPathStreaming = Path.Combine(GamePath!, @$"{execName}_Data\StreamingAssets\DesignData\Windows"); - - string baseLuaPathPersistent = Path.Combine(GamePath!, @$"{execName}_Data\Persistent\Lua\Windows"); - string baseLuaPathStreaming = Path.Combine(GamePath!, @$"{execName}_Data\StreamingAssets\Lua\Windows"); - - string baseIFixPathPersistent = Path.Combine(GamePath!, @$"{execName}_Data\Persistent\IFix\Windows"); - string baseIFixPathStreaming = Path.Combine(GamePath!, @$"{execName}_Data\StreamingAssets\IFix\Windows"); - - try - { - // Do check in parallelization. - await Parallel.ForEachAsync(assetIndex!, new ParallelOptions - { - MaxDegreeOfParallelism = ThreadCount, - CancellationToken = token - }, async (asset, threadToken) => - { - switch (asset!.AssetType) - { - case SRAssetType.DesignData: - await CheckAsset(asset, returnAsset, baseDesignDataPathPersistent, baseDesignDataPathStreaming, threadToken); - break; - case SRAssetType.Lua: - await CheckAsset(asset, returnAsset, baseLuaPathPersistent, baseLuaPathStreaming, threadToken); - break; - case SRAssetType.IFix: - await CheckAsset(asset, returnAsset, baseIFixPathPersistent, baseIFixPathStreaming, threadToken); - break; - } - }); - } - catch (AggregateException ex) - { - throw ex.Flatten().InnerExceptions.First(); - } - - // Return the asset index - return returnAsset; - } - - private async ValueTask CheckAsset(SRAsset asset, List returnAsset, string basePersistent, string baseStreaming, CancellationToken token) - { - // Increment the count and update the status - lock (this) - { - ProgressAllCountCurrent++; - Status.ActivityStatus = string.Format(Lang!._CachesPage!.CachesStatusChecking!, asset!.AssetType, asset.LocalName); - Status.ActivityAll = string.Format(Lang!._CachesPage!.CachesTotalStatusChecking!, ProgressAllCountCurrent, ProgressAllCountTotal); - } - - // Get persistent and streaming paths - FileInfo fileInfoPersistent = new FileInfo(Path.Combine(basePersistent!, asset.LocalName!)).EnsureNoReadOnly(out bool isFileInfoPersistentExist); - FileInfo fileInfoStreaming = new FileInfo(Path.Combine(baseStreaming!, asset.LocalName!)).EnsureNoReadOnly(out bool isStreamingExist); - - bool usePersistent = !isStreamingExist; - bool isPersistentExist = isFileInfoPersistentExist && fileInfoPersistent.Length == asset.Size; - asset.LocalName = usePersistent ? fileInfoPersistent.FullName : fileInfoStreaming.FullName; - - // Check if the file exist. If not, then add it to asset index. - if (usePersistent && !isPersistentExist) - { - AddGenericCheckAsset(asset, CacheAssetStatus.New, returnAsset, null, asset.Hash); - return; - } - - // Skip CRC check if fast method is used - if (UseFastMethod) - { - return; - } - - // If above passes, then run the CRC check - await using FileStream fs = await NaivelyOpenFileStreamAsync(usePersistent ? fileInfoPersistent : fileInfoStreaming, - FileMode.Open, FileAccess.Read, FileShare.Read); - // Calculate the asset CRC (MD5) - byte[] hashArray = await GetCryptoHashAsync(fs, null, true, true, token); - - // If the asset CRC doesn't match, then add the file to asset index. - if (!IsArrayMatch(asset.Hash, hashArray)) - { - AddGenericCheckAsset(asset, CacheAssetStatus.Obsolete, returnAsset, hashArray, asset.Hash); - } - } - - private void AddGenericCheckAsset(SRAsset asset, CacheAssetStatus assetStatus, List returnAsset, byte[] localCrc, byte[] remoteCrc) - { - // Increment the count and total size - lock (this) - { - // Set Indetermined status as false - Status.IsProgressAllIndetermined = false; - ProgressAllCountFound++; - ProgressAllSizeFound += asset!.Size; - } - - // Add file into asset index - lock (returnAsset!) - { - returnAsset.Add(asset); - - LogWriteLine($"[T: {asset.AssetType}]: {asset.LocalName} found to be \"{assetStatus}\"", LogType.Warning, true); - } - - // Add to asset entry display - Dispatch(() => AssetEntry!.Add(new AssetProperty( - Path.GetFileName(asset.LocalName), - ConvertCacheAssetTypeEnum(asset.AssetType), - $"{asset.AssetType}", - asset.Size, - localCrc, - remoteCrc - )) - ); - } - } -} diff --git a/CollapseLauncher/Classes/CachesManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/CachesManagement/StarRail/Fetch.cs deleted file mode 100644 index 9a76e48d3..000000000 --- a/CollapseLauncher/Classes/CachesManagement/StarRail/Fetch.cs +++ /dev/null @@ -1,139 +0,0 @@ -using CollapseLauncher.GameSettings.StarRail; -using CollapseLauncher.Helper; -using Hi3Helper; -using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; -using Hi3Helper.Http; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Data.ConverterTool; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault - -namespace CollapseLauncher -{ - internal partial class StarRailCache - { - private async Task> Fetch(CancellationToken token) - { - // Initialize asset index for the return - List returnAsset = []; - - // Initialize new proxy-aware HttpClient - using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); - - // Initialize the new DownloadClient - DownloadClient downloadClient = DownloadClient.CreateInstance(client); - - // Initialize metadata - // Set total activity string as "Fetching Caches Type: Dispatcher" - Status.ActivityStatus = string.Format(Lang!._CachesPage!.CachesStatusFetchingType!, CacheAssetType.Dispatcher); - Status.IsProgressAllIndetermined = true; - Status.IsIncludePerFileIndicator = false; - UpdateStatus(); - - if (!await InnerGameVersionManager!.StarRailMetadataTool.Initialize(token, downloadClient, _httpClient_FetchAssetProgress, GetExistingGameRegionID(), Path.Combine(GamePath!, $"{Path.GetFileNameWithoutExtension(GameVersionManager!.GamePreset!.GameExecutableName)}_Data\\Persistent"))) - throw new InvalidDataException("The dispatcher response is invalid! Please open an issue to our GitHub page to report this issue."); - - // Iterate type and do fetch - await Parallel.ForEachAsync(Enum.GetValues(), token, async (type, innerCancelToken) => - { - // Skip for unused type - switch (type) - { - case SRAssetType.Audio: - case SRAssetType.Video: - case SRAssetType.Block: - case SRAssetType.Asb: - return; - } - - // uint = Count of the assets available - // long = Total size of the assets available - (int, long) count = await FetchByType(downloadClient, _httpClient_FetchAssetProgress, type, returnAsset, innerCancelToken); - - // Write a log about the metadata - LogWriteLine($"Cache Metadata [T: {type}]:", LogType.Default, true); - LogWriteLine($" Cache Count = {count.Item1}", LogType.NoTag, true); - LogWriteLine($" Cache Size = {SummarizeSizeSimple(count.Item2)}", LogType.NoTag, true); - - // Increment the Total Size and Count - Interlocked.Add(ref ProgressAllCountTotal, count.Item1); - Interlocked.Add(ref ProgressAllSizeTotal, count.Item2); - }).ConfigureAwait(false); - - // Return asset index - return returnAsset; - } - - private async Task<(int, long)> FetchByType(DownloadClient downloadClient, DownloadProgressDelegate downloadProgress, SRAssetType type, List assetIndex, CancellationToken token) - { - // Set total activity string as "Fetching Caches Type: " - Status.ActivityStatus = string.Format(Lang!._CachesPage!.CachesStatusFetchingType!, type); - Status.IsProgressAllIndetermined = true; - Status.IsIncludePerFileIndicator = false; - UpdateStatus(); - - // Start reading the metadata and build the asset index of each type - SRAssetProperty assetProperty; - switch (type) - { - case SRAssetType.IFix: - await InnerGameVersionManager!.StarRailMetadataTool!.ReadIFixMetadataInformation(downloadClient, downloadProgress, token); - assetProperty = InnerGameVersionManager!.StarRailMetadataTool!.MetadataIFix!.GetAssets(); - assetIndex!.AddRange(assetProperty!.AssetList!); - return (assetProperty.AssetList.Count, assetProperty.AssetTotalSize); - case SRAssetType.DesignData: - await InnerGameVersionManager!.StarRailMetadataTool!.ReadDesignMetadataInformation(downloadClient, downloadProgress, token); - assetProperty = InnerGameVersionManager.StarRailMetadataTool.MetadataDesign!.GetAssets(); - assetIndex!.AddRange(assetProperty!.AssetList!); - return (assetProperty.AssetList.Count, assetProperty.AssetTotalSize); - case SRAssetType.Lua: - await InnerGameVersionManager!.StarRailMetadataTool!.ReadLuaMetadataInformation(downloadClient, downloadProgress, token); - assetProperty = InnerGameVersionManager.StarRailMetadataTool.MetadataLua!.GetAssets(); - assetIndex!.AddRange(assetProperty!.AssetList!); - return (assetProperty.AssetList.Count, assetProperty.AssetTotalSize); - } - - return (0, 0); - } - - #region Utilities - private unsafe string GetExistingGameRegionID() - { -#nullable enable - object? value = (GameSettings as StarRailSettings)?.RegistryRoot?.GetValue("App_LastServerName_h2577443795", null); - if (value == null) - { - return GameVersionManager!.GamePreset.GameDispatchDefaultName ?? throw new KeyNotFoundException("Default dispatcher name in metadata is not exist!"); - } -#nullable disable - - ReadOnlySpan span = (value as byte[]).AsSpan(); - fixed (byte* valueSpan = span) - { - string name = Encoding.UTF8.GetString(valueSpan, span.Length - 1); - return name; - } - } - - private static CacheAssetType ConvertCacheAssetTypeEnum(SRAssetType assetType) => assetType switch - { - SRAssetType.IFix => CacheAssetType.IFix, - SRAssetType.DesignData => CacheAssetType.DesignData, - SRAssetType.Lua => CacheAssetType.Lua, - _ => CacheAssetType.General - }; - #endregion - } -} diff --git a/CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCache.cs b/CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCache.cs deleted file mode 100644 index fc400162b..000000000 --- a/CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCache.cs +++ /dev/null @@ -1,110 +0,0 @@ -using CollapseLauncher.GameVersioning; -using CollapseLauncher.Interfaces; -using Hi3Helper.Data; -using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; -using Microsoft.UI.Xaml; -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -// ReSharper disable UnusedMember.Global - -namespace CollapseLauncher -{ - internal partial class StarRailCache(UIElement parentUI, IGameVersion gameVersionManager, IGameSettings gameSettings) - : ProgressBase(parentUI, - gameVersionManager, - gameSettings, - null, - gameVersionManager.GetGameVersionApi()?.VersionString), ICache - { - #region Properties - private GameTypeStarRailVersion InnerGameVersionManager { get; } = gameVersionManager as GameTypeStarRailVersion; - private List UpdateAssetIndex { get; set; } - protected override string UserAgent => "UnityPlayer/2019.4.34f1 (UnityWebRequest/1.0, libcurl/7.75.0-DEV)"; - - public override string GamePath - { - get => GameVersionManager.GameDirPath; - set => throw new InvalidOperationException(); - } - #endregion - - ~StarRailCache() => Dispose(); - - public async Task StartCheckRoutine(bool useFastCheck) - { - UseFastMethod = useFastCheck; - return await TryRunExamineThrow(CheckRoutine()); - } - - private async Task CheckRoutine() - { - // Initialize _updateAssetIndex - UpdateAssetIndex = []; - - // Reset status and progress - ResetStatusAndProgress(); - - // Step 1: Fetch asset indexes - AssetIndex = await Fetch(Token.Token); - - // Step 2: Start assets checking - UpdateAssetIndex = await Check(AssetIndex, Token.Token); - - // Step 3: Summarize and returns true if the assetIndex count != 0 indicates caches needs to be updated. - // either way, returns false. - return SummarizeStatusAndProgress( - UpdateAssetIndex, - string.Format(Lang._CachesPage.CachesStatusNeedUpdate, ProgressAllCountFound, ConverterTool.SummarizeSizeSimple(ProgressAllSizeFound)), - Lang._CachesPage.CachesStatusUpToDate); - } - - public async Task StartUpdateRoutine(bool showInteractivePrompt = false) - { - if (UpdateAssetIndex.Count == 0) throw new InvalidOperationException("There's no cache file need to be update! You can't do the update process!"); - - _ = await TryRunExamineThrow(UpdateRoutine()); - } - - private async Task UpdateRoutine() - { - // Assign update task - Task updateTask = Update(UpdateAssetIndex, AssetIndex, Token.Token); - - // Run update process - bool updateTaskSuccess = await TryRunExamineThrow(updateTask); - - // Reset status and progress - ResetStatusAndProgress(); - - // Set as completed - Status.IsCompleted = true; - Status.IsCanceled = false; - Status.ActivityStatus = Lang._CachesPage.CachesStatusUpToDate; - - // Update status and progress - UpdateAll(); - - // Clean up _updateAssetIndex - UpdateAssetIndex.Clear(); - - return updateTaskSuccess; - } - - public StarRailCache AsBaseType() => this; - - public void CancelRoutine() - { - Token?.Cancel(); - Token?.Dispose(); - Token = null; - } - - public void Dispose() - { - CancelRoutine(); - GC.SuppressFinalize(this); - } - } -} diff --git a/CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCacheV2.cs b/CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCacheV2.cs new file mode 100644 index 000000000..57b0f2596 --- /dev/null +++ b/CollapseLauncher/Classes/CachesManagement/StarRail/StarRailCacheV2.cs @@ -0,0 +1,20 @@ +using CollapseLauncher.Interfaces; +using Microsoft.UI.Xaml; +using System.Threading.Tasks; +// ReSharper disable UnusedMember.Global + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailCacheV2(UIElement parentUI, IGameVersion gameVersionManager, IGameSettings gameSettings) + : StarRailRepairV2(parentUI, gameVersionManager, gameSettings, false, null, true), ICache, ICacheBase + { + public StarRailCacheV2 AsBaseType() => this; + + public Task StartUpdateRoutine(bool showInteractivePrompt = false) + => StartRepairRoutine(showInteractivePrompt); + } +} diff --git a/CollapseLauncher/Classes/CachesManagement/StarRail/Update.cs b/CollapseLauncher/Classes/CachesManagement/StarRail/Update.cs deleted file mode 100644 index 6d6515d0f..000000000 --- a/CollapseLauncher/Classes/CachesManagement/StarRail/Update.cs +++ /dev/null @@ -1,117 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; -using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; -using Hi3Helper.Http; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable CommentTypo - -namespace CollapseLauncher -{ - [SuppressMessage("ReSharper", "PossibleNullReferenceException")] - internal partial class StarRailCache - { - // ReSharper disable once UnusedParameter.Local - private async Task Update(List updateAssetIndex, List assetIndex, CancellationToken token) - { - // Initialize new proxy-aware HttpClient - using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); - - // Assign DownloadClient - DownloadClient downloadClient = DownloadClient.CreateInstance(client); - try - { - // Set IsProgressAllIndetermined as false and update the status - Status.IsProgressAllIndetermined = true; - UpdateStatus(); - - // Iterate the asset index and do update operation - ObservableCollection assetProperty = [.. AssetEntry]; - - ConcurrentDictionary<(SRAsset, IAssetProperty), byte> runningTask = new(); - if (IsBurstDownloadEnabled) - { - await Parallel.ForEachAsync( - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(updateAssetIndex) -#else - updateAssetIndex -#endif - , assetProperty), - new ParallelOptions { CancellationToken = token, MaxDegreeOfParallelism = DownloadThreadCount }, - async (asset, innerToken) => - { - if (!runningTask.TryAdd(asset, 0)) - { - LogWriteLine($"Found duplicated task for {asset.AssetProperty.Name}! Skipping...", LogType.Warning, true); - return; - } - await UpdateCacheAsset(asset, downloadClient, _httpClient_UpdateAssetProgress, innerToken); - runningTask.Remove(asset, out _); - }); - } - else - { - foreach ((SRAsset, IAssetProperty) asset in - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(updateAssetIndex) -#else - updateAssetIndex -#endif - , assetProperty)) - { - if (!runningTask.TryAdd(asset, 0)) - { - LogWriteLine($"Found duplicated task for {asset.Item2.Name}! Skipping...", LogType.Warning, true); - continue; - } - await UpdateCacheAsset(asset, downloadClient, _httpClient_UpdateAssetProgress, token); - runningTask.Remove(asset, out _); - } - } - - return true; - } - catch (TaskCanceledException) { throw; } - catch (OperationCanceledException) { throw; } - catch (Exception ex) - { - LogWriteLine($"An error occured while updating cache file!\r\n{ex}", LogType.Error, true); - throw; - } - } - - private async Task UpdateCacheAsset((SRAsset AssetIndex, IAssetProperty AssetProperty) asset, DownloadClient downloadClient, DownloadProgressDelegate downloadProgress, CancellationToken token) - { - // Increment total count and update the status - ProgressAllCountCurrent++; - FileInfo fileInfo = new FileInfo(asset.AssetIndex.LocalName!).EnsureCreationOfDirectory().StripAlternateDataStream().EnsureNoReadOnly(); - Status.ActivityStatus = string.Format(Lang._Misc.Downloading + " {0}: {1}", asset.AssetIndex.AssetType, Path.GetFileName(fileInfo.Name)); - UpdateAll(); - - // Run download task - await RunDownloadTask(asset.AssetIndex.Size, fileInfo, asset.AssetIndex.RemoteURL, downloadClient, downloadProgress, token); - LogWriteLine($"Downloaded cache [T: {asset.AssetIndex.AssetType}]: {Path.GetFileName(fileInfo.Name)}", LogType.Default, true); - - // Remove Asset Entry display - PopRepairAssetEntry(asset.AssetProperty); - } - } -} diff --git a/CollapseLauncher/Classes/GameManagement/Versioning/StarRail/VersionCheck.cs b/CollapseLauncher/Classes/GameManagement/Versioning/StarRail/VersionCheck.cs index a14552503..26bfd5154 100644 --- a/CollapseLauncher/Classes/GameManagement/Versioning/StarRail/VersionCheck.cs +++ b/CollapseLauncher/Classes/GameManagement/Versioning/StarRail/VersionCheck.cs @@ -37,8 +37,7 @@ public GameTypeStarRailVersion(ILauncherApi launcherApi, PresetConfig presetConf { throw new NullReferenceException(GamePreset.GameDispatchArrayURL + " is null!"); } - StarRailMetadataTool = new SRMetadata( - GamePreset.GameDispatchArrayURL[0], + StarRailMetadataTool = new SRMetadata(GamePreset.GameDispatchArrayURL[0], GamePreset.ProtoDispatchKey, dispatchUrlTemplate, gatewayUrlTemplate, diff --git a/CollapseLauncher/Classes/GamePresetProperty.cs b/CollapseLauncher/Classes/GamePresetProperty.cs index 66cdb4ed8..d3cf107c0 100644 --- a/CollapseLauncher/Classes/GamePresetProperty.cs +++ b/CollapseLauncher/Classes/GamePresetProperty.cs @@ -34,7 +34,7 @@ internal sealed partial class GamePresetProperty : IDisposable { internal static GamePresetProperty Create(UIElement uiElementParent, ILauncherApi? launcherApis, string gameName, string gameRegion) { - var gamePreset = LauncherMetadataHelper.LauncherMetadataConfig?[gameName]?[gameRegion]; + PresetConfig? gamePreset = LauncherMetadataHelper.LauncherMetadataConfig?[gameName]?[gameRegion]; if (gamePreset == null) { throw new NullReferenceException($"Cannot find game with name: {gameName} and region: {gameRegion} on the currently loaded metadata config!"); @@ -53,7 +53,6 @@ internal static GamePresetProperty Create(UIElement uiElementParent, ILauncherAp property.GameVersion = new GameTypeHonkaiVersion(launcherApis, gamePreset); property.GameSettings = new HonkaiSettings(property.GameVersion); property.GameCache = new HonkaiCache(uiElementParent, property.GameVersion, property.GameSettings); - // property.GameRepair = new HonkaiRepair(uiElementParent, property.GameVersion, property.GameCache, property.GameSettings); property.GameRepair = new HonkaiRepairV2(uiElementParent, property.GameVersion, property.GameSettings); property.GameInstall = new HonkaiInstall(uiElementParent, property.GameVersion, property.GameSettings); break; @@ -61,22 +60,22 @@ internal static GamePresetProperty Create(UIElement uiElementParent, ILauncherAp property.GameVersion = new GameTypeStarRailVersion(launcherApis, gamePreset); property.GameSettings = new StarRailSettings(property.GameVersion); property.GameInstall = new StarRailInstall(uiElementParent, property.GameVersion, property.GameSettings); - property.GameCache = new StarRailCache(uiElementParent, property.GameVersion, property.GameSettings); - property.GameRepair = new StarRailRepair(uiElementParent, property.GameVersion, property.GameInstall, property.GameSettings); + property.GameCache = new StarRailCacheV2(uiElementParent, property.GameVersion, property.GameSettings); + property.GameRepair = new StarRailRepairV2(uiElementParent, property.GameVersion, property.GameSettings); break; case GameNameType.Genshin: - property.GameVersion = new GameTypeGenshinVersion(launcherApis, gamePreset); + property.GameVersion = new GameTypeGenshinVersion(launcherApis, gamePreset); property.GameSettings = new GenshinSettings(property.GameVersion); - property.GameCache = null; - property.GameRepair = new GenshinRepair(uiElementParent, property.GameVersion, property.GameSettings); - property.GameInstall = new GenshinInstall(uiElementParent, property.GameVersion, property.GameSettings, property.GameRepair); + property.GameCache = null; + property.GameRepair = new GenshinRepair(uiElementParent, property.GameVersion, property.GameSettings); + property.GameInstall = new GenshinInstall(uiElementParent, property.GameVersion, property.GameSettings, property.GameRepair); break; case GameNameType.Zenless: - property.GameVersion = new GameTypeZenlessVersion(launcherApis, gamePreset); + property.GameVersion = new GameTypeZenlessVersion(launcherApis, gamePreset); property.GameSettings = new ZenlessSettings(property.GameVersion); - property.GameCache = new ZenlessCache(uiElementParent, property.GameVersion, property.GameSettings); - property.GameRepair = new ZenlessRepair(uiElementParent, property.GameVersion, property.GameSettings); - property.GameInstall = new ZenlessInstall(uiElementParent, property.GameVersion, property.GameSettings); + property.GameCache = new ZenlessCache(uiElementParent, property.GameVersion, property.GameSettings); + property.GameRepair = new ZenlessRepair(uiElementParent, property.GameVersion, property.GameSettings); + property.GameInstall = new ZenlessInstall(uiElementParent, property.GameVersion, property.GameSettings); break; case GameNameType.Plugin: PluginPresetConfigWrapper pluginPresetConfig = (PluginPresetConfigWrapper)gamePreset; diff --git a/CollapseLauncher/Classes/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs b/CollapseLauncher/Classes/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs new file mode 100644 index 000000000..b634f31fd --- /dev/null +++ b/CollapseLauncher/Classes/Helper/JsonConverter/UtcUnixStampToDateTimeOffsetJsonConverter.cs @@ -0,0 +1,67 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +#pragma warning disable IDE0130 + +namespace CollapseLauncher.Helper.JsonConverter; + +internal class UtcUnixStampToDateTimeOffsetJsonConverter : JsonConverter +{ + public override DateTimeOffset Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + if (reader.ValueSpan.IsEmpty) + { + return default; + } + + if (reader.TokenType == JsonTokenType.String) + { + return double.TryParse(reader.ValueSpan, out double timestampStr) + ? Parse(timestampStr) + : throw new InvalidOperationException("Cannot parse string value to number for DateTimeOffset."); + } + + if (reader.TryGetDouble(out double unixTimestampDouble)) + { + return Parse(unixTimestampDouble); + } + + if (reader.TryGetInt64(out long unixTimestampLong)) + { + return Parse(unixTimestampLong); + } + + if (reader.TryGetUInt64(out ulong unixTimestampUlong)) + { + return Parse(unixTimestampUlong); + } + + throw new InvalidOperationException("Cannot parse number to DateTimeOffset"); + } + + public override void Write( + Utf8JsonWriter writer, + DateTimeOffset value, + JsonSerializerOptions options) + { + long number = value.Millisecond != 0 + ? value.ToUnixTimeMilliseconds() + : value.ToUnixTimeSeconds(); + + if (options.NumberHandling.HasFlag(JsonNumberHandling.WriteAsString)) + { + writer.WriteStringValue($"{number}"); + return; + } + + writer.WriteNumberValue(number); + } + + private static DateTimeOffset Parse(double timestamp) => timestamp >= 1000000000000 + ? DateTimeOffset.FromUnixTimeMilliseconds((long)Math.Abs(timestamp)) + : DateTimeOffset.FromUnixTimeSeconds((long)Math.Abs(timestamp)); +} diff --git a/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs b/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs index 429cfdf10..f1b1e3283 100644 --- a/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs +++ b/CollapseLauncher/Classes/Helper/Metadata/PresetConfig.cs @@ -626,8 +626,8 @@ private int GetVoiceLanguageID_Genshin(string regPath) { try { - RegistryKey? keys = Registry.CurrentUser.OpenSubKey(ConfigRegistryLocation); - byte[]? value = (byte[]?)keys?.GetValue("GENERAL_DATA_h2389025596"); + RegistryKey? keys = Registry.CurrentUser.OpenSubKey(ConfigRegistryLocation); + byte[]? value = (byte[]?)keys?.GetValue("GENERAL_DATA_h2389025596"); if (keys is null || value is null || value.Length is 0) { @@ -658,15 +658,25 @@ private int GetVoiceLanguageID_Genshin(string regPath) // WARNING!!! // This feature is only available for Genshin and Star Rail. - public void SetVoiceLanguageID(int langID) + public void SetVoiceLanguageID(string localeId) { switch (GameType) { case GameNameType.Genshin: - SetVoiceLanguageID_Genshin(langID); + int genshinId = localeId switch + { + "zh-cn" => 0, + "en-us" => 1, + "ja-jp" => 2, + "ko-kr" => 3, + _ => throw new + InvalidOperationException($"[SetVoiceLanguageID] Locale ID is unknown! {localeId}") + }; + SetVoiceLanguageID_Genshin(genshinId); break; case GameNameType.StarRail: - SetVoiceLanguageID_StarRail(langID); + int srId = GetStarRailVoiceLanguageByName(localeId.GetSplit(1, "-").ToString()); + SetVoiceLanguageID_StarRail(srId); break; } } diff --git a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs index 1171105ad..17fc990e8 100644 --- a/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs +++ b/CollapseLauncher/Classes/Helper/StreamUtility/StreamExtension.cs @@ -325,7 +325,7 @@ public static void DeleteEmptyDirectory(this DirectoryInfo dir, bool recursive = { foreach (DirectoryInfo childDir in dir.EnumerateDirectories("*", SearchOption.TopDirectoryOnly)) { - DeleteEmptyDirectory(childDir); + childDir.DeleteEmptyDirectory(); } } diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs index 575bd9e79..9f65f7d6f 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.Sophon.cs @@ -48,8 +48,8 @@ internal partial class InstallManagerBase #region Protected Properties - private List _sophonVOLanguageList { get; } = []; - private bool _isSophonDownloadCompleted { get; set; } + private HashSet _sophonVOLanguageList { get; } = []; + private bool _isSophonDownloadCompleted { get; set; } private bool _isSophonPreloadCompleted { @@ -249,7 +249,7 @@ await GameVersionManager.GamePreset List sophonInfoPairList = []; // Get the info pair based on info provided above (for main game file) - var sophonMainInfoPair = await + SophonChunkManifestInfoPair? sophonMainInfoPair = await SophonManifest.CreateSophonChunkManifestInfoPair(httpClient, requestedUrl, GameVersionManager.GamePreset.LauncherResourceChunksURL.MainBranchMatchingField, @@ -262,90 +262,68 @@ await GameVersionManager.GamePreset // Add the manifest to the pair list sophonInfoPairList.Add(sophonMainInfoPair); - List voLanguageList = + Dictionary voLanguageDict = GetSophonLanguageDisplayDictFromVoicePackList(sophonMainInfoPair.OtherSophonBuildData); - // Get Audio Choices first. - // If the fallbackFromUpdate flag is set, then don't show the dialog and instead - // use the default language (ja-jp) as the fallback and read the existing audio_lang file - List addedVo = []; - int setAsDefaultVo = GetSophonLocaleCodeIndex( - sophonMainInfoPair.OtherSophonBuildData, - "ja-jp" - ); + const string VOLanguageDefaultId = "ja-jp"; - if (voLanguageList.Count != 0) + if (voLanguageDict.Count != 0) { - if (fallbackFromUpdate) + // Now we only add VO list if the file actually exist. + // We won't bother any misconfiguration on user side anymore. + if (fallbackFromUpdate && File.Exists(_gameAudioLangListPathStatic)) { - if (!File.Exists(_gameAudioLangListPathStatic)) + string[] voLangList = await File.ReadAllLinesAsync(_gameAudioLangListPathStatic); + foreach (string voLang in voLangList) { - addedVo.Add(setAsDefaultVo); - } - else - { - string[] voLangList = await File.ReadAllLinesAsync(_gameAudioLangListPathStatic); - foreach (string voLang in voLangList) - { - string? voLocaleId = GetLanguageLocaleCodeByLanguageString( - voLang -#if !DEBUG + string? voLocaleId = GetLanguageLocaleCodeByLanguageString( + voLang + #if !DEBUG , false -#endif - ); - - if (string.IsNullOrEmpty(voLocaleId)) - { - continue; - } - - int voLocaleIndex = GetSophonLocaleCodeIndex( - sophonMainInfoPair.OtherSophonBuildData, - voLocaleId - ); - addedVo.Add(voLocaleIndex); - } + #endif + ); - if (addedVo.Count == 0) + if (string.IsNullOrEmpty(voLocaleId)) { - addedVo.Add(setAsDefaultVo); + continue; } + + _sophonVOLanguageList.Add(voLocaleId); + } + + if (_sophonVOLanguageList.Count == 0) + { + _sophonVOLanguageList.Add(VOLanguageDefaultId); } } else { - (List? addedVoTemp, setAsDefaultVo) = - await SimpleDialogs.Dialog_ChooseAudioLanguageChoice( - voLanguageList, - setAsDefaultVo); + (HashSet? addedVos, string? setAsDefaultVo) = + await SimpleDialogs.Dialog_ChooseAudioLanguageChoice(voLanguageDict); - if (addedVoTemp != null) + if (addedVos == null || setAsDefaultVo == null) { - addedVo.AddRange(addedVoTemp); + throw new TaskCanceledException(); // Cancel entire operation } - } - } - if (addedVo == null || setAsDefaultVo < 0) - { - throw new TaskCanceledException(); + foreach (string addedVo in addedVos) + { + _sophonVOLanguageList.Add(addedVo); + } + + // Set the voice language ID to value given + GameVersionManager.GamePreset.SetVoiceLanguageID(setAsDefaultVo); + } } - for (int i = 0; i < addedVo.Count; i++) + foreach (string sophonVoLangLocaleId in _sophonVOLanguageList) { - int voLangIndex = addedVo[i]; - string voLangLocaleCode = GetLanguageLocaleCodeByID(voLangIndex); - _sophonVOLanguageList?.Add(voLangLocaleCode); - // Get the info pair based on info provided above (for the selected VO audio file) SophonChunkManifestInfoPair sophonSelectedVoLang = - sophonMainInfoPair.GetOtherManifestInfoPair(voLangLocaleCode); + sophonMainInfoPair.GetOtherManifestInfoPair(sophonVoLangLocaleId); sophonInfoPairList.Add(sophonSelectedVoLang); } - // Set the voice language ID to value given - GameVersionManager.GamePreset.SetVoiceLanguageID(setAsDefaultVo); - // If the fallback is used from update, use the same display as All Size for Per File progress. if (fallbackFromUpdate) { @@ -981,6 +959,14 @@ private ValueTask RunSophonAssetDownloadThread(HttpClient client, return RunSophonAssetUpdateThread(client, asset, parallelOptions); } + // HACK: To avoid user unable to continue the download due to executable being downloaded completely, + // append "_tempSophon" on it. + string filename = Path.GetFileName(assetName); + if (filename.StartsWith(GameVersionManager.GamePreset.GameExecutableName ?? "", StringComparison.OrdinalIgnoreCase)) + { + filePath += "_tempSophon"; + } + // Get the target and temp file info FileInfo existingFileInfo = new FileInfo(filePath).EnsureNoReadOnly(); @@ -1299,12 +1285,11 @@ protected virtual int GetSophonLocaleCodeIndex(SophonManifestBuildData sophonDat return Math.Max(0, index); } - protected virtual List GetSophonLanguageDisplayDictFromVoicePackList(SophonManifestBuildData sophonData) + protected virtual Dictionary GetSophonLanguageDisplayDictFromVoicePackList(SophonManifestBuildData sophonData) { - List value = []; - for (var index = 0; index < sophonData.ManifestIdentityList.Count; index++) + Dictionary value = new(StringComparer.OrdinalIgnoreCase); + foreach (SophonManifestBuildIdentity identity in sophonData.ManifestIdentityList) { - var identity = sophonData.ManifestIdentityList[index]; // Check the lang ID and add the translation of the language to the list string localeCode = identity.MatchingField.ToLower(); if (!IsValidLocaleCode(localeCode)) @@ -1318,19 +1303,13 @@ protected virtual List GetSophonLanguageDisplayDictFromVoicePackList(Sop continue; } - value.Add(languageDisplay); + value.Add(localeCode, languageDisplay); } return value; } - protected virtual void RearrangeSophonDataLocaleOrder(SophonManifestBuildData? sophonData) - { - // Rearrange the sophon data list order based on matching field for the locale - RearrangeDataListLocaleOrder(sophonData?.ManifestIdentityList, x => x.MatchingField); - } - - protected virtual void WriteAudioLangListSophon(List sophonVOList) + protected virtual void WriteAudioLangListSophon(ICollection sophonVOList) { // Create persistent directory if not exist if (!Directory.Exists(_gameDataPersistentPath)) @@ -1350,10 +1329,9 @@ protected virtual void WriteAudioLangListSophon(List sophonVOList) : []; // Try lookup if there is a new language list, then add it to the list - for (int index = 0; index < sophonVOList.Count; index++) + foreach (string packageLocaleCodeString in sophonVOList) { - var packageLocaleCodeString = sophonVOList[index]; - string langString = GetLanguageStringByLocaleCode(packageLocaleCodeString); + string langString = GetLanguageStringByLocaleCode(packageLocaleCodeString); if (!langList.Contains(langString, StringComparer.OrdinalIgnoreCase)) { langList.Add(langString); @@ -1361,11 +1339,11 @@ protected virtual void WriteAudioLangListSophon(List sophonVOList) } // Create the audio lang list file - using var sw = new StreamWriter(_gameAudioLangListPathStatic, - new FileStreamOptions - { Mode = FileMode.Create, Access = FileAccess.Write }); + using StreamWriter sw = new StreamWriter(_gameAudioLangListPathStatic, + new FileStreamOptions + { Mode = FileMode.Create, Access = FileAccess.Write }); // Iterate the package list - foreach (var voIds in langList) + foreach (string voIds in langList) // Write the language string as per ID { sw.WriteLine(voIds); diff --git a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs index fb8fc1513..efa8be75f 100644 --- a/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs +++ b/CollapseLauncher/Classes/InstallManagement/Base/InstallManagerBase.cs @@ -2747,16 +2747,13 @@ protected virtual async ValueTask AddVoiceOverResourceVersionList( else { // Get the dialog and go for the selection - (Dictionary addedVO, string setAsDefaultVOLocalecode) = + (HashSet addedVO, string setAsDefaultVOLocalecode) = await Dialog_ChooseAudioLanguageChoice(langStringsDict); if (addedVO == null && string.IsNullOrEmpty(setAsDefaultVOLocalecode)) { throw new TaskCanceledException(); } - // Get the game default VO index - int setAsDefaultVO = GetIDByLanguageLocaleCode(setAsDefaultVOLocalecode); - // Sanitize check for invalid values if (addedVO == null || string.IsNullOrEmpty(setAsDefaultVOLocalecode)) { @@ -2765,11 +2762,11 @@ protected virtual async ValueTask AddVoiceOverResourceVersionList( } // Lookup for the package - foreach (KeyValuePair voChoice in addedVO) + foreach (string VoLocaleId in addedVO) { // Try find the VO resource by locale code if (!TryGetVoiceOverResourceByLocaleCode(packageDetail.AudioPackage, - voChoice.Key, + VoLocaleId, out HypPackageData voRes)) { continue; @@ -2777,7 +2774,7 @@ protected virtual async ValueTask AddVoiceOverResourceVersionList( package = new GameInstallPackage(voRes, GamePath, packageDetail.UncompressedUrl, packageDetail.Version) { - LanguageID = voChoice.Key, + LanguageID = VoLocaleId, PackageType = GameInstallPackageType.Audio }; packageList.Add(package); @@ -2786,7 +2783,7 @@ protected virtual async ValueTask AddVoiceOverResourceVersionList( } // Set the voice language ID to value given - GameVersionManager.GamePreset.SetVoiceLanguageID(setAsDefaultVO); + GameVersionManager.GamePreset.SetVoiceLanguageID(setAsDefaultVOLocalecode); } } } diff --git a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs index af182cf6d..9c8eedf1e 100644 --- a/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs +++ b/CollapseLauncher/Classes/InstallManagement/StarRail/StarRailInstall.cs @@ -58,7 +58,7 @@ protected override string _gameAudioLangListPath protected override string _gameAudioLangListPathStatic => Path.Combine(_gameDataPersistentPath, "AudioLaucherRecord.txt"); - private StarRailRepair _gameRepairManager { get; set; } + private StarRailRepairV2 _gameRepairManager { get; set; } #endregion @@ -90,10 +90,9 @@ public override async ValueTask StartPackageVerification(List - new StarRailRepair(ParentUI, + protected override StarRailRepairV2 GetGameRepairInstance(string? versionString) => + new StarRailRepairV2(ParentUI, GameVersionManager, - this, GameSettings, true, versionString); diff --git a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs index c5e4713ca..43be19689 100644 --- a/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs +++ b/CollapseLauncher/Classes/InstallManagement/Zenless/ZenlessInstall.cs @@ -159,34 +159,30 @@ protected override void WriteAudioLangList(List gamePackage) } } - protected override void WriteAudioLangListSophon(List sophonVOList) + protected override void WriteAudioLangListSophon(ICollection sophonVOList) { // Run the writing method from the base first base.WriteAudioLangListSophon(sophonVOList); // Then create the one from the alternate one // Read all the existing list - List langList = File.Exists(_gameAudioLangListPathAlternateStatic) - ? File.ReadAllLines(_gameAudioLangListPathAlternateStatic).ToList() + HashSet langList = File.Exists(_gameAudioLangListPathAlternateStatic) + ? File.ReadAllLines(_gameAudioLangListPathAlternateStatic).ToHashSet(StringComparer.OrdinalIgnoreCase) : []; // Try lookup if there is a new language list, then add it to the list - for (int index = sophonVOList.Count - 1; index >= 0; index--) + foreach (string vo in sophonVOList) { - var packageLocaleCodeString = sophonVOList[index]; - string langString = GetLanguageStringByLocaleCodeAlternate(packageLocaleCodeString); - if (!langList.Contains(langString, StringComparer.OrdinalIgnoreCase)) - { - langList.Add(langString); - } + string langString = GetLanguageStringByLocaleCodeAlternate(vo); + langList.Add(langString); } // Create the audio lang list file - using var sw = new StreamWriter(_gameAudioLangListPathAlternateStatic, - new FileStreamOptions - { Mode = FileMode.Create, Access = FileAccess.Write }); + using StreamWriter sw = new StreamWriter(_gameAudioLangListPathAlternateStatic, + new FileStreamOptions + { Mode = FileMode.Create, Access = FileAccess.Write }); // Iterate the package list - foreach (var voIds in langList) + foreach (string voIds in langList) // Write the language string as per ID { sw.WriteLine(voIds); diff --git a/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs b/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs index eb1975f49..834fa33ae 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/GamePropertyBase.cs @@ -75,7 +75,7 @@ protected static int ThreadCount get => (byte)LauncherConfig.AppCurrentThread; } - protected GameVersion GameVersion + internal GameVersion GameVersion { get { @@ -87,7 +87,7 @@ protected GameVersion GameVersion } } - protected IGameVersion GameVersionManager + internal IGameVersion GameVersionManager { get; } diff --git a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs index 02021ec93..732db006c 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs @@ -1108,21 +1108,54 @@ void Impl(HypPluginPackageInfo plugin) protected virtual async Task TryRunExamineThrow(Task action) { - await TryRunExamineThrow((Task)action); + // Define if the status is still running + Status.IsRunning = true; + Status.IsCompleted = false; + Status.IsCanceled = false; - if (action.IsCompletedSuccessfully) + try { - return action.Result; - } + // Run the task + T result = await action; - if ((action.IsFaulted || - action.IsCanceled) && - action.Exception != null) + Status.IsCompleted = true; + return result; + } + catch (TaskCanceledException) { - throw action.Exception; + // If a cancellation was thrown, then set IsCanceled as true + Status.IsCompleted = false; + Status.IsCanceled = true; + throw; } + catch (OperationCanceledException) + { + // If a cancellation was thrown, then set IsCanceled as true + Status.IsCompleted = false; + Status.IsCanceled = true; + throw; + } + catch (Exception) + { + // Except, if the other exception was thrown, then set both IsCompleted + // and IsCanceled as false. + Status.IsCompleted = false; + Status.IsCanceled = false; + throw; + } + finally + { + if (Status is { IsCompleted: false, IsCanceled: false }) + { + WindowUtility.SetTaskBarState(TaskbarState.Error); + } + else + { + WindowUtility.SetTaskBarState(TaskbarState.NoProgress); + } - throw new InvalidOperationException(); + Status.IsRunning = false; + } } protected virtual async Task TryRunExamineThrow(Task task) @@ -1163,7 +1196,7 @@ protected virtual async Task TryRunExamineThrow(Task task) } finally { - if (Status is { IsCompleted: false }) + if (Status is { IsCompleted: false, IsCanceled: false }) { WindowUtility.SetTaskBarState(TaskbarState.Error); } @@ -1215,7 +1248,7 @@ protected virtual async ValueTask TryRunExamineThrow(ValueTask task) } finally { - if (Status is { IsCompleted: false }) + if (Status is { IsCompleted: false, IsCanceled: false }) { WindowUtility.SetTaskBarState(TaskbarState.Error); } diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs index f4fe3d0f9..02630c1d4 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.AsbExt.Generic.cs @@ -14,58 +14,55 @@ namespace CollapseLauncher.RepairManagement; internal static partial class AssetBundleExtension { - internal static void AddBrokenAssetToList( - this ProgressBase progressBase, - FilePropertiesRemote asset, - byte[]? finalHash = null, - long? useFoundSize = null) + extension(ProgressBase progressBase) { - AssetProperty property = - new AssetProperty(Path.GetFileName(asset.N), - asset.GetRepairAssetType(), - Path.GetDirectoryName(asset.N) ?? "\\", - useFoundSize ?? asset.S, - finalHash, - asset.CRCArray); - - asset.AssociatedAssetProperty = property; - progressBase.Dispatch(AddToUITable); - lock (progressBase.AssetIndex) + internal void AddBrokenAssetToList(FilePropertiesRemote asset, + byte[]? finalHash = null, + long? useFoundSize = null) { - progressBase.AssetIndex.Add(asset); - } + AssetProperty property = + new AssetProperty(Path.GetFileName(asset.N), + asset.GetRepairAssetType(), + Path.GetDirectoryName(asset.N) ?? "\\", + useFoundSize ?? asset.S, + finalHash, + asset.CRCArray); - progressBase.Status.IsAssetEntryPanelShow = progressBase.AssetIndex.Count > 0; - progressBase.UpdateStatus(); - Interlocked.Add(ref progressBase.ProgressAllSizeFound, useFoundSize ?? asset.S); - Interlocked.Increment(ref progressBase.ProgressAllCountFound); + asset.AssociatedAssetProperty = property; + progressBase.Dispatch(AddToUITable); + lock (progressBase.AssetIndex) + { + progressBase.AssetIndex.Add(asset); + } - return; + progressBase.Status.IsAssetEntryPanelShow = progressBase.AssetIndex.Count > 0; + progressBase.UpdateStatus(); + Interlocked.Add(ref progressBase.ProgressAllSizeFound, useFoundSize ?? asset.S); + Interlocked.Increment(ref progressBase.ProgressAllCountFound); - void AddToUITable() - { - progressBase.AssetEntry.Add(property); + return; + + void AddToUITable() + { + progressBase.AssetEntry.Add(property); + } } - } - internal static void PopBrokenAssetFromList( - this ProgressBase progressBase, - FilePropertiesRemote asset) - { - if (asset.AssociatedAssetProperty is IAssetProperty assetProperty) + internal void PopBrokenAssetFromList(FilePropertiesRemote asset) { - progressBase.PopRepairAssetEntry(assetProperty); + if (asset.AssociatedAssetProperty is IAssetProperty assetProperty) + { + progressBase.PopRepairAssetEntry(assetProperty); + } } - } - internal static void UpdateCurrentRepairStatus( - this ProgressBase progressBase, - FilePropertiesRemote asset) - { - // Increment total count current - progressBase.ProgressAllCountCurrent++; - progressBase.Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status8, asset.N); - progressBase.UpdateStatus(); + internal void UpdateCurrentRepairStatus(FilePropertiesRemote asset, bool isCacheUpdateMode = false) + { + // Increment total count current + progressBase.ProgressAllCountCurrent++; + progressBase.Status.ActivityStatus = string.Format(isCacheUpdateMode ? Locale.Lang!._Misc!.Downloading + ": {0}" : Locale.Lang._GameRepairPage.Status8, asset.N); + progressBase.UpdateStatus(); + } } private static RepairAssetType GetRepairAssetType(this FilePropertiesRemote asset) => diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs index 4acbc4ae6..56c47fab4 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Fetch.cs @@ -44,64 +44,12 @@ internal class SenadinaFileResult #region Fetch by Sophon private async Task FetchAssetFromSophon(List assetIndex, CancellationToken token) { - // Set total activity string as "Fetching Caches Type: " - Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "Sophon"); - Status.IsProgressAllIndetermined = true; - Status.IsIncludePerFileIndicator = false; - UpdateStatus(); - - PresetConfig gamePreset = GameVersionManager!.GamePreset; - string? sophonApiUrl = gamePreset.LauncherResourceChunksURL?.MainUrl; - string? matchingField = gamePreset.LauncherResourceChunksURL?.MainBranchMatchingField; - - if (sophonApiUrl == null) - { - throw new NullReferenceException("Sophon API URL inside of the metadata is null. Try to clear your metadata by going to \"App Settings\" > \"Clear Metadata and Restart\""); - } - - string versionApiToUse = GameVersion.ToString(); - sophonApiUrl += $"&tag={versionApiToUse}"; - - SophonChunkManifestInfoPair infoPair = await SophonManifest - .CreateSophonChunkManifestInfoPair(HttpClientGeneric, - sophonApiUrl, - matchingField, - false, + await this.FetchAssetsFromSophonAsync(HttpClientGeneric, + assetIndex, + DetermineFileTypeFromExtension, + GameVersion, + ["en-us", "zh-cn", "ja-jp", "ko-kr"], token); - - if (!infoPair.IsFound) - { - throw new InvalidOperationException($"Sophon cannot find matching field: {matchingField} from API URL: {sophonApiUrl}"); - } - - SearchValues excludedMatchingFields = - SearchValues.Create(["en-us", "zh-cn", "ja-jp", "ko-kr"], StringComparison.OrdinalIgnoreCase); - List infoPairs = [infoPair]; - infoPairs.AddRange(infoPair - .OtherSophonBuildData? - .ManifestIdentityList - .Where(x => !x.MatchingField - .ContainsAny(excludedMatchingFields) && !x.MatchingField.Equals(matchingField)) - .Select(x => infoPair.GetOtherManifestInfoPair(x.MatchingField)) ?? []); - - - foreach (SophonChunkManifestInfoPair pair in infoPairs) - { - await foreach (SophonAsset asset in SophonManifest - .EnumerateAsync(HttpClientGeneric, - pair, - token: token)) - { - assetIndex.Add(new FilePropertiesRemote - { - AssociatedObject = asset, - S = asset.AssetSize, - N = asset.AssetName.NormalizePath(), - CRC = asset.AssetHash, - FT = DetermineFileTypeFromExtension(asset.AssetName) - }); - } - } } #endregion diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs index 882f1e354..905b650f3 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.Generic.cs @@ -24,30 +24,37 @@ private async ValueTask RepairAssetGenericSophonType( // Update repair status to the UI this.UpdateCurrentRepairStatus(asset); - string assetPath = Path.Combine(GamePath, asset.N); - FileInfo assetFileInfo = new FileInfo(assetPath) - .StripAlternateDataStream() - .EnsureCreationOfDirectory() - .EnsureNoReadOnly(); + try + { + string assetPath = Path.Combine(GamePath, asset.N); + FileInfo assetFileInfo = new FileInfo(assetPath) + .StripAlternateDataStream() + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(); - await using FileStream assetFileStream = assetFileInfo - .Open(FileMode.Create, - FileAccess.Write, - FileShare.Write, - asset.S.GetFileStreamBufferSize()); + await using FileStream assetFileStream = assetFileInfo + .Open(FileMode.Create, + FileAccess.Write, + FileShare.Write, + asset.S.GetFileStreamBufferSize()); - if (asset.AssociatedObject is not SophonAsset sophonAsset) + if (asset.AssociatedObject is not SophonAsset sophonAsset) + { + throw new + InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); + } + + // Download as Sophon asset + await sophonAsset + .WriteToStreamAsync(HttpClientGeneric, + assetFileStream, + readBytes => UpdateProgressCounter(readBytes, readBytes), + token: token); + } + finally { - throw new - InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); + this.PopBrokenAssetFromList(asset); } - - // Download as Sophon asset - await sophonAsset - .WriteToStreamAsync(HttpClientGeneric, - assetFileStream, - readBytes => UpdateProgressCounter(readBytes, readBytes), - token: token); } private async ValueTask RepairAssetGenericType( @@ -61,6 +68,7 @@ private async ValueTask RepairAssetGenericType( string assetPath = Path.Combine(GamePath, asset.N); FileInfo assetFileInfo = new FileInfo(assetPath) .StripAlternateDataStream() + .EnsureCreationOfDirectory() .EnsureNoReadOnly(); try diff --git a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs index 9a1139c93..faa5ff534 100644 --- a/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/HonkaiV2/HonkaiRepairV2.Repair.cs @@ -50,11 +50,15 @@ private async Task StartRepairRoutineCoreAsync(bool showInteractivePrompt await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); } + int threadNum = IsBurstDownloadEnabled + ? 1 + : ThreadForIONormalized; + await Parallel.ForEachAsync(AssetIndex, new ParallelOptions { CancellationToken = Token!.Token, - MaxDegreeOfParallelism = ThreadForIONormalized + MaxDegreeOfParallelism = threadNum }, Impl); diff --git a/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs new file mode 100644 index 000000000..0c241e92f --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/SophonRepairFetchUtility.cs @@ -0,0 +1,95 @@ +using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.Interfaces; +using Hi3Helper; +using Hi3Helper.Plugin.Core.Management; +using Hi3Helper.Shared.ClassStruct; +using Hi3Helper.Sophon; +using Hi3Helper.Sophon.Structs; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable +#pragma warning disable IDE0130 +namespace CollapseLauncher.RepairManagement; + +internal static class RepairSharedUtility +{ + public static async Task FetchAssetsFromSophonAsync( + this ProgressBase instance, + HttpClient client, + List assetIndex, + Func assetTypeDeterminer, + GameVersion gameVersion, + string[] excludeMatchingFieldList, + CancellationToken token = default) + { + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "Sophon"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + PresetConfig gamePreset = instance.GameVersionManager.GamePreset; + string? sophonApiUrl = gamePreset.LauncherResourceChunksURL?.MainUrl; + string? matchingField = gamePreset.LauncherResourceChunksURL?.MainBranchMatchingField; + + if (sophonApiUrl == null) + { + throw new NullReferenceException("Sophon API URL inside of the metadata is null. Try to clear your metadata by going to \"App Settings\" > \"Clear Metadata and Restart\""); + } + + string versionApiToUse = gameVersion.ToString(); + sophonApiUrl += $"&tag={versionApiToUse}"; + + SophonChunkManifestInfoPair infoPair = await SophonManifest + .CreateSophonChunkManifestInfoPair(client, + sophonApiUrl, + matchingField, + false, + token); + + if (!infoPair.IsFound) + { + throw new InvalidOperationException($"Sophon cannot find matching field: {matchingField} from API URL: {sophonApiUrl}"); + } + + SearchValues excludedMatchingFields = SearchValues.Create(excludeMatchingFieldList, StringComparison.OrdinalIgnoreCase); + List infoPairs = [infoPair]; + infoPairs.AddRange(infoPair + .OtherSophonBuildData? + .ManifestIdentityList + .Where(x => !x.MatchingField + .ContainsAny(excludedMatchingFields) && !x.MatchingField.Equals(matchingField)) + .Select(x => infoPair.GetOtherManifestInfoPair(x.MatchingField)) ?? []); + + foreach (SophonChunkManifestInfoPair pair in infoPairs) + { + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Sophon ({pair.MatchingField})"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + await foreach (SophonAsset asset in SophonManifest + .EnumerateAsync(client, + pair, + token: token)) + { + assetIndex.Add(new FilePropertiesRemote + { + AssociatedObject = asset, + S = asset.AssetSize, + N = asset.AssetName.NormalizePath(), + CRC = asset.AssetHash, + FT = assetTypeDeterminer(asset.AssetName) + }); + } + } + } + +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs deleted file mode 100644 index ac795da81..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Check.cs +++ /dev/null @@ -1,411 +0,0 @@ -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.SentryHelper; -using Hi3Helper.Shared.ClassStruct; -using Hi3Helper.Win32.Native.LibraryImport; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable StringLiteralTypo -// ReSharper disable CommentTypo -// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault - -namespace CollapseLauncher -{ - internal static partial class StarRailRepairExtension - { - internal static string ReplaceStreamingToPersistentPath(string inputPath, string execName, FileType type) - { - string parentStreamingRelativePath = string.Format(type switch - { - FileType.Block => StarRailRepair.AssetGameBlocksStreamingPath, - FileType.Audio => StarRailRepair.AssetGameAudioStreamingPath, - FileType.Video => StarRailRepair.AssetGameVideoStreamingPath, - _ => string.Empty - }, execName); - string parentPersistentRelativePath = string.Format(type switch - { - FileType.Block => StarRailRepair.AssetGameBlocksPersistentPath, - FileType.Audio => StarRailRepair.AssetGameAudioPersistentPath, - FileType.Video => StarRailRepair.AssetGameVideoPersistentPath, - _ => string.Empty - }, execName); - - int indexOfStart = inputPath.IndexOf(parentStreamingRelativePath, StringComparison.Ordinal); - int indexOfEnd = indexOfStart + parentStreamingRelativePath.Length; - - if (indexOfStart == -1) return inputPath; - - ReadOnlySpan startOfPath = inputPath.AsSpan(0, indexOfStart).TrimEnd('\\'); - ReadOnlySpan endOfPath = inputPath.AsSpan(indexOfEnd, inputPath.Length - indexOfEnd).TrimStart('\\'); - - string returnPath = Path.Join(startOfPath, parentPersistentRelativePath, endOfPath); - return returnPath; - } - - internal static string GetFileRelativePath(string inputPath, string parentPath) => inputPath.AsSpan(parentPath.Length).ToString(); - } - - internal partial class StarRailRepair - { - private async Task Check(List assetIndex, CancellationToken token) - { - // Try to find "badlist.byte" files in the game folder and delete it - foreach (string badListFile in Directory.EnumerateFiles(GamePath, "*badlist*.byte*", SearchOption.AllDirectories)) - { - LogWriteLine($"Removing bad list mark at: {badListFile}", LogType.Warning, true); - TryDeleteReadOnlyFile(badListFile); - } - - // Try to find "verify.fail" files in the game folder and delete it - foreach (string verifyFail in Directory.EnumerateFiles(GamePath, "*verify*.fail*", SearchOption.AllDirectories)) - { - LogWriteLine($"Removing verify.fail mark at: {verifyFail}", LogType.Warning, true); - TryDeleteReadOnlyFile(verifyFail); - } - - List brokenAssetIndex = []; - - // Set Indetermined status as false - Status.IsProgressAllIndetermined = false; - Status.IsProgressPerFileIndetermined = false; - - // Show the asset entry panel - Status.IsAssetEntryPanelShow = true; - - // Await the task for parallel processing - try - { - // Iterate assetIndex and check it using different method for each type and run it in parallel - await Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount, CancellationToken = token }, async (asset, threadToken) => - { - // Assign a task depends on the asset type - switch (asset.FT) - { - case FileType.Generic: - await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); - break; - case FileType.Block: - case FileType.Audio: - case FileType.Video: - await CheckAssetType(asset, brokenAssetIndex, threadToken); - break; - } - }); - } - catch (AggregateException ex) - { - var innerExceptionsFirst = ex.Flatten().InnerExceptions.First(); - await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); - throw innerExceptionsFirst; - } - - // Re-add the asset index with a broken asset index - assetIndex.Clear(); - assetIndex.AddRange(brokenAssetIndex); - } - - #region AssetTypeCheck - private async Task CheckGenericAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) - { - // Update activity status - Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, - StarRailRepairExtension.GetFileRelativePath(asset.N, GamePath)); - - // Increment current total count - ProgressAllCountCurrent++; - - // Reset per file size counter - ProgressPerFileSizeTotal = asset.S; - ProgressPerFileSizeCurrent = 0; - - // Get the file info - FileInfo fileInfo = new FileInfo(asset.N); - - // Check if the file exist or has unmatched size - if (!fileInfo.Exists) - { - AddIndex(); - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is not found", LogType.Warning, true); - return; - } - - if (fileInfo.Length != asset.S) - { - if (fileInfo.Name.Contains("pkg_version")) return; - AddIndex(); - LogWriteLine($"File [T: {asset.FT}]: {asset.N} has unmatched size " + - $"(Local: {fileInfo.Length} <=> Remote: {asset.S}", - LogType.Warning, true); - return; - } - - // Skip CRC check if fast method is used - if (UseFastMethod) - { - return; - } - - // Open and read fileInfo as FileStream - await using FileStream fileStream = await NaivelyOpenFileStreamAsync(fileInfo, FileMode.Open, FileAccess.Read, FileShare.Read); - // If pass the check above, then do CRC calculation - // Additional: the total file size progress is disabled and will be incremented after this - byte[] localCrc = await GetCryptoHashAsync(fileStream, null, true, true, token); - - // If local and asset CRC doesn't match, then add the asset - if (IsArrayMatch(localCrc, asset.CRCArray)) - { - return; - } - - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - localCrc, - asset.CRCArray - ) - )); - - // Mark the main block as "need to be repaired" - asset.IsBlockNeedRepair = true; - targetAssetIndex.Add(asset); - - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is broken! Index CRC: {asset.CRC} <--> File CRC: {HexTool.BytesToHexUnsafe(localCrc)}", LogType.Warning, true); - return; - - void AddIndex() - { - // Update the total progress and found counter - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - // Set the per size progress - ProgressPerFileSizeCurrent = asset.S; - - // Increment the total current progress - ProgressAllSizeCurrent += asset.S; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - null, - null - ) - )); - targetAssetIndex.Add(asset); - } - } - - private async Task CheckAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) - { - // Update activity status - Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, - StarRailRepairExtension.GetFileRelativePath(asset.N, GamePath)); - - // Increment current total count - ProgressAllCountCurrent++; - - // Reset per file size counter - ProgressPerFileSizeTotal = asset.S; - ProgressPerFileSizeCurrent = 0; - - // Get persistent and streaming paths - FileInfo fileInfoPersistent = new FileInfo(StarRailRepairExtension.ReplaceStreamingToPersistentPath(asset.N, ExecName, asset.FT)); - FileInfo fileInfoStreaming = new FileInfo(asset.N); - - bool usePersistent = asset.IsPatchApplicable || !fileInfoStreaming.Exists; - bool isHasMark = asset.IsHasHashMark || usePersistent; - bool isPersistentExist = fileInfoPersistent.Exists && fileInfoPersistent.Length == asset.S; - bool isStreamingExist = fileInfoStreaming.Exists && fileInfoStreaming.Length == asset.S; - - // Update the local path to full persistent or streaming path and add asset for missing/unmatched size file - asset.N = usePersistent ? fileInfoPersistent.FullName : fileInfoStreaming.FullName; - - // Check if the file exist on both persistent and streaming path for non-patch file, then mark the - // persistent path as redundant (unused) - bool isNonPatchHasRedundantPersistent = !asset.IsPatchApplicable && isPersistentExist && isStreamingExist && fileInfoStreaming.Length == asset.S; - - if (isNonPatchHasRedundantPersistent) - { - // Add the count and asset. Mark the type as "RepairAssetType.Unused" - ProgressAllCountFound++; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(fileInfoPersistent.FullName), - RepairAssetType.Unused, - Path.GetDirectoryName(fileInfoPersistent.FullName), - asset.S, - null, - null - ) - )); - - // Create a new instance as unused one - FilePropertiesRemote unusedAsset = new FilePropertiesRemote - { - N = fileInfoPersistent.FullName, - FT = FileType.Unused, - RN = asset.RN, - CRC = asset.CRC, - S = asset.S - }; - targetAssetIndex.Add(unusedAsset); - - LogWriteLine($"File [T: {asset.FT}]: {unusedAsset.N} is redundant (exist both on persistent and streaming)", LogType.Warning, true); - } - - // If the file has Hash Mark or is persistent, then create the hash mark file - if (isHasMark) CreateHashMarkFile(asset.N, asset.CRC); - - // Check if both location has the file exist or has the size right - if ((usePersistent && !isPersistentExist && !isStreamingExist) - || (usePersistent && !isPersistentExist)) - { - // Update the total progress and found counter - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - // Set the per size progress - ProgressPerFileSizeCurrent = asset.S; - - // Increment the total current progress - ProgressAllSizeCurrent += asset.S; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - null, - null - ) - )); - targetAssetIndex.Add(asset); - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is not found or has unmatched size", LogType.Warning, true); - - return; - } - - // Skip CRC check if fast method is used - if (UseFastMethod) - { - return; - } - - // Open and read fileInfo as FileStream - string fileNameToOpen = usePersistent ? fileInfoPersistent.FullName : fileInfoStreaming.FullName; - try - { - await CheckFile(fileNameToOpen, asset, targetAssetIndex, token); - } - catch (FileNotFoundException ex) - { - await SentryHelper.ExceptionHandlerAsync(ex); - LogWriteLine($"File {fileNameToOpen} is not found while UsePersistent is {usePersistent}. " + - $"Creating hard link and retrying...", LogType.Warning, true); - - var targetFile = File.Exists(fileInfoPersistent.FullName) ? fileInfoPersistent.FullName : - File.Exists(fileInfoStreaming.FullName) ? fileInfoStreaming.FullName : - throw new FileNotFoundException(fileNameToOpen); - - PInvoke.CreateHardLink(fileNameToOpen, targetFile, IntPtr.Zero); - await CheckFile(fileNameToOpen, asset, targetAssetIndex, token); - } - } - - private async Task CheckFile(string fileNameToOpen, FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) - { - await using FileStream fileStream = new FileStream(fileNameToOpen, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - BufferBigLength); - // If pass the check above, then do CRC calculation - // Additional: the total file size progress is disabled and will be incremented after this - byte[] localCrc = await GetCryptoHashAsync(fileStream, null, true, true, token); - - // If local and asset CRC doesn't match, then add the asset - if (!IsArrayMatch(localCrc, asset.CRCArray)) - { - ProgressAllSizeFound += asset.S; - ProgressAllCountFound++; - - Dispatch(() => AssetEntry.Add( - new AssetProperty( - Path.GetFileName(asset.N), - ConvertRepairAssetTypeEnum(asset.FT), - Path.GetDirectoryName(asset.N), - asset.S, - localCrc, - asset.CRCArray - ) - )); - - // Mark the main block as "need to be repaired" - asset.IsBlockNeedRepair = true; - targetAssetIndex.Add(asset); - - LogWriteLine($"File [T: {asset.FT}]: {asset.N} is broken! Index CRC: {asset.CRC} <--> File CRC: {HexTool.BytesToHexUnsafe(localCrc)}", LogType.Warning, true); - } - } - - private static void CreateHashMarkFile(string filePath, string hash) - { - RemoveHashMarkFile(filePath, out var basePath, out var baseName); - - // Create base path if not exist - if (!string.IsNullOrEmpty(basePath) && !Directory.Exists(basePath)) - Directory.CreateDirectory(basePath); - - // Re-create the hash file - string toName = Path.Combine(basePath ?? "", $"{baseName}_{hash}.hash"); - if (File.Exists(toName)) return; - File.Create(toName).Dispose(); - } - - private static void RemoveHashMarkFile(string filePath, out string basePath, out string baseName) - { - // Get the base path and name - basePath = Path.GetDirectoryName(filePath); - baseName = Path.GetFileNameWithoutExtension(filePath); - - // Get directory base info. If it doesn't exist, return - if (string.IsNullOrEmpty(basePath)) - { - return; - } - - DirectoryInfo basePathDirInfo = new DirectoryInfo(basePath); - if (!basePathDirInfo.Exists) - { - return; - } - - // Enumerate any possible existing hash path and delete it - foreach (FileInfo existingPath in basePathDirInfo.EnumerateFiles($"{baseName}_*.hash") - .EnumerateNoReadOnly()) - { - existingPath.Delete(); - } - } - #endregion - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs deleted file mode 100644 index 0546e066f..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Fetch.cs +++ /dev/null @@ -1,437 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.Metadata; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.EncTool.Parser.AssetIndex; -using Hi3Helper.EncTool.Parser.AssetMetadata.SRMetadataAsset; -using Hi3Helper.Http; -using Hi3Helper.Shared.ClassStruct; -using Hi3Helper.Shared.Region; -using System; -using System.Collections.Generic; -using System.Data; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Data.ConverterTool; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; -// ReSharper disable CommentTypo -// ReSharper disable StringLiteralTypo -// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault - -namespace CollapseLauncher -{ - internal static partial class StarRailRepairExtension - { - private static readonly Dictionary Hashtable = new(); - - internal static void ClearHashtable() => Hashtable.Clear(); - - internal static void AddSanitize(this List assetIndex, FilePropertiesRemote assetProperty) - { - string key = assetProperty.N + assetProperty.IsPatchApplicable; - - // Check if the asset has the key - // If yes (exist), then get the index of the asset from hashtable - if (Hashtable.TryGetValue(key, out int index)) - { - // Get the property of the asset based on index from hashtable - FilePropertiesRemote oldAssetProperty = assetIndex[index]; - // If the hash is not equal, then replace the existing property from assetIndex - if (oldAssetProperty.CRCArray - .AsSpan() - .SequenceEqual(assetProperty.CRCArray)) - { - return; - } - #if DEBUG - LogWriteLine($"[StarRailRepairExtension::AddSanitize()] Replacing duplicate of: {assetProperty.N} from: {oldAssetProperty.CRC}|{oldAssetProperty.S} to {assetProperty.CRC}|{assetProperty.S}", LogType.Debug, true); - #endif - assetIndex[index] = assetProperty; - return; - } - - Hashtable.Add(key, assetIndex.Count); - assetIndex.Add(assetProperty); - } - } - - internal partial class StarRailRepair - { - private async Task Fetch(List assetIndex, CancellationToken token) - { - // Set total activity string as "Loading Indexes..." - Status.ActivityStatus = Lang._GameRepairPage.Status2; - Status.IsProgressAllIndetermined = true; - - UpdateStatus(); - StarRailRepairExtension.ClearHashtable(); - - // Initialize new proxy-aware HttpClient - using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); - - // Initialize the new DownloadClient - DownloadClient downloadClient = DownloadClient.CreateInstance(client); - - try - { - // Get the primary manifest - await GetPrimaryManifest(assetIndex, token); - - // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex - if (IsOnlyRecoverMain && IsVersionOverride) - { - OriginAssetIndex = []; - foreach (FilePropertiesRemote asset in assetIndex) - { - FilePropertiesRemote newAsset = asset.Copy(); - ReadOnlyMemory assetRelativePath = newAsset.N.AsMemory(GamePath.Length).TrimStart('\\'); - newAsset.N = assetRelativePath.ToString(); - OriginAssetIndex.Add(newAsset); - } - } - - // Subscribe the fetching progress and subscribe StarRailMetadataTool progress to adapter - // _innerGameVersionManager.StarRailMetadataTool.HttpEvent += _httpClient_FetchAssetProgress; - - // Initialize the metadata tool (including dispatcher and gateway). - // Perform this if only base._isVersionOverride is false to indicate that the repair performed is - // not for delta patch integrity check. - if (!IsVersionOverride && !IsOnlyRecoverMain && await InnerGameVersionManager.StarRailMetadataTool.Initialize(token, downloadClient, _httpClient_FetchAssetProgress, GetExistingGameRegionID(), Path.Combine(GamePath, $"{Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName)}_Data\\Persistent"))) - { - await Task.WhenAll( - // Read Block metadata - InnerGameVersionManager.StarRailMetadataTool.ReadAsbMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token), - InnerGameVersionManager.StarRailMetadataTool.ReadBlockMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token), - // Read Audio metadata - InnerGameVersionManager.StarRailMetadataTool.ReadAudioMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token), - // Read Video metadata - InnerGameVersionManager.StarRailMetadataTool.ReadVideoMetadataInformation(downloadClient, _httpClient_FetchAssetProgress, token) - ).ConfigureAwait(false); - - // Convert Block, Audio and Video metadata to FilePropertiesRemote - ConvertSrMetadataToAssetIndex(InnerGameVersionManager.StarRailMetadataTool.MetadataBlock, assetIndex); - ConvertSrMetadataToAssetIndex(InnerGameVersionManager.StarRailMetadataTool.MetadataAudio, assetIndex, true); - ConvertSrMetadataToAssetIndex(InnerGameVersionManager.StarRailMetadataTool.MetadataVideo, assetIndex); - } - - // Force-Fetch the Bilibili SDK (if exist :pepehands:) - await FetchBilibiliSdk(token); - - // Remove plugin from assetIndex - // Skip the removal for Delta-Patch - if (!IsOnlyRecoverMain) - { - EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); - } - } - finally - { - // Clear the hashtable - StarRailRepairExtension.ClearHashtable(); - // Unsubscribe the fetching progress and dispose it and unsubscribe cacheUtil progress to adapter - // _innerGameVersionManager.StarRailMetadataTool.HttpEvent -= _httpClient_FetchAssetProgress; - } - } - - #region PrimaryManifest - private async Task GetPrimaryManifest(List assetIndex, CancellationToken token) - { - // Initialize pkgVersion list - List pkgVersion = []; - - // Initialize repo metadata - try - { - // Get the metadata - Dictionary repoMetadata = await FetchMetadata(token); - - // Check for manifest. If it doesn't exist, then throw and warn the user - if (!repoMetadata.TryGetValue(GameVersion.VersionString, out var value)) - { - throw new VersionNotFoundException($"Manifest for {GameVersionManager.GamePreset.ZoneName} (version: {GameVersion.VersionString}) doesn't exist! Please contact @neon-nyan or open an issue for this!"); - } - - // Assign the URL based on the version - GameRepoURL = value; - } - // If the base._isVersionOverride is true, then throw. This sanity check is required if the delta patch is being performed. - catch when (IsVersionOverride) { throw; } - - // Fetch the asset index from CDN - // Set asset index URL - string urlIndex = string.Format(LauncherConfig.AppGameRepairIndexURLPrefix, GameVersionManager.GamePreset.ProfileName, GameVersion.VersionString) + ".binv2"; - - // Start downloading asset index using FallbackCDNUtil and return its stream - await using Stream stream = await FallbackCDNUtil.TryGetCDNFallbackStream(urlIndex, token: token); - // Deserialize asset index and set it to list - AssetIndexV2 parserTool = new AssetIndexV2(); - pkgVersion = parserTool.Deserialize(stream, out DateTime timestamp); - LogWriteLine($"Asset index timestamp: {timestamp}", LogType.Default, true); - - // Convert the pkg version list to asset index - ConvertPkgVersionToAssetIndex(pkgVersion, assetIndex); - - // Clear the pkg version list - pkgVersion.Clear(); - } - - private async Task> FetchMetadata(CancellationToken token) - { - // Set metadata URL - string urlMetadata = string.Format(LauncherConfig.AppGameRepoIndexURLPrefix, GameVersionManager.GamePreset.ProfileName); - - // Start downloading metadata using FallbackCDNUtil - await using Stream stream = await FallbackCDNUtil.TryGetCDNFallbackStream(urlMetadata, token: token); - return await stream.DeserializeAsync(CoreLibraryJsonContext.Default.DictionaryStringString, token: token); - } - - private void ConvertPkgVersionToAssetIndex(List pkgVersion, List assetIndex) - { - for (var index = pkgVersion.Count - 1; index >= 0; index--) - { - var entry = pkgVersion[index]; - // Add the pkgVersion entry to asset index - FilePropertiesRemote normalizedProperty = GetNormalizedFilePropertyTypeBased( - GameRepoURL, - entry.remoteName, - entry.fileSize, - entry.md5, - FileType.Generic, - true); - assetIndex.AddSanitize(normalizedProperty); - } - } - #endregion - - #region Utilities - private FilePropertiesRemote GetNormalizedFilePropertyTypeBased(string remoteParentURL, - string remoteRelativePath, - long fileSize, - string hash, - FileType type = FileType.Generic, - bool isPatchApplicable = false, - bool isHasHashMark = false) - { - string remoteAbsolutePath = type switch - { - FileType.Generic => CombineURLFromString(remoteParentURL, remoteRelativePath), - _ => remoteParentURL - }, - typeAssetRelativeParentPath = string.Format(type switch - { - FileType.Block => AssetGameBlocksStreamingPath, - FileType.Audio => AssetGameAudioStreamingPath, - FileType.Video => AssetGameVideoStreamingPath, - _ => string.Empty - }, ExecName); - - var localAbsolutePath = Path.Combine(GamePath, typeAssetRelativeParentPath, NormalizePath(remoteRelativePath)); - - return new FilePropertiesRemote - { - FT = type, - CRC = hash, - S = fileSize, - N = localAbsolutePath, - RN = remoteAbsolutePath, - IsPatchApplicable = isPatchApplicable, - IsHasHashMark = isHasHashMark - }; - } - - private unsafe string GetExistingGameRegionID() - { - // Delegate the default return value - string GetDefaultValue() => InnerGameVersionManager.GamePreset.GameDispatchDefaultName ?? throw new KeyNotFoundException("Default dispatcher name in metadata is not exist!"); - -#nullable enable - // Try to get the value as nullable object - object? value = GameSettings?.RegistryRoot?.GetValue("App_LastServerName_h2577443795", null); - // Check if the value is null, then return the default name - // Return the dispatch default name. If none, then throw - if (value == null) return GetDefaultValue(); -#nullable disable - - // Cast the value as byte array - byte[] valueBytes = (byte[])value; - int count = valueBytes.Length; - - // If the registry is empty, then return the default value; - if (valueBytes.Length == 0) - return GetDefaultValue(); - - // Get the pointer of the byte array - fixed (byte* valuePtr = &valueBytes[0]) - { - // Try check the byte value. If it's null, then continue the loop while - // also decreasing the count as its index - while (*(valuePtr + (count - 1)) == 0) { --count; } - - // Get the name from the span and trim the \0 character at the end - string name = Encoding.UTF8.GetString(valuePtr, count); - return name; - } - } - - private void ConvertSrMetadataToAssetIndex(SRMetadataBase metadata, List assetIndex, bool writeAudioLangReordered = false) - { - // Get the voice Lang ID - int voLangID = InnerGameVersionManager.GamePreset.GetVoiceLanguageID(); - // Get the voice Lang name by ID - string voLangName = PresetConfig.GetStarRailVoiceLanguageFullNameByID(voLangID); - - // If prompt to write Redord file - if (writeAudioLangReordered) - { - // Get game executable name, directory and file path - string execName = Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName); - string audioReorderedDir = Path.Combine(GamePath, @$"{execName}_Data\Persistent\Audio\AudioPackage\Windows"); - string audioReorderedPath = EnsureCreationOfDirectory(Path.Combine(audioReorderedDir, "AudioLangRedord.txt")); - - // Then write the Redord file content - File.WriteAllText(audioReorderedPath, "{\"AudioLang\":\"" + voLangName + "\"}"); - } - - // Get the audio lang list - string[] audioLangList = GetCurrentAudioLangList(voLangName); - - // Enumerate the Asset List - int lastAssetIndexCount = assetIndex.Count; - foreach (SRAsset asset in metadata.EnumerateAssets()) - { - // Get the hash by bytes - string hash = HexTool.BytesToHexUnsafe(asset.Hash); - - // Filter only current audio language file and other assets - if (!FilterCurrentAudioLangFile(asset, audioLangList, out bool isHasHashMark)) - { - continue; - } - - // Convert and add the asset as FilePropertiesRemote to assetIndex - FilePropertiesRemote assetProperty = GetNormalizedFilePropertyTypeBased( - asset.RemoteURL, - asset.LocalName, - asset.Size, - hash, - ConvertFileTypeEnum(asset.AssetType), - asset.IsPatch, - isHasHashMark - ); - assetIndex.AddSanitize(assetProperty); - } - - int addedCount = assetIndex.Count - lastAssetIndexCount; - long addedSize = 0; - ReadOnlySpan assetIndexSpan = CollectionsMarshal.AsSpan(assetIndex)[lastAssetIndexCount..]; - for (int i = assetIndexSpan.Length - 1; i >= 0; i--) addedSize += assetIndexSpan[i].S; - - LogWriteLine($"Added additional {addedCount} assets with {SummarizeSizeSimple(addedSize)}/{addedSize} bytes in size", LogType.Default, true); - } - - private string[] GetCurrentAudioLangList(string fallbackCurrentLangName) - { - // Initialize the variable. - string audioLangListPath = GameAudioLangListPath; - string audioLangListPathStatic = GameAudioLangListPathStatic; - string[] returnValue; - - // Check if the audioLangListPath is null or the file is not exist, - // then create a new one from the fallback value - if (audioLangListPath == null || !File.Exists(audioLangListPathStatic)) - { - // Try check if the folder exist. If not, create one. - string audioLangPathDir = Path.GetDirectoryName(audioLangListPathStatic); - if (Directory.Exists(audioLangPathDir)) - Directory.CreateDirectory(audioLangPathDir); - - // Assign the default value and write to the file, then return. - returnValue = [fallbackCurrentLangName]; - if (audioLangListPathStatic != null) - { - File.WriteAllLines(audioLangListPathStatic, returnValue); - } - - return returnValue; - } - - // Read all the lines. If empty, then assign the default value and rewrite it - returnValue = File.ReadAllLines(audioLangListPathStatic); - if (returnValue.Length != 0) - { - return returnValue; - } - - returnValue = [fallbackCurrentLangName]; - File.WriteAllLines(audioLangListPathStatic, returnValue); - - // Return the value - return returnValue; - } - - private static bool FilterCurrentAudioLangFile(SRAsset asset, string[] langNames, out bool isHasHashMark) - { - // Set output value as false - isHasHashMark = false; - switch (asset.AssetType) - { - // In case if the type is SRAssetType.Audio, then do filtering - case SRAssetType.Audio: - // Set isHasHashMark to true - isHasHashMark = true; - // Split the name definition from LocalName - string[] nameDef = asset.LocalName.Split('/'); - // If the name definition array length > 1, then start do filtering - if (nameDef.Length > 1) - { - // Compare if the first name definition is equal to target langName. - // Also return if the file is an audio language file if it is SFX file or not. - return langNames.Contains(nameDef[0], StringComparer.OrdinalIgnoreCase) || nameDef[0] == "SFX"; - } - // If it's not in criteria of name definition, then return true as "normal asset" - return true; - default: - // return true as "normal asset" - return true; - } - } - - private void CountAssetIndex(List assetIndex) - { - // Sum the assetIndex size and assign to _progressAllSize - ProgressAllSizeTotal = assetIndex.Sum(x => x.S); - - // Assign the assetIndex count to _progressAllCount - ProgressAllCountTotal = assetIndex.Count; - } - - private static FileType ConvertFileTypeEnum(SRAssetType assetType) => assetType switch - { - SRAssetType.Asb => FileType.Block, - SRAssetType.Block => FileType.Block, - SRAssetType.Audio => FileType.Audio, - SRAssetType.Video => FileType.Video, - _ => FileType.Generic - }; - - private static RepairAssetType ConvertRepairAssetTypeEnum(FileType assetType) => assetType switch - { - FileType.Block => RepairAssetType.Block, - FileType.Audio => RepairAssetType.Audio, - FileType.Video => RepairAssetType.Video, - _ => RepairAssetType.Generic - }; - #endregion - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs deleted file mode 100644 index 371a1b77d..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/Repair.cs +++ /dev/null @@ -1,143 +0,0 @@ -using CollapseLauncher.Helper; -using CollapseLauncher.Helper.StreamUtility; -using Hi3Helper; -using Hi3Helper.Data; -using Hi3Helper.Http; -using Hi3Helper.Shared.ClassStruct; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -using static Hi3Helper.Logger; - -namespace CollapseLauncher -{ - internal partial class StarRailRepair - { - private async Task Repair(List repairAssetIndex, CancellationToken token) - { - // Set total activity string as "Waiting for repair process to start..." - Status.ActivityStatus = Lang._GameRepairPage.Status11; - Status.IsProgressAllIndetermined = true; - Status.IsProgressPerFileIndetermined = true; - - // Update status - UpdateStatus(); - - // Initialize new proxy-aware HttpClient - using HttpClient client = new HttpClientBuilder() - .UseLauncherConfig(DownloadThreadWithReservedCount) - .SetUserAgent(UserAgent) - .SetAllowedDecompression(DecompressionMethods.None) - .Create(); - - // Use the new DownloadClient instance - DownloadClient downloadClient = DownloadClient.CreateInstance(client); - - // Iterate repair asset and check it using different method for each type - ObservableCollection assetProperty = [.. AssetEntry]; - ConcurrentDictionary<(FilePropertiesRemote, IAssetProperty), byte> runningTask = new(); - if (IsBurstDownloadEnabled) - { - await Parallel.ForEachAsync( - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(repairAssetIndex) -#else - repairAssetIndex -#endif - , assetProperty), - new ParallelOptions { CancellationToken = token, MaxDegreeOfParallelism = DownloadThreadCount }, - async (asset, innerToken) => - { - if (!runningTask.TryAdd(asset, 0)) - { - LogWriteLine($"Found duplicated task for {asset.AssetProperty.Name}! Skipping...", LogType.Warning, true); - return; - } - // Assign a task depends on the asset type - Task assetTask = RepairAssetTypeGeneric(asset, downloadClient, _httpClient_RepairAssetProgress, innerToken); - - // Await the task - await assetTask; - runningTask.Remove(asset, out _); - }); - } - else - { - foreach ((FilePropertiesRemote AssetIndex, IAssetProperty AssetProperty) asset in - PairEnumeratePropertyAndAssetIndexPackage( -#if ENABLEHTTPREPAIR - EnforceHttpSchemeToAssetIndex(repairAssetIndex) -#else - repairAssetIndex -#endif - , assetProperty)) - { - if (!runningTask.TryAdd(asset, 0)) - { - LogWriteLine($"Found duplicated task for {asset.AssetProperty.Name}! Skipping...", LogType.Warning, true); - break; - } - // Assign a task depends on the asset type - Task assetTask = RepairAssetTypeGeneric(asset, downloadClient, _httpClient_RepairAssetProgress, token); - - // Await the task - await assetTask; - runningTask.Remove(asset, out _); - } - } - - return true; - } - - #region GenericRepair - private async Task RepairAssetTypeGeneric((FilePropertiesRemote AssetIndex, IAssetProperty AssetProperty) asset, DownloadClient downloadClient, DownloadProgressDelegate downloadProgress, CancellationToken token) - { - // Increment total count current - ProgressAllCountCurrent++; - // Set repair activity status - string timeLeftString = string.Format(Lang!._Misc!.TimeRemainHMSFormat!, Progress.ProgressAllTimeLeft); - UpdateRepairStatus( - string.Format(Lang._GameRepairPage.Status8, Path.GetFileName(asset.AssetIndex.N)), - string.Format(Lang._GameRepairPage.PerProgressSubtitle2, ConverterTool.SummarizeSizeSimple(ProgressAllSizeCurrent), ConverterTool.SummarizeSizeSimple(ProgressAllSizeTotal)) + $" | {timeLeftString}", - true); - - FileInfo fileInfo = new FileInfo(asset.AssetIndex.N!).StripAlternateDataStream().EnsureNoReadOnly(); - - // If asset type is unused, then delete it - if (asset.AssetIndex.FT == FileType.Unused) - { - if (fileInfo.Exists) - { - fileInfo.Delete(); - LogWriteLine($"File [T: {asset.AssetIndex.FT}] {(asset.AssetIndex.FT == FileType.Block ? asset.AssetIndex.CRC : asset.AssetIndex.N)} deleted!", LogType.Default, true); - } - RemoveHashMarkFile(asset.AssetIndex.N, out _, out _); - } - else - { - try - { - // Start asset download task - await RunDownloadTask(asset.AssetIndex.S, fileInfo, asset.AssetIndex.RN, downloadClient, downloadProgress, token); - LogWriteLine($"File [T: {asset.AssetIndex.FT}] {(asset.AssetIndex.FT == FileType.Block ? asset.AssetIndex.CRC : asset.AssetIndex.N)} has been downloaded!", LogType.Default, true); - } - catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) - { - LogWriteLine($"URL for asset {asset.AssetIndex.N} returned 404 Not Found. This may indicate that the asset is no longer available on the server.\r\n" + - $"\t URL: {asset.AssetIndex.GetRemoteURL()}", LogType.Warning, true); - } - } - - // Pop repair asset display entry - PopRepairAssetEntry(asset.AssetProperty); - } - #endregion - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs b/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs deleted file mode 100644 index b7f993867..000000000 --- a/CollapseLauncher/Classes/RepairManagement/StarRail/StarRailRepair.cs +++ /dev/null @@ -1,167 +0,0 @@ -using CollapseLauncher.GameVersioning; -using CollapseLauncher.InstallManager.StarRail; -using CollapseLauncher.Interfaces; -using Hi3Helper.Data; -using Hi3Helper.Shared.ClassStruct; -using Microsoft.UI.Xaml; -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using static Hi3Helper.Locale; -// ReSharper disable StringLiteralTypo - -namespace CollapseLauncher -{ - internal partial class StarRailRepair : ProgressBase, IRepair, IRepairAssetIndex - { - #region Properties - - public override string GamePath - { - get => GameVersionManager.GameDirPath; - set => GameVersionManager.GameDirPath = value; - } - - private GameTypeStarRailVersion InnerGameVersionManager { get; } - private StarRailInstall InnerGameInstaller { get; } - private bool IsOnlyRecoverMain { get; } - private List OriginAssetIndex { get; set; } - private string ExecName { get; } - private string GameDataPersistentPath { get => Path.Combine(GamePath, $"{ExecName}_Data", "Persistent"); } - private string GameAudioLangListPath - { - get - { - // If the persistent folder is not exist, then return null - if (!Directory.Exists(GameDataPersistentPath)) return null; - - // Set the file list path - string audioRecordPath = Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); - - // Check if the file exist. If not, return null - return !File.Exists(audioRecordPath) ? null : - // If it exists, then return the path - audioRecordPath; - } - } - private string GameAudioLangListPathStatic { get => Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); } - - internal const string AssetGameAudioStreamingPath = @"{0}_Data\StreamingAssets\Audio\AudioPackage\Windows"; - internal const string AssetGameAudioPersistentPath = @"{0}_Data\Persistent\Audio\AudioPackage\Windows"; - - internal const string AssetGameBlocksStreamingPath = @"{0}_Data\StreamingAssets\Asb\Windows"; - internal const string AssetGameBlocksPersistentPath = @"{0}_Data\Persistent\Asb\Windows"; - - internal const string AssetGameVideoStreamingPath = @"{0}_Data\StreamingAssets\Video\Windows"; - internal const string AssetGameVideoPersistentPath = @"{0}_Data\Persistent\Video\Windows"; - protected override string UserAgent => "UnityPlayer/2019.4.34f1 (UnityWebRequest/1.0, libcurl/7.75.0-DEV)"; - #endregion - - public StarRailRepair( - UIElement parentUI, - IGameVersion gameVersionManager, - IGameInstallManager gameInstallManager, - IGameSettings gameSettings, - bool onlyRecoverMainAsset = false, - string versionOverride = null) - : base(parentUI, - gameVersionManager, - gameSettings, - "", - versionOverride) - { - // Get flag to only recover main assets - IsOnlyRecoverMain = onlyRecoverMainAsset; - InnerGameVersionManager = gameVersionManager as GameTypeStarRailVersion; - InnerGameInstaller = gameInstallManager as StarRailInstall; - ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager!.GamePreset.GameExecutableName); - } - - ~StarRailRepair() => Dispose(); - - public List GetAssetIndex() => OriginAssetIndex; - - public async Task StartCheckRoutine(bool useFastCheck) - { - UseFastMethod = useFastCheck; - return await TryRunExamineThrow(CheckRoutine()); - } - - public async Task StartRepairRoutine(bool showInteractivePrompt = false, Action actionIfInteractiveCancel = null) - { - if (AssetIndex.Count == 0) throw new InvalidOperationException("There's no broken file being reported! You can't do the repair process!"); - - if (showInteractivePrompt) - { - await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); - } - - _ = await TryRunExamineThrow(RepairRoutine()); - } - - private async Task CheckRoutine() - { - // Always clear the asset index list - AssetIndex.Clear(); - - // Reset status and progress - ResetStatusAndProgress(); - - // Step 1: Fetch asset indexes - await Fetch(AssetIndex, Token!.Token); - - // Step 2: Remove blacklisted files from asset index (borrow function from StarRailInstall) - await InnerGameInstaller.FilterAssetList(AssetIndex, x => x.N, Token.Token); - - // Step 3: Calculate the total size and count of the files - CountAssetIndex(AssetIndex); - - // Step 4: Check for the asset indexes integrity - await Check(AssetIndex, Token.Token); - - // Step 5: Summarize and returns true if the assetIndex count != 0 indicates broken file was found. - // either way, returns false. - return SummarizeStatusAndProgress( - AssetIndex, - string.Format(Lang._GameRepairPage.Status3, ProgressAllCountFound, ConverterTool.SummarizeSizeSimple(ProgressAllSizeFound)), - Lang._GameRepairPage.Status4); - } - - private async Task RepairRoutine() - { - // Assign repair task - Task repairTask = Repair(AssetIndex, Token!.Token); - - // Run repair process - bool repairTaskSuccess = await TryRunExamineThrow(repairTask); - - // Reset status and progress - ResetStatusAndProgress(); - - // Set as completed - Status.IsCompleted = true; - Status.IsCanceled = false; - Status.ActivityStatus = Lang._GameRepairPage.Status7; - - // Update status and progress - UpdateAll(); - - return repairTaskSuccess; - } - - public void CancelRoutine() - { - // Trigger token cancellation - Token?.Cancel(); - Token?.Dispose(); - Token = null; - } - - public void Dispose() - { - CancelRoutine(); - GC.SuppressFinalize(this); - } - } -} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs new file mode 100644 index 000000000..12183cb7e --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.FinalizeFetch.cs @@ -0,0 +1,241 @@ +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper; +using Hi3Helper.EncTool; +using Hi3Helper.Plugin.Core.Utility; +using Hi3Helper.Shared.ClassStruct; +using Hi3Helper.Sophon; +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0130 +#nullable enable +namespace CollapseLauncher; + +internal partial class StarRailPersistentRefResult +{ + public async Task FinalizeCacheFetchAsync(StarRailRepairV2 instance, + HttpClient client, + List assetIndex, + string gameDir, + string aLuaDir, + CancellationToken token) + { + if (!IsCacheMode) + { + throw new + InvalidOperationException("You cannot call this method for finalization as you're using Game Repair mode. Please use FinalizeRepairFetchAsync instead!"); + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "ChangeLuaPathInfo.bytes"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + // -- Get stock LuaV manifest file from sophon + FilePropertiesRemote? luaStockManifestFile = + assetIndex.FirstOrDefault(x => x.N.Contains(@"StreamingAssets\Lua\Windows\LuaV_", + StringComparison.OrdinalIgnoreCase) && + x.N.EndsWith(".bytes", StringComparison.OrdinalIgnoreCase)); + + if (luaStockManifestFile is not { AssociatedObject: SophonAsset sophonAsset }) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] We cannot finalize fetching process as necessary file is not available. The game might behave incorrectly!", + LogType.Warning, + true); + return; + } + + // -- Load temporarily from sophon + await using MemoryStream tempStream = new(); + await sophonAsset.WriteToStreamAsync(client, tempStream, token: token); + tempStream.Position = 0; + + // -- Parse manifest and get the first asset from stock metadata + StarRailAssetSignaturelessMetadata metadataLuaV = new(".bytes"); + await metadataLuaV.ParseAsync(tempStream, true, token); + + // -- Get stock dictionary asset + StarRailAssetSignaturelessMetadata.Metadata? stockLuaDictPath = metadataLuaV.DataList.FirstOrDefault(); + FilePropertiesRemote? stockLuaDictAsset = + assetIndex.FirstOrDefault(x => x.N.EndsWith(stockLuaDictPath?.Filename ?? "", + StringComparison.OrdinalIgnoreCase)); + if (stockLuaDictAsset is not { AssociatedObject: SophonAsset stockLuaDictSophon }) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] Stock Lua Dictionary file is not found! Skipping", + LogType.Warning, + true); + return; + } + + using MemoryStream stockLuaDictStream = new(); + await stockLuaDictSophon.WriteToStreamAsync(client, stockLuaDictStream, token: token); + stockLuaDictStream.Position = 0; + + // -- Get game server's dictionary asset + StarRailAssetSignaturelessMetadata.Metadata? gameServStockLuaPath = Metadata.CacheLua?.DataList.FirstOrDefault(); + string gameServLuaDictUrl = BaseUrls.CacheLua.CombineUrlFromString(gameServStockLuaPath?.Filename); + if (string.IsNullOrEmpty(gameServLuaDictUrl)) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] Game Server's Lua Dictionary file is not found! Skipping", + LogType.Warning, + true); + return; + } + + CDNCacheResult gameServLuaDictRemote = await client.TryGetCachedStreamFrom(gameServLuaDictUrl, token: token); + if (!gameServLuaDictRemote.IsSuccessStatusCode) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeCacheFetchAsync] Game Server's Lua Dictionary file returns unsuccessful code! Skipping", + LogType.Warning, + true); + return; + } + await using Stream gameServLuaDictRemoteStream = gameServLuaDictRemote.Stream; + await using MemoryStream gameServLuaDictStream = new(); + await gameServLuaDictRemoteStream.CopyToAsync(gameServLuaDictStream, token); + gameServLuaDictStream.Position = 0; + + // -- Load Lua Dictionary Stream + Dictionary stockLuaDic = await LoadStarRailLuaPathDictAsync(stockLuaDictStream, token); + Dictionary gameServLuaDic = + await LoadStarRailLuaPathDictAsync(gameServLuaDictStream, token); + + // -- Generate ChangeLuaPathInfo.bytes to persistent folder + List newLuaDic = CompareLuaDict(stockLuaDic, gameServLuaDic); + if (newLuaDic.Count == 0) + { + return; + } + + FileInfo luaPathInfo = new FileInfo(Path.Combine(gameDir, aLuaDir, "ChangeLuaPathInfo.bytes")) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly() + .StripAlternateDataStream(); + await using StreamWriter luaPathInfoWriter = luaPathInfo.CreateText(); + luaPathInfoWriter.NewLine = "\n"; + foreach (string line in newLuaDic.Select(newLuaEntry => newLuaEntry.Serialize(StarRailLuaPathJsonContext.Default.StarRailLuaPath, false))) + { + await luaPathInfoWriter.WriteLineAsync(line); + } + } + + public async Task FinalizeRepairFetchAsync(StarRailRepairV2 instance, + HttpClient client, + List assetIndex, + string persistentDir, + CancellationToken token) + { + if (IsCacheMode) + { + throw new + InvalidOperationException("You cannot call this method for finalization as you're using Cache Update mode. Please use FinalizeCacheFetchAsync instead!"); + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, "BinaryVersion.bytes"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + FilePropertiesRemote? binaryVersionFile = + assetIndex.FirstOrDefault(x => x.N.EndsWith("StreamingAssets\\BinaryVersion.bytes", + StringComparison.OrdinalIgnoreCase)); + + if (binaryVersionFile is not { AssociatedObject: SophonAsset asSophonAsset }) + { + Logger.LogWriteLine("[StarRailPersistentRefResult::FinalizeRepairFetchAsync] We cannot finalize fetching process as necessary file is not available. The game might behave incorrectly!", + LogType.Warning, + true); + return; + } + + await using MemoryStream tempStream = new(); + await asSophonAsset.WriteToStreamAsync(client, tempStream, token: token); + tempStream.Position = 0; + + byte[] buffer = tempStream.ToArray(); + Span bufferSpan = buffer.AsSpan()[..^3]; + + string binAppIdentityPath = Path.Combine(persistentDir, "AppIdentity.txt"); + string binDownloadedFullAssetsPath = Path.Combine(persistentDir, "DownloadedFullAssets.txt"); + string binInstallVersionPath = Path.Combine(persistentDir, "InstallVersion.bin"); + + Span hashSpan = bufferSpan[^36..^4]; + string hashStr = Encoding.UTF8.GetString(hashSpan); + + GetVersionNumber(bufferSpan, out uint majorVersion, out uint minorVersion, out uint stockPatchVersion); + + await File.WriteAllTextAsync(binAppIdentityPath, hashStr, token); + await File.WriteAllTextAsync(binDownloadedFullAssetsPath, hashStr, token); + await File.WriteAllTextAsync(binInstallVersionPath, $"{hashStr},{majorVersion}.{minorVersion}.{stockPatchVersion}", token); + + return; + + static void GetVersionNumber(ReadOnlySpan span, out uint major, out uint minor, out uint patch) + { + ushort strLen = BinaryPrimitives.ReadUInt16BigEndian(span); + span = span[(2 + strLen)..]; // Skip + patch = BinaryPrimitives.ReadUInt32BigEndian(span); + major = BinaryPrimitives.ReadUInt32BigEndian(span[4..]); + minor = BinaryPrimitives.ReadUInt32BigEndian(span[8..]); + } + } + + private static List CompareLuaDict( + Dictionary stock, + Dictionary gameServ) + { + List newPaths = []; + newPaths.AddRange(from kvp in gameServ where !stock.ContainsKey(kvp.Key) select kvp.Value); + + return newPaths; + } + + private static async Task> + LoadStarRailLuaPathDictAsync( + Stream stream, + CancellationToken token) + { + Dictionary dic = new(StringComparer.OrdinalIgnoreCase); + using StreamReader reader = new(stream, leaveOpen: true); + while (await reader.ReadLineAsync(token) is { } line) + { + // Break if we are already at the end of the JSON part + if (!string.IsNullOrEmpty(line) && + line[0] != '{') + { + break; + } + + if (line.Deserialize(StarRailLuaPathJsonContext.Default.StarRailLuaPath) is { } entry) + { + dic.TryAdd(entry.Md5 + entry.Path, entry); + } + } + + return dic; + } + + [JsonSerializable(typeof(StarRailLuaPath))] + [JsonSourceGenerationOptions(NewLine = "\n")] + public partial class StarRailLuaPathJsonContext : JsonSerializerContext; + + public class StarRailLuaPath + { + [JsonPropertyOrder(0)] + public required string Path { get; set; } + + [JsonPropertyOrder(1)] + public required string Md5 { get; set; } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs new file mode 100644 index 000000000..7697661b3 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.GetPersistentFiles.cs @@ -0,0 +1,308 @@ +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper.Data; +using Hi3Helper.Shared.ClassStruct; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher; + +file static class StarRailPersistentExtension +{ + public static IEnumerable WhereNotStartWith(this IEnumerable enumerable, + params ReadOnlySpan excludeStartWith) + where T : StarRailAssetGenericFileInfo + { + SearchValues excludeStartWithS = SearchValues.Create(excludeStartWith, StringComparison.OrdinalIgnoreCase); + return enumerable.Where(Impl); + + bool Impl(T asset) + { + ReadOnlySpan filePath = asset.Filename; + return filePath.IndexOfAny(excludeStartWithS) < 0; + } + } + + public static string GetPersistentLangPrefixToLauncherAudioLang(this string str) + => str switch + { + "English" => "English(US)", + "Chinese(PRC)" => "Chinese", + _ => str + }; +} + +internal partial class StarRailPersistentRefResult +{ + public List GetPersistentFiles( + List fileList, + string gameDirPath, + string[] installedVoiceLang) + { + Dictionary oldDic = fileList.ToDictionary(x => x.N); + Dictionary unusedAssets = new(StringComparer.OrdinalIgnoreCase); + + string[] audioLangPrefix = ["Chinese(PRC)", "Japanese", "Korean", "English"]; + string[] excludedAudioLangPrefix = audioLangPrefix + .Where(x => !installedVoiceLang.Contains(x.GetPersistentLangPrefixToLauncherAudioLang(), StringComparer.OrdinalIgnoreCase)) + .ToArray(); + + + foreach (FilePropertiesRemote asset in fileList) + { + oldDic.TryAdd(asset.N, asset); + } + + if (Metadata.StartBlockV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAsbBlock, + BaseDirs.PersistentAsbBlock, + BaseUrls.AsbBlock, + BaseUrls.AsbBlockPersistent, + false, + fileList, + unusedAssets, + oldDic, + Metadata.StartBlockV.DataList); + } + + if (Metadata.BlockV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAsbBlock, + BaseDirs.PersistentAsbBlock, + BaseUrls.AsbBlock, + BaseUrls.AsbBlockPersistent, + false, + fileList, + unusedAssets, + oldDic, + Metadata.BlockV.DataList); + } + + if (Metadata.VideoV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingVideo, + BaseDirs.PersistentVideo, + BaseUrls.Video, + BaseUrls.Video, + true, + fileList, + unusedAssets, + oldDic, + Metadata.VideoV.DataList); + } + + if (Metadata.AudioV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingAudio, + BaseDirs.PersistentAudio, + BaseUrls.Audio, + BaseUrls.Audio, + true, + fileList, + unusedAssets, + oldDic, + Metadata.AudioV!.DataList + .WhereNotStartWith(excludedAudioLangPrefix)); + + AddUnusedAudioAssets(gameDirPath, + BaseDirs.StreamingAudio, + BaseDirs.PersistentAudio, + Metadata.AudioV!.DataList, + fileList, + excludedAudioLangPrefix); + } + + if (Metadata.RawResV != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.StreamingRawRes, + BaseDirs.PersistentRawRes, + BaseUrls.RawRes, + BaseUrls.RawRes, + false, + fileList, + unusedAssets, + oldDic, + Metadata.RawResV.DataList); + } + + if (Metadata.CacheLua != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.CacheLua!.Replace("Persistent", "StreamingAssets"), + BaseDirs.CacheLua ?? "", + BaseUrls.CacheLua ?? "", + BaseUrls.CacheLua ?? "", + false, + fileList, + unusedAssets, + oldDic, + Metadata.CacheLua.DataList); + } + + if (Metadata.CacheIFix != null) + { + AddAdditionalAssets(gameDirPath, + BaseDirs.CacheIFix ?? "", + BaseDirs.CacheIFix ?? "", + BaseUrls.CacheIFix ?? "", + BaseUrls.CacheIFix ?? "", + false, + fileList, + unusedAssets, + oldDic, + Metadata.CacheIFix.DataList); + } + + return unusedAssets.Values.ToList(); + } + + private static void AddUnusedAudioAssets( + string gameDir, + string streamingDir, + string persistentDir, + IEnumerable listEnumerator, + List fileList, + params ReadOnlySpan excludedAudioLang) + where T : StarRailAssetFlaggable + { + if (excludedAudioLang.Length == 4) // Assume the user doesn't have any language installed, so ignore it. + { + return; + } + + string baseStreamingDir = Path.Combine(gameDir, streamingDir); + string basePersistentDir = Path.Combine(gameDir, persistentDir); + + SearchValues searchIndexes = SearchValues.Create(excludedAudioLang, StringComparison.OrdinalIgnoreCase); + foreach (T entry in listEnumerator) + { + ReadOnlySpan filename = entry.Filename; + int indexOf = filename.IndexOfAny(searchIndexes); + if (indexOf != 0) + { + continue; + } + + string filenameStr = entry.Filename ?? ""; + + string atStreaming = Path.Combine(baseStreamingDir, filenameStr).NormalizePath(); + string atPersistent = Path.Combine(basePersistentDir, filenameStr).NormalizePath(); + + string relStreaming = Path.Combine(streamingDir, filenameStr).NormalizePath(); + string relPersistent = Path.Combine(persistentDir, filenameStr).NormalizePath(); + + if (File.Exists(atStreaming)) + { + FilePropertiesRemote entryToRemove = new() + { + FT = FileType.Unused, + N = relStreaming + }; + fileList.Add(entryToRemove); + } + + // ReSharper disable once InvertIf + if (File.Exists(atPersistent)) + { + FilePropertiesRemote entryToRemove = new() + { + FT = FileType.Unused, + N = relPersistent + }; + fileList.Add(entryToRemove); + } + } + } + + private static void AddAdditionalAssets( + string gameDirPath, + string assetDirPathStreaming, + string assetDirPathPersistent, + string urlBase, + string urlBasePersistent, + bool isHashMarked, + List fileList, + Dictionary unusedFileList, + Dictionary fileDic, + IEnumerable flaggableAssets) + where T : StarRailAssetFlaggable + { + foreach (T asset in flaggableAssets) + { + string filename = asset.Filename?.NormalizePath() ?? ""; + + // Gets relative and absolute paths. + string relPathInStreaming = Path.Combine(assetDirPathStreaming, filename); + string relPathInPersistent = Path.Combine(assetDirPathPersistent, filename); + string pathInStreaming = Path.Combine(gameDirPath, relPathInStreaming); + string pathInPersistent = Path.Combine(gameDirPath, relPathInPersistent); + + // If file is not persistent while exists on both persistent and streaming, then + // remove the persistent one. + if (!asset.IsPersistent && + File.Exists(pathInPersistent) && + File.Exists(pathInStreaming) && + pathInStreaming != pathInPersistent) + { + unusedFileList.TryAdd(relPathInPersistent, new FilePropertiesRemote + { + FT = FileType.Unused, + N = relPathInPersistent + }); + } + + // Try to check entry existence + ref FilePropertiesRemote assetFromDic = ref CollectionsMarshal + .GetValueRefOrNullRef(fileDic, + relPathInPersistent); + + if (Unsafe.IsNullRef(ref assetFromDic)) + { + assetFromDic = ref CollectionsMarshal + .GetValueRefOrNullRef(fileDic, + relPathInStreaming); + } + + // Skip if entry already exist and file is not persistent. + if (!Unsafe.IsNullRef(ref assetFromDic) && + !asset.IsPersistent) + { + continue; + } + + // Now, the game will see any files which don't exist on Sophon as persistent files, + // even though they are not marked as persistent in metadata. + string url = (asset.IsPersistent + ? urlBasePersistent + : urlBase).CombineURLFromString(asset.Filename); + + FilePropertiesRemote file = new() + { + RN = url, + N = relPathInPersistent, + S = asset.FileSize, + CRCArray = asset.MD5Checksum, + FT = StarRailRepairV2.DetermineFileTypeFromExtension(asset.Filename ?? ""), + IsHasHashMark = isHashMarked + }; + fileDic.TryAdd(relPathInPersistent, file); + fileList.Add(file); + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs new file mode 100644 index 000000000..8d4188199 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailPersistentRefResult.cs @@ -0,0 +1,784 @@ +using CollapseLauncher.Helper.JsonConverter; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement.StarRail.Struct; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Hashes; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.EncTool.Proto.StarRail; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo +// ReSharper disable UnusedMember.Global + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher; + +internal partial class StarRailPersistentRefResult +{ + public required AssetBaseUrls BaseUrls { get; set; } + public required AssetBaseDirs BaseDirs { get; set; } + public required AssetMetadata Metadata { get; set; } + public bool IsCacheMode { get; set; } + + public static async Task GetCacheReferenceAsync( + StarRailRepairV2 instance, + SRDispatcherInfo dispatcherInfo, + HttpClient client, + string gameBaseDir, + string persistentDir, + CancellationToken token) + { + StarRailGatewayStatic gateway = dispatcherInfo.RegionGateway; + Dictionary gatewayKvp = gateway.ValuePairs; + + // -- Assign main URLs + string mainUrlLua = gatewayKvp["LuaBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlIFix = gatewayKvp["IFixPatchVersionUpdateUrl"].CombineURLFromString("client/Windows"); + AssetBaseUrls baseUrls = new() + { + GatewayKvp = gatewayKvp, + Archive = "", + AsbBlock = "", + AsbBlockPersistent = "", + Audio = "", + DesignData = "", + NativeData = "", + Video = "", + RawRes = "", + CacheLua = mainUrlLua, + CacheIFix = mainUrlIFix + }; + + string refLuaUrl = mainUrlLua.CombineURLFromString("M_LuaV.bytes"); + string refIFixUrl = mainUrlIFix.CombineURLFromString("M_IFixV.bytes"); + + // -- Initialize persistent dirs + string lDirLua = Path.Combine(persistentDir, @"Lua\Windows"); + string lDirIFix = Path.Combine(persistentDir, @"IFix\Windows"); + string aDirLua = Path.Combine(gameBaseDir, lDirLua); + string aDirIFix = Path.Combine(gameBaseDir, lDirIFix); + AssetBaseDirs baseDirs = new() + { + CacheLua = lDirLua, + CacheIFix = lDirIFix + }; + + // -- Fetch and parse the index references + StarRailAssetMetadataIndex metadataLua = new(useHeaderSizeOfForAssert: true); + Dictionary handleLua = await StarRailRefMainInfo + .ParseMetadataFromUrlAsync(instance, + client, + refLuaUrl, + metadataLua, + x => x.DataList[0].MD5Checksum, + x => x.DataList[0].MetadataIndexFileSize, + x => x.DataList[0].Timestamp, + x => new Version(x.DataList[0].MajorVersion, x.DataList[0].MinorVersion, x.DataList[0].PatchVersion), + aDirLua, + token); + + StarRailAssetMetadataIndex metadataIFix = new(use6BytesPadding: true, useHeaderSizeOfForAssert: true); + Dictionary handleIFix = await StarRailRefMainInfo + .ParseMetadataFromUrlAsync(instance, + client, + refIFixUrl, + metadataIFix, + x => x.DataList[0].MD5Checksum, + x => x.DataList[0].MetadataIndexFileSize, + x => x.DataList[0].Timestamp, + x => new Version(x.DataList[0].MajorVersion, x.DataList[0].MinorVersion, x.DataList[0].PatchVersion), + aDirIFix, + token); + + // -- Save local index files + // Notes to Dev: HoYo no longer provides a proper raw bytes data anymore and the client creates it based + // on data provided by "handleArchive", so we need to emulate how the game generates these data. + await SaveLocalIndexFiles(instance, handleLua, aDirLua, "LuaV", token); + await SaveLocalIndexFiles(instance, handleIFix, aDirIFix, "IFixV", token); + + // -- Load metadata files + // -- LuaV + StarRailAssetSignaturelessMetadata? metadataLuaV = new(".bytes"); + metadataLuaV = await LoadMetadataFile(instance, + handleLua, + client, + baseUrls.CacheLua, + "LuaV", + metadataLuaV, + aDirLua, + token); + + // -- IFixV + StarRailAssetCsvMetadata? metadataIFixV = + await LoadMetadataFile(instance, + handleIFix, + client, + baseUrls.CacheIFix, + "IFixV", + aDirIFix, + token); + + // -- Generate ChangeLuaPathInfo.bytes + + return new StarRailPersistentRefResult + { + BaseDirs = baseDirs, + BaseUrls = baseUrls, + Metadata = new AssetMetadata + { + CacheLua = metadataLuaV, + CacheIFix = metadataIFixV + }, + IsCacheMode = true + }; + } + + public static async Task GetRepairReferenceAsync( + StarRailRepairV2 instance, + SRDispatcherInfo dispatcherInfo, + HttpClient client, + string gameBaseDir, + string persistentDir, + CancellationToken token) + { + StarRailGatewayStatic gateway = dispatcherInfo.RegionGateway; + Dictionary gatewayKvp = gateway.ValuePairs; + + string mainUrlAsb = gatewayKvp["AssetBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlAsbAlt = gatewayKvp["AssetBundleVersionUpdateUrlAlt"].CombineURLFromString("client/Windows"); + string mainUrlDesignData = gatewayKvp["DesignDataBundleVersionUpdateUrl"].CombineURLFromString("client/Windows"); + string mainUrlArchive = mainUrlAsb.CombineURLFromString("Archive"); + + string refDesignArchiveUrl = mainUrlDesignData.CombineURLFromString("M_Design_ArchiveV.bytes"); + string refArchiveUrl = mainUrlArchive.CombineURLFromString("M_ArchiveV.bytes"); + + // -- Test ArchiveV endpoint + // Notes to Dev: This is intentional. We need to find which endpoint is actually represents the ArchiveV file URL. + bool isSecondArchiveVEndpointRetry = false; + TestArchiveVEndpoint: + if (!await IsEndpointAlive(client, refArchiveUrl, token)) + { + if (isSecondArchiveVEndpointRetry) + { + throw new HttpRequestException("Seems like the URL for ArchiveV is missing. Please report this issue to our devs!"); + } + + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Given ArchiveV Url is invalid! (previously: {refArchiveUrl}). Try swapping...", + LogType.Warning, + true); + + // Also swap the Asset bundle URL so we know that the URL assigned inside the gateway is flipped. + (mainUrlAsb, mainUrlAsbAlt) = (mainUrlAsbAlt, mainUrlAsb); + + isSecondArchiveVEndpointRetry = true; + mainUrlArchive = mainUrlAsb.CombineURLFromString("Archive"); + refArchiveUrl = mainUrlArchive.CombineURLFromString("M_ArchiveV.bytes"); + goto TestArchiveVEndpoint; + } + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] ArchiveV Url is found! at: {refArchiveUrl}", + LogType.Debug, + true); + + // -- Assign other URLs after checks + // Notes to Dev: + // We are now only assigning URL after above check because the game sometimes being a dick for swapping these + // distinct Asset bundle URLs. We don't want to assign these other URLs below unless the Asb URL is already correct. + // We also made the second check for the actual block URLs below so HoYo wouldn't be able to fuck around with our code + // anymore. + string mainUrlAudio = mainUrlAsb.CombineURLFromString("AudioBlock"); + string mainUrlAsbBlock = mainUrlAsb.CombineURLFromString("Block"); + string mainUrlAsbBlockAlt = mainUrlAsbAlt.CombineURLFromString("Block"); + string mainUrlNativeData = mainUrlDesignData.CombineURLFromString("NativeData"); + string mainUrlVideo = mainUrlAsb.CombineURLFromString("Video"); + string mainUrlRawRes = mainUrlAsb.CombineURLFromString("RawRes"); + + AssetBaseUrls baseUrl = new() + { + GatewayKvp = gatewayKvp, + DesignData = mainUrlDesignData, + Archive = mainUrlArchive, + Audio = mainUrlAudio, + AsbBlock = mainUrlAsbBlock, + AsbBlockPersistent = mainUrlAsbBlockAlt, + NativeData = mainUrlNativeData, + Video = mainUrlVideo, + RawRes = mainUrlRawRes + }; + + // -- Initialize persistent dirs + string lDirArchive = Path.Combine(persistentDir, @"Archive\Windows"); + string lDirAsbBlock = Path.Combine(persistentDir, @"Asb\Windows"); + string lDirAudio = Path.Combine(persistentDir, @"Audio\AudioPackage\Windows"); + string lDirDesignData = Path.Combine(persistentDir, @"DesignData\Windows"); + string lDirNativeData = Path.Combine(persistentDir, @"NativeData\Windows"); + string lDirVideo = Path.Combine(persistentDir, @"Video\Windows"); + string lDirRawRes = Path.Combine(persistentDir, @"RawRes\Windows"); + string aDirArchive = Path.Combine(gameBaseDir, lDirArchive); + string aDirAsbBlock = Path.Combine(gameBaseDir, lDirAsbBlock); + string aDirAudio = Path.Combine(gameBaseDir, lDirAudio); + string aDirDesignData = Path.Combine(gameBaseDir, lDirDesignData); + string aDirNativeData = Path.Combine(gameBaseDir, lDirNativeData); + string aDirVideo = Path.Combine(gameBaseDir, lDirVideo); + string aDirRawRes = Path.Combine(gameBaseDir, lDirRawRes); + AssetBaseDirs baseDirs = new(lDirArchive, lDirAsbBlock, lDirAudio, lDirDesignData, lDirNativeData, lDirVideo, lDirRawRes); + + // -- Fetch and parse the index references + Dictionary handleDesignArchive = await StarRailRefMainInfo + .ParseListFromUrlAsync(instance, + client, + refDesignArchiveUrl, + null, + token); + + Dictionary handleArchive = await StarRailRefMainInfo + .ParseListFromUrlAsync(instance, + client, + refArchiveUrl, + aDirArchive, + token); + + // -- Test Asset bundle endpoint + // Notes to Dev: This is intentional. We need to find which endpoint is actually represents the persistent file URL. + bool isSecondAsbEndpointRetry = false; + TestAsbPersistentEndpoint: + if (!await IsEndpointAlive(handleArchive, client, baseUrl.AsbBlockPersistent, "BlockV", token)) + { + if (isSecondAsbEndpointRetry) + { + throw new HttpRequestException("Seems like the URL for persistent asset bundle is missing. Please report this issue to our devs!"); + } + + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Given persistent asset bundle URL is invalid! (previously: {baseUrl.AsbBlockPersistent}). Try swapping...", + LogType.Warning, + true); + isSecondAsbEndpointRetry = true; + baseUrl.SwapAsbPersistentUrl(); + goto TestAsbPersistentEndpoint; + } + Logger.LogWriteLine($"[StarRailPersistentRefResult::GetRepairReferenceAsync] Persistent asset bundle URL is found! at: {baseUrl.AsbBlockPersistent}", + LogType.Debug, + true); + + // -- Save local index files + // Notes to Dev: HoYo no longer provides a proper raw bytes data anymore and the client creates it based + // on data provided by "handleArchive", so we need to emulate how the game generates these data. + await SaveLocalIndexFiles(instance, handleDesignArchive, aDirDesignData, "DesignV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "AsbV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "BlockV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "Start_AsbV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAsbBlock, "Start_BlockV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirAudio, "AudioV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirVideo, "VideoV", token); + await SaveLocalIndexFiles(instance, handleArchive, aDirRawRes, "RawResV", token); + + // -- Load metadata files + // -- DesignV + StarRailAssetSignaturelessMetadata? metadataDesignV = + await LoadMetadataFile(instance, + handleDesignArchive, + client, + baseUrl.DesignData, + "DesignV", + aDirDesignData, + token); + + // -- NativeDataV + StarRailAssetNativeDataMetadata? metadataNativeDataV = + await LoadMetadataFile(instance, + handleDesignArchive, + client, + baseUrl.NativeData, + "NativeDataV", + aDirNativeData, + token); + + // -- Start_AsbV + StarRailAssetBundleMetadata? metadataStartAsbV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "Start_AsbV", + aDirAsbBlock, + token); + + // -- Start_BlockV + StarRailAssetBlockMetadata? metadataStartBlockV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "Start_BlockV", + aDirAsbBlock, + token); + + // -- AsbV + StarRailAssetBundleMetadata? metadataAsbV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "AsbV", + null, + token); + + // -- BlockV + StarRailAssetBlockMetadata? metadataBlockV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.AsbBlockPersistent, + "BlockV", + null, + token); + + // -- AudioV + StarRailAssetJsonMetadata? metadataAudioV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.Audio, + "AudioV", + aDirAudio, + token); + + // -- VideoV + StarRailAssetJsonMetadata? metadataVideoV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.Video, + "VideoV", + aDirVideo, + token); + + // -- RawResV + StarRailAssetJsonMetadata? metadataRawResV = + await LoadMetadataFile(instance, + handleArchive, + client, + baseUrl.RawRes, + "RawResV", + aDirRawRes, + token); + + return new StarRailPersistentRefResult + { + BaseDirs = baseDirs, + BaseUrls = baseUrl, + Metadata = new AssetMetadata + { + DesignV = metadataDesignV, + NativeDataV = metadataNativeDataV, + StartAsbV = metadataStartAsbV, + StartBlockV = metadataStartBlockV, + AsbV = metadataAsbV, + BlockV = metadataBlockV, + AudioV = metadataAudioV, + VideoV = metadataVideoV, + RawResV = metadataRawResV + } + }; + } + + private static async ValueTask SaveLocalIndexFiles( + StarRailRepairV2 instance, + Dictionary handleArchiveSource, + string outputDir, + string indexKey, + CancellationToken token) + { + if (!handleArchiveSource.TryGetValue(indexKey, out StarRailRefMainInfo? index)) + { + Logger.LogWriteLine($"Game server doesn't serve index file: {indexKey}. Please contact our developer to get this fixed!", LogType.Warning, true); + return; + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Index: {index.FileName}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + StarRailAssetMetadataIndex indexMetadata = index; + string filePath = Path.Combine(outputDir, index.FileName + ".bytes"); + await indexMetadata.WriteAsync(filePath, token); + } + + private static ValueTask LoadMetadataFile( + StarRailRepairV2 instance, + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + string? saveToLocalDir = null, + CancellationToken token = default) + where T : StarRailBinaryData, new() + { + T parser = StarRailBinaryData.CreateDefault(); + return LoadMetadataFile(instance, + handleArchiveSource, + client, + baseUrl, + indexKey, + parser, + saveToLocalDir, + token); + } + + private static async ValueTask LoadMetadataFile( + StarRailRepairV2 instance, + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + T parser, + string? saveToLocalDir = null, + CancellationToken token = default) + where T : StarRailBinaryData + { + if (!handleArchiveSource.TryGetValue(indexKey, out StarRailRefMainInfo? index)) + { + Logger.LogWriteLine($"Game server doesn't serve index file: {indexKey}. Please contact our developer to get this fixed!", LogType.Warning, true); + return null; + } + + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Metadata: {index.FileName}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + if (!string.IsNullOrEmpty(saveToLocalDir) && + Directory.Exists(saveToLocalDir)) + { + DirectoryInfo dirInfo = new(saveToLocalDir); + foreach (FileInfo oldFilePath in dirInfo.EnumerateFiles($"{index.UnaliasedFileName}_*.bytes", SearchOption.TopDirectoryOnly)) + { + ReadOnlySpan fileNameOnly = oldFilePath.Name; + ReadOnlySpan fileHash = ConverterTool.GetSplit(fileNameOnly, ^2, "_."); + if (HexTool.IsHexString(fileHash) && + !fileHash.Equals(index.ContentHash, StringComparison.OrdinalIgnoreCase)) + { + oldFilePath + .EnsureNoReadOnly() + .StripAlternateDataStream() + .TryDeleteFile(); + } + } + } + + string filename = index.RemoteFileName; + + // Check if the stream has been downloaded + if (!string.IsNullOrEmpty(saveToLocalDir) && + Path.Combine(saveToLocalDir, filename) is { } localFilePath && + File.Exists(localFilePath)) + { + await using FileStream existingFileStream = File.OpenRead(localFilePath); + byte[] hash = await CryptoHashUtility + .ThreadSafe + .GetHashFromStreamAsync(existingFileStream, token: token); + byte[] hashRemote = HexTool.HexToBytesUnsafe(index.ContentHash); + + if (!hash.SequenceEqual(hashRemote)) + { + goto GetReadFromRemote; + } + + existingFileStream.Position = 0; + await parser.ParseAsync(existingFileStream, true, token); + + return parser; + } + + GetReadFromRemote: + string fileUrl = baseUrl.CombineURLFromString(filename); + await using Stream networkStream = (await client.TryGetCachedStreamFrom(fileUrl, token: token)).Stream; + await using Stream sourceStream = !string.IsNullOrEmpty(saveToLocalDir) + ? CreateLocalStream(networkStream, Path.Combine(saveToLocalDir, filename)) + : networkStream; + + await parser.ParseAsync(sourceStream, true, token); + + return parser; + + static Stream CreateLocalStream(Stream thisSourceStream, string filePath) + { + FileInfo fileInfo = new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly() + .StripAlternateDataStream(); + return new CopyToStream(thisSourceStream, fileInfo.Create(), null, true); + } + } + + private static async ValueTask IsEndpointAlive( + Dictionary handleArchiveSource, + HttpClient client, + string baseUrl, + string indexKey, + CancellationToken token) + { + if (!handleArchiveSource.TryGetValue(indexKey, out StarRailRefMainInfo? index)) + { + Logger.LogWriteLine($"Game server doesn't serve index file: {indexKey}. Please contact our developer to get this fixed!", LogType.Warning, true); + return false; + } + + string filename = index.RemoteFileName; + string url = baseUrl.CombineURLFromString(filename); + + return await IsEndpointAlive(client, url, token); + } + + private static async ValueTask IsEndpointAlive( + HttpClient client, + string url, + CancellationToken token) + { + UrlStatus status = await client.GetCachedUrlStatus(url, token); + if (!status.IsSuccessStatusCode) + { + Logger.LogWriteLine($"[StarRailPersistentRefResult::IsEndpointAlive] Url: {url} returns unsuccessful status code: {status.StatusCode} ({(int)status.StatusCode})", + LogType.Warning, + true); + } + + return status.IsSuccessStatusCode; + } + + public class AssetBaseDirs( + string nArchive, + string nAsbBlock, + string nAudio, + string nDesignData, + string nNativeData, + string nVideo, + string nRawRes) + { + public AssetBaseDirs() : this("", "", "", "", "", "", "") + { + + } + + public string PersistentArchive { get; set; } = nArchive; + public string PersistentAsbBlock { get; set; } = nAsbBlock; + public string PersistentAudio { get; set; } = nAudio; + public string PersistentDesignData { get; set; } = nDesignData; + public string PersistentNativeData { get; set; } = nNativeData; + public string PersistentVideo { get; set; } = nVideo; + public string PersistentRawRes { get; set; } = nRawRes; + public string StreamingArchive { get; set; } = GetStreamingAssetsDir(nArchive); + public string StreamingAsbBlock { get; set; } = GetStreamingAssetsDir(nAsbBlock); + public string StreamingAudio { get; set; } = GetStreamingAssetsDir(nAudio); + public string StreamingDesignData { get; set; } = GetStreamingAssetsDir(nDesignData); + public string StreamingNativeData { get; set; } = GetStreamingAssetsDir(nNativeData); + public string StreamingVideo { get; set; } = GetStreamingAssetsDir(nVideo); + public string StreamingRawRes { get; set; } = GetStreamingAssetsDir(nRawRes); + + public string? CacheIFix { get; set; } + public string? CacheLua { get; set; } + + private static string GetStreamingAssetsDir(string dir) => dir.Replace("Persistent", "StreamingAssets"); + } + + public class AssetBaseUrls + { + public required Dictionary GatewayKvp { get; set; } + public required string DesignData { get; set; } + public required string Archive { get; set; } + public required string Audio { get; set; } + public required string AsbBlock { get; set; } + public required string AsbBlockPersistent { get; set; } + public required string NativeData { get; set; } + public required string Video { get; set; } + public required string RawRes { get; set; } + + public string? CacheLua { get; set; } + public string? CacheIFix { get; set; } + + public void SwapAsbPersistentUrl() => (AsbBlock, AsbBlockPersistent) = (AsbBlockPersistent, AsbBlock); + } + + public class AssetMetadata + { + public StarRailAssetSignaturelessMetadata? DesignV { get; set; } + public StarRailAssetNativeDataMetadata? NativeDataV { get; set; } + public StarRailAssetBundleMetadata? StartAsbV { get; set; } + public StarRailAssetBlockMetadata? StartBlockV { get; set; } + public StarRailAssetBundleMetadata? AsbV { get; set; } + public StarRailAssetBlockMetadata? BlockV { get; set; } + public StarRailAssetJsonMetadata? AudioV { get; set; } + public StarRailAssetJsonMetadata? VideoV { get; set; } + public StarRailAssetJsonMetadata? RawResV { get; set; } + + public StarRailAssetSignaturelessMetadata? CacheLua { get; set; } + public StarRailAssetCsvMetadata? CacheIFix { get; set; } + } +} + +[JsonSerializable(typeof(StarRailRefMainInfo))] +internal partial class StarRailRepairJsonContext : JsonSerializerContext; + +internal class StarRailRefMainInfo +{ + [JsonPropertyOrder(0)] public int MajorVersion { get; init; } + [JsonPropertyOrder(1)] public int MinorVersion { get; init; } + [JsonPropertyOrder(2)] public int PatchVersion { get; init; } + [JsonPropertyOrder(3)] public int PrevPatch { get; init; } + [JsonPropertyOrder(4)] public string ContentHash { get; init; } = ""; + [JsonPropertyOrder(5)] public long FileSize { get; init; } + [JsonPropertyOrder(7)] public string FileName { get; init; } = ""; + [JsonPropertyOrder(8)] public string BaseAssetsDownloadUrl { get; init; } = ""; + + [JsonPropertyOrder(6)] + [JsonConverter(typeof(UtcUnixStampToDateTimeOffsetJsonConverter))] + public DateTimeOffset TimeStamp { get; init; } + + [JsonIgnore] + public string UnaliasedFileName => + FileName.StartsWith("M_", StringComparison.OrdinalIgnoreCase) ? FileName[2..] : FileName; + + [JsonIgnore] + public string RemoteFileName => field ??= $"{UnaliasedFileName}_{ContentHash}.bytes"; + + public override string ToString() => RemoteFileName; + + /// + /// Converts this instance to .
+ /// This is necessary as SRMI (Star Rail Metadata Index) file is now being generated on Client-side, so + /// we wanted to save the files locally to save the information about the reference file. + ///
+ /// An instance of . + public StarRailAssetMetadataIndex ToMetadataIndex() + { + StarRailAssetMetadataIndex metadataIndex = + StarRailBinaryData.CreateDefault(); + + StarRailAssetMetadataIndex.MetadataIndex indexData = new() + { + MajorVersion = MajorVersion, + MinorVersion = MinorVersion, + PatchVersion = PatchVersion, + MD5Checksum = HexTool.HexToBytesUnsafe(ContentHash), + MetadataIndexFileSize = (int)FileSize, + PrevPatch = 0, // Leave PrevPatch to be 0 + Timestamp = TimeStamp, + Reserved = new byte[10] + }; + + metadataIndex.DataList.Add(indexData); + return metadataIndex; + } + + /// + /// Converts this instance to .
+ /// This is necessary as SRMI (Star Rail Metadata Index) file is now being generated on Client-side, so + /// we wanted to save the files locally to save the information about the reference file. + ///
+ /// An instance of . + public static implicit operator StarRailAssetMetadataIndex(StarRailRefMainInfo instance) => instance.ToMetadataIndex(); + + public static async Task> ParseListFromUrlAsync( + StarRailRepairV2 instance, + HttpClient client, + string url, + string? saveToLocalDir = null, + CancellationToken token = default) + { + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Ref: {Path.GetFileNameWithoutExtension(url)}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + await using Stream networkStream = (await client.TryGetCachedStreamFrom(url, token: token)).Stream; + await using Stream sourceStream = !string.IsNullOrEmpty(saveToLocalDir) + ? CreateLocalStream(networkStream, Path.Combine(saveToLocalDir, Path.GetFileName(url))) + : networkStream; + + Dictionary returnList = []; + using StreamReader reader = new(sourceStream); + while (await reader.ReadLineAsync(token) is { } line) + { + StarRailRefMainInfo refInfo = line.Deserialize(StarRailRepairJsonContext.Default.StarRailRefMainInfo) + ?? throw new NullReferenceException(); + + returnList.Add(refInfo.UnaliasedFileName, refInfo); + } + + return returnList; + } + + public static async Task> + ParseMetadataFromUrlAsync( + StarRailRepairV2 instance, + HttpClient client, + string url, + TParser parser, + Func md5Selector, + Func sizeSelector, + Func timestampSelector, + Func versionSelector, + string? saveToLocalDir = null, + CancellationToken token = default) + where TParser : StarRailBinaryData + { + // Set total activity string as "Fetching Caches Type: " + instance.Status.ActivityStatus = string.Format(Locale.Lang._CachesPage.CachesStatusFetchingType, $"Game Ref: {Path.GetFileNameWithoutExtension(url)}"); + instance.Status.IsProgressAllIndetermined = true; + instance.Status.IsIncludePerFileIndicator = false; + instance.UpdateStatus(); + + await using Stream networkStream = (await client.TryGetCachedStreamFrom(url, token: token)).Stream; + await using Stream sourceStream = !string.IsNullOrEmpty(saveToLocalDir) + ? CreateLocalStream(networkStream, Path.Combine(saveToLocalDir, Path.GetFileName(url))) + : networkStream; + + string filenameNoExt = Path.GetFileNameWithoutExtension(url); + + // Start parsing + await parser.ParseAsync(sourceStream, true, token); + byte[] md5Checksum = md5Selector(parser); + long fileSize = sizeSelector(parser); + DateTimeOffset timestamp = timestampSelector(parser); + Version version = versionSelector(parser); + + StarRailRefMainInfo relInfo = new() + { + ContentHash = HexTool.BytesToHexUnsafe(md5Checksum)!, + FileName = filenameNoExt, + FileSize = fileSize, + TimeStamp = timestamp, + MajorVersion = version.Major, + MinorVersion = version.Minor, + PatchVersion = version.Build + }; + + Dictionary dict = []; + dict.Add(relInfo.UnaliasedFileName, relInfo); + return dict; + } + + private static CopyToStream CreateLocalStream(Stream thisSourceStream, string filePath) + { + FileInfo fileInfo = new FileInfo(filePath) + .EnsureCreationOfDirectory() + .EnsureNoReadOnly() + .StripAlternateDataStream(); + return new CopyToStream(thisSourceStream, fileInfo.Create(), null, true); + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs new file mode 100644 index 000000000..dacd0f510 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Check.cs @@ -0,0 +1,221 @@ +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.SentryHelper; +using Hi3Helper.Shared.ClassStruct; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using static Hi3Helper.Locale; +using static Hi3Helper.Logger; +// ReSharper disable StringLiteralTypo +// ReSharper disable CommentTypo +// ReSharper disable SwitchStatementMissingSomeEnumCasesNoDefault + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 + { + private async Task Check(List assetIndex, CancellationToken token) + { + // Try to find "badlist.byte" files in the game folder and delete it + foreach (string badListFile in Directory.EnumerateFiles(GamePath, "*badlist*.byte*", SearchOption.AllDirectories)) + { + LogWriteLine($"Removing bad list mark at: {badListFile}", LogType.Warning, true); + TryDeleteReadOnlyFile(badListFile); + } + + // Try to find "verify.fail" files in the game folder and delete it + foreach (string verifyFail in Directory.EnumerateFiles(GamePath, "*verify*.fail*", SearchOption.AllDirectories)) + { + LogWriteLine($"Removing verify.fail mark at: {verifyFail}", LogType.Warning, true); + TryDeleteReadOnlyFile(verifyFail); + } + + List brokenAssetIndex = []; + + // Set Indetermined status as false + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; + + // Show the asset entry panel + Status.IsAssetEntryPanelShow = true; + + // Await the task for parallel processing + try + { + // Iterate assetIndex and check it using different method for each type and run it in parallel + await Parallel.ForEachAsync(assetIndex, new ParallelOptions { MaxDegreeOfParallelism = ThreadCount, CancellationToken = token }, async (asset, threadToken) => + { + await CheckGenericAssetType(asset, brokenAssetIndex, threadToken); + }); + } + catch (AggregateException ex) + { + Exception innerExceptionsFirst = ex.Flatten().InnerExceptions.First(); + await SentryHelper.ExceptionHandlerAsync(innerExceptionsFirst, SentryHelper.ExceptionType.UnhandledOther); + throw innerExceptionsFirst; + } + + // Re-add the asset index with a broken asset index + assetIndex.Clear(); + assetIndex.AddRange(brokenAssetIndex); + } + + #region AssetTypeCheck + + private async Task CheckGenericAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) + { + // Update activity status + Status.ActivityStatus = string.Format(Lang._GameRepairPage.Status6, asset.N); + + // Increment current total count + ProgressAllCountCurrent++; + + // Reset per file size counter + ProgressPerFileSizeTotal = asset.S; + ProgressPerFileSizeCurrent = 0; + + string gamePath = GamePath; + string filePath = Path.Combine(gamePath, asset.N); + + AddUnusedHashMarkFile(filePath, gamePath, asset, targetAssetIndex); + if (asset.FT == FileType.Unused) + { + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File: {asset.N} is unused", LogType.Warning, true); + return; + } + + // Get the file info + FileInfo fileInfo = new(filePath); + + // Check if the file exist or has unmatched size + if (!fileInfo.Exists) + { + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File [T: {asset.FT}]: {asset.N} is not found", LogType.Warning, true); + return; + } + + if (fileInfo.Length != asset.S) + { + if (fileInfo.Name.Contains("pkg_version")) return; + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File [T: {asset.FT}]: {asset.N} has unmatched size " + + $"(Local: {fileInfo.Length} <=> Remote: {asset.S}", + LogType.Warning, true); + return; + } + + // Skip CRC check if fast method is used + if (UseFastMethod) + { + return; + } + + // Open and read fileInfo as FileStream + await using FileStream fileStream = await NaivelyOpenFileStreamAsync(fileInfo, FileMode.Open, FileAccess.Read, FileShare.Read); + // If pass the check above, then do CRC calculation + // Additional: the total file size progress is disabled and will be incremented after this + byte[] localCrc = await GetCryptoHashAsync(fileStream, null, true, true, token); + + // If local and asset CRC doesn't match, then add the asset + if (IsArrayMatch(localCrc, asset.CRCArray)) + { + return; + } + + AddIndex(asset, targetAssetIndex); + LogWriteLine($"File [T: {asset.FT}]: {asset.N} is broken! Index CRC: {asset.CRC} <--> File CRC: {HexTool.BytesToHexUnsafe(localCrc)}", LogType.Warning, true); + } + + private void AddIndex(FilePropertiesRemote asset, List targetAssetIndex) + { + // Update the total progress and found counter + ProgressAllSizeFound += asset.S; + ProgressAllCountFound++; + + // Set the per size progress + ProgressPerFileSizeCurrent = asset.S; + + // Increment the total current progress + ProgressAllSizeCurrent += asset.S; + + var prop = new AssetProperty(Path.GetFileName(asset.N), + ConvertRepairAssetTypeEnum(asset.FT), + Path.GetDirectoryName(asset.N), + asset.S, + null, + null); + + Dispatch(() => AssetEntry.Add(prop)); + asset.AssociatedAssetProperty = prop; + targetAssetIndex.Add(asset); + } + + private void AddUnusedHashMarkFile(string filePath, + string gamePath, + FilePropertiesRemote asset, + List brokenFileList) + { + if (asset.CRCArray?.Length == 0 || + (!asset.IsHasHashMark && asset.FT != FileType.Unused)) + { + return; + } + + string dir = Path.GetDirectoryName(filePath)!; + string fileNameNoExt = Path.GetFileNameWithoutExtension(filePath); + + if (!Directory.Exists(dir)) + { + return; + } + + foreach (string markFile in Directory.EnumerateFiles(dir, $"{fileNameNoExt}_*", + SearchOption.TopDirectoryOnly)) + { + ReadOnlySpan markFilename = Path.GetFileName(markFile); + ReadOnlySpan hashSpan = ConverterTool.GetSplit(markFilename, ^2, "_."); + if (!HexTool.IsHexString(hashSpan)) + { + continue; + } + + if (!asset.CRC?.Equals(hashSpan, StringComparison.OrdinalIgnoreCase) ?? false) + { + AddAssetInner(markFile); + } + + // Add equal hash mark if the file is marked as unused. + if (asset.FT != FileType.Unused) + { + continue; + } + + AddAssetInner(markFile); + } + + return; + + void AddAssetInner(string thisFilePath) + { + string relPath = thisFilePath[gamePath.Length..].Trim('\\'); + AddIndex(new FilePropertiesRemote + { + FT = FileType.Unused, + N = relPath + }, brokenFileList); + } + } + #endregion + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs new file mode 100644 index 000000000..d68ba8a90 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Fetch.cs @@ -0,0 +1,237 @@ +using CollapseLauncher.Helper; +using CollapseLauncher.Helper.Metadata; +using CollapseLauncher.RepairManagement; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.Shared.ClassStruct; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using static Hi3Helper.Locale; +// ReSharper disable CommentTypo +// ReSharper disable StringLiteralTypo +// ReSharper disable SwitchStatementHandlesSomeKnownEnumValuesWithDefault + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 + { + private async Task Fetch(List assetIndex, CancellationToken token) + { + // Set total activity string as "Loading Indexes..." + Status.ActivityStatus = Lang._GameRepairPage.Status2; + Status.IsProgressAllIndetermined = true; + + UpdateStatus(); + + // Initialize new proxy-aware HttpClient + using HttpClient client = new HttpClientBuilder() + .UseLauncherConfig(DownloadThreadWithReservedCount) + .SetUserAgent(UserAgent) + .SetAllowedDecompression(DecompressionMethods.None) + .Create(); + + // Get shared HttpClient + HttpClient sharedClient = FallbackCDNUtil.GetGlobalHttpClient(true); + string regionId = GetExistingGameRegionID(); + string[] installedVoiceLang = await GetInstalledVoiceLanguageOrDefault(token); + + // Redirect to fetch cache if Cache Update Mode is used. + if (IsCacheUpdateMode) + { + await FetchForCacheUpdateMode(client, regionId, assetIndex, token); + return; + } + + // Get the primary manifest + await GetPrimaryManifest(assetIndex, installedVoiceLang, token); + + // If the this._isOnlyRecoverMain && base._isVersionOverride is true, copy the asset index into the _originAssetIndex + if (IsOnlyRecoverMain && IsVersionOverride) + { + OriginAssetIndex = [..assetIndex]; + // Due to all assets have relative path instead of absolute path, this code is no longer necessary. + /* + foreach (FilePropertiesRemote asset in assetIndex) + { + FilePropertiesRemote newAsset = asset.Copy(); + ReadOnlyMemory assetRelativePath = newAsset.N.AsMemory(GamePath.Length).TrimStart('\\'); + newAsset.N = assetRelativePath.ToString(); + OriginAssetIndex.Add(newAsset); + }*/ + + } + + // Fetch assets from game server + if (!IsVersionOverride && + !IsOnlyRecoverMain) + { + PresetConfig gamePreset = GameVersionManager.GamePreset; + SRDispatcherInfo dispatcherInfo = new(gamePreset.GameDispatchArrayURL, + gamePreset.ProtoDispatchKey, + gamePreset.GameDispatchURLTemplate, + gamePreset.GameGatewayURLTemplate, + gamePreset.GameDispatchChannelName, + GameVersionManager.GetGameVersionApi().ToString()); + await dispatcherInfo.Initialize(client, regionId, token); + + StarRailPersistentRefResult persistentRefResult = await StarRailPersistentRefResult + .GetRepairReferenceAsync(this, + dispatcherInfo, + client, + GamePath, + GameDataPersistentPathRelative, + token); + + assetIndex.AddRange(persistentRefResult.GetPersistentFiles(assetIndex, GamePath, installedVoiceLang)); + await persistentRefResult.FinalizeRepairFetchAsync(this, sharedClient, assetIndex, + GameDataPersistentPath, token); + } + + // Force-Fetch the Bilibili SDK (if exist :pepehands:) + await FetchBilibiliSdk(token); + + // Remove plugin from assetIndex + // Skip the removal for Delta-Patch + if (!IsOnlyRecoverMain) + { + EliminatePluginAssetIndex(assetIndex, x => x.N, x => x.RN); + } + } + + #region PrimaryManifest + + private async Task GetInstalledVoiceLanguageOrDefault(CancellationToken token) + { + if (!File.Exists(GameAudioLangListPathStatic)) + { + return []; // Return empty. For now, let's not mind about what VOs the user actually have and let the game decide. + } + + string[] installedAudioLang = (await File.ReadAllLinesAsync(GameAudioLangListPathStatic, token)) + .Where(x => !string.IsNullOrEmpty(x)) + .ToArray(); + return installedAudioLang; + } + + private async Task GetPrimaryManifest(List assetIndex, string[] voiceLang, CancellationToken token) + { + // 2025/12/28: + // Starting from this, we use Sophon as primary manifest source instead of relying on our Game Repair Index + // as miHoYo might remove uncompressed files from their CDN and fully moving to Sophon. + + HttpClient client = FallbackCDNUtil.GetGlobalHttpClient(true); + + string[] excludedMatchingField = ["en-us", "zh-cn", "ja-jp", "ko-kr"]; + if (File.Exists(GameAudioLangListPathStatic)) + { + string[] installedAudioLang = voiceLang + .Select(x => x switch + { + "English" => "en-us", + "English(US)" => "en-us", + "Japanese" => "ja-jp", + "Chinese(PRC)" => "zh-cn", + "Korean" => "ko-kr", + _ => "" + }) + .Where(x => !string.IsNullOrEmpty(x)) + .ToArray(); + + excludedMatchingField = excludedMatchingField.Where(x => !installedAudioLang.Contains(x)) + .ToArray(); + } + + await this.FetchAssetsFromSophonAsync(client, + assetIndex, + DetermineFileTypeFromExtension, + GameVersion, + excludedMatchingField, + token); + } + + #endregion + + #region Utilities + + internal static FileType DetermineFileTypeFromExtension(string fileName) + { + if (fileName.EndsWith(".block", StringComparison.OrdinalIgnoreCase)) + { + return FileType.Block; + } + + if (fileName.EndsWith(".usm", StringComparison.OrdinalIgnoreCase)) + { + return FileType.Video; + } + + if (fileName.EndsWith(".pck", StringComparison.OrdinalIgnoreCase)) + { + return FileType.Audio; + } + + return FileType.Generic; + } + + private unsafe string GetExistingGameRegionID() + { + // Delegate the default return value + string GetDefaultValue() => InnerGameVersionManager.GamePreset.GameDispatchDefaultName ?? throw new KeyNotFoundException("Default dispatcher name in metadata is not exist!"); + + // Try to get the value as nullable object + object? value = GameSettings?.RegistryRoot?.GetValue("App_LastServerName_h2577443795", null); + // Check if the value is null, then return the default name + // Return the dispatch default name. If none, then throw + if (value == null) return GetDefaultValue(); + + // Cast the value as byte array + byte[] valueBytes = (byte[])value; + int count = valueBytes.Length; + + // If the registry is empty, then return the default value; + if (valueBytes.Length == 0) + return GetDefaultValue(); + + // Get the pointer of the byte array + fixed (byte* valuePtr = &valueBytes[0]) + { + // Try check the byte value. If it's null, then continue the loop while + // also decreasing the count as its index + while (*(valuePtr + (count - 1)) == 0) { --count; } + + // Get the name from the span and trim the \0 character at the end + string name = Encoding.UTF8.GetString(valuePtr, count); + return name; + } + } + + private void CountAssetIndex(List assetIndex) + { + // Sum the assetIndex size and assign to _progressAllSize + ProgressAllSizeTotal = assetIndex.Sum(x => x.S); + + // Assign the assetIndex count to _progressAllCount + ProgressAllCountTotal = assetIndex.Count; + } + + private static RepairAssetType ConvertRepairAssetTypeEnum(FileType assetType) => assetType switch + { + FileType.Unused => RepairAssetType.Unused, + FileType.Block => RepairAssetType.Block, + FileType.Audio => RepairAssetType.Audio, + FileType.Video => RepairAssetType.Video, + _ => RepairAssetType.Generic + }; + #endregion + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs new file mode 100644 index 000000000..ca745f342 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.FetchForCacheUpdateMode.cs @@ -0,0 +1,60 @@ +using CollapseLauncher.Helper.Metadata; +using Hi3Helper.EncTool.Parser.AssetMetadata; +using Hi3Helper.Shared.ClassStruct; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher; + +internal partial class StarRailRepairV2 +{ + + #region CacheUpdateManifest + + private async Task FetchForCacheUpdateMode(HttpClient client, + string regionId, + List assetIndex, + CancellationToken token) + { + // -- Fetch game dispatcher/gateway + PresetConfig gamePreset = GameVersionManager.GamePreset; + SRDispatcherInfo dispatcherInfo = new(gamePreset.GameDispatchArrayURL, + gamePreset.ProtoDispatchKey, + gamePreset.GameDispatchURLTemplate, + gamePreset.GameGatewayURLTemplate, + gamePreset.GameDispatchChannelName, + GameVersionManager.GetGameVersionApi().ToString()); + await dispatcherInfo.Initialize(client, regionId, token); + + StarRailPersistentRefResult persistentRefResult = await StarRailPersistentRefResult + .GetCacheReferenceAsync(this, + dispatcherInfo, + client, + GamePath, + GameDataPersistentPathRelative, + token); + + List sophonAssets = []; + await GetPrimaryManifest(sophonAssets, [], token); // Just to get the sophon asset for stock LuaV + + await persistentRefResult.FinalizeCacheFetchAsync(this, + client, + sophonAssets, + GamePath, + persistentRefResult.BaseDirs.CacheLua!, + token); + + // HACK: Duplicate List from Sophon so we know which one is being added + List sophonAssetsDup = new(sophonAssets); + assetIndex.AddRange(persistentRefResult.GetPersistentFiles(sophonAssetsDup, GamePath, [])); + assetIndex.AddRange(sophonAssetsDup[sophonAssets.Count..]); + } + + #endregion +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs new file mode 100644 index 000000000..6f92283ea --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.Repair.cs @@ -0,0 +1,292 @@ +using CollapseLauncher.Extension; +using CollapseLauncher.Helper; +using CollapseLauncher.Helper.StreamUtility; +using CollapseLauncher.RepairManagement; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.Http; +using Hi3Helper.Shared.ClassStruct; +using Hi3Helper.Sophon; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 + { + private static ReadOnlySpan HashMarkFileContent => [0x20]; + + public async Task StartRepairRoutine( + bool showInteractivePrompt = false, + Action? actionIfInteractiveCancel = null) + { + await TryRunExamineThrow(StartRepairRoutineCoreAsync(showInteractivePrompt, actionIfInteractiveCancel)); + + // Reset status and progress + ResetStatusAndProgress(); + + // Set as completed + Status.ActivityStatus = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusUpToDate : Locale.Lang._GameRepairPage.Status7; + + // Update status and progress + UpdateAll(); + } + + private async Task StartRepairRoutineCoreAsync(bool showInteractivePrompt = false, + Action? actionIfInteractiveCancel = null) + { + if (AssetIndex.Count == 0) throw new InvalidOperationException("There's no broken file being reported! You can't perform repair process!"); + + // Swap current found all size to per file size + ProgressPerFileSizeTotal = ProgressAllSizeTotal; + ProgressAllSizeTotal = AssetIndex.Where(x => x.FT != FileType.Unused).Sum(x => x.S); + + // Reset progress counter + ResetProgressCounter(); + + if (showInteractivePrompt && + actionIfInteractiveCancel != null) + { + await SpawnRepairDialog(AssetIndex, actionIfInteractiveCancel); + } + + // Initialize new proxy-aware HttpClient + using HttpClient client = new HttpClientBuilder() + .UseLauncherConfig(DownloadThreadWithReservedCount) + .SetUserAgent(UserAgent) + .SetAllowedDecompression(DecompressionMethods.None) + .Create(); + + int threadNum = IsBurstDownloadEnabled + ? ThreadForIONormalized + : 1; + + await Parallel.ForEachAsync(AssetIndex, + new ParallelOptions + { + CancellationToken = Token!.Token, + MaxDegreeOfParallelism = threadNum + }, + Impl); + + return; + + async ValueTask Impl(FilePropertiesRemote asset, CancellationToken token) + { + await (asset switch + { + { AssociatedObject: SophonAsset } => RepairAssetGenericSophonType(asset, token), + // ReSharper disable once AccessToDisposedClosure + _ => RepairAssetGenericType(client, asset, token) + }); + + if (!asset.IsHasHashMark) + { + return; + } + + string fileDir = Path.Combine(GamePath, Path.GetDirectoryName(asset.N) ?? ""); + string fileNameNoExt = Path.GetFileNameWithoutExtension(asset.N); + string markPath = Path.Combine(fileDir, $"{fileNameNoExt}_{asset.CRC}.hash"); + + File.WriteAllBytes(markPath, HashMarkFileContent); + } + } + + private async ValueTask RepairAssetGenericSophonType( + FilePropertiesRemote asset, + CancellationToken token) + { + // Update repair status to the UI + this.UpdateCurrentRepairStatus(asset); + + string assetPath = Path.Combine(GamePath, asset.N); + FileInfo assetFileInfo = new FileInfo(assetPath) + .StripAlternateDataStream() + .EnsureCreationOfDirectory() + .EnsureNoReadOnly(); + + try + { + await using FileStream assetFileStream = assetFileInfo + .Open(FileMode.Create, + FileAccess.Write, + FileShare.Write, + asset.S.GetFileStreamBufferSize()); + + if (asset.AssociatedObject is not SophonAsset sophonAsset) + { + throw new + InvalidOperationException("Invalid operation! This asset shouldn't have been here! It's not a sophon-based asset!"); + } + + // Download as Sophon asset + await sophonAsset + .WriteToStreamAsync(FallbackCDNUtil.GetGlobalHttpClient(true), + assetFileStream, + readBytes => UpdateProgressCounter(readBytes, readBytes), + token: token); + } + finally + { + this.PopBrokenAssetFromList(asset); + assetFileInfo.Directory?.DeleteEmptyDirectory(true); + } + } + + private async ValueTask RepairAssetGenericType( + HttpClient downloadHttpClient, + FilePropertiesRemote asset, + CancellationToken token) + { + // Update repair status to the UI + this.UpdateCurrentRepairStatus(asset, IsCacheUpdateMode); + + string assetPath = Path.Combine(GamePath, asset.N); + FileInfo assetFileInfo = new FileInfo(assetPath) + .StripAlternateDataStream() + .EnsureNoReadOnly(); + + try + { + if (asset.FT == FileType.Unused) + { + if (assetFileInfo.TryDeleteFile()) + { + Logger.LogWriteLine($"[StarRailRepairV2::RepairAssetGenericType] Unused asset {asset} has been deleted!", + LogType.Default, + true); + } + + return; + } + + // Use Hi3Helper.Http module to download the file. + DownloadClient downloadClient = DownloadClient + .CreateInstance(downloadHttpClient); + + // Perform download + await RunDownloadTask(asset.S, + assetFileInfo, + asset.RN, + downloadClient, + ProgressRepairAssetGenericType, + token); + + Logger.LogWriteLine($"[StarRailRepairV2::RepairAssetGenericType] Asset {asset.N} has been downloaded!", + LogType.Default, + true); + } + finally + { + this.PopBrokenAssetFromList(asset); + assetFileInfo.Directory?.DeleteEmptyDirectory(true); + } + } + + // Note for future me @neon-nyan: + // This is intended that we ignore DownloadProgress for now as the download size for "per-file" progress + // is now being handled by this own class progress counter. + private void ProgressRepairAssetGenericType(int read, DownloadProgress progress) => UpdateProgressCounter(read, read); + + private double _downloadReadLastSpeed; + private long _downloadReadLastReceivedBytes; + private long _downloadReadLastTick; + + private double _dataWriteLastSpeed; + private long _dataWriteLastReceivedBytes; + private long _dataWriteLastTick; + + private void UpdateProgressCounter(long dataWrite, long downloadRead) + { + double speedAll = CalculateSpeed(dataWrite, // dataWrite used as All Progress overall speed. + ref _dataWriteLastSpeed, + ref _dataWriteLastReceivedBytes, + ref _dataWriteLastTick); + + double speedPerFile = CalculateSpeed(downloadRead, // downloadRead used as Per File Progress overall speed. + ref _downloadReadLastSpeed, + ref _downloadReadLastReceivedBytes, + ref _downloadReadLastTick); + + Interlocked.Add(ref ProgressAllSizeCurrent, dataWrite); + Interlocked.Add(ref ProgressPerFileSizeCurrent, downloadRead); + + if (!CheckIfNeedRefreshStopwatch()) + { + return; + } + + double speedClamped = speedAll.ClampLimitedSpeedNumber(); + TimeSpan timeLeftSpan = ConverterTool.ToTimeSpanRemain(ProgressAllSizeTotal, + ProgressAllSizeCurrent, + speedClamped); + + double percentPerFile = ProgressPerFileSizeCurrent != 0 + ? ConverterTool.ToPercentage(ProgressPerFileSizeTotal, ProgressPerFileSizeCurrent) + : 0; + double percentAll = ProgressAllSizeCurrent != 0 + ? ConverterTool.ToPercentage(ProgressAllSizeTotal, ProgressAllSizeCurrent) + : 0; + + lock (Progress) + { + Progress.ProgressPerFilePercentage = percentPerFile; + Progress.ProgressPerFileSizeCurrent = ProgressPerFileSizeCurrent; + Progress.ProgressPerFileSizeTotal = ProgressPerFileSizeTotal; + Progress.ProgressAllSizeCurrent = ProgressAllSizeCurrent; + Progress.ProgressAllSizeTotal = ProgressAllSizeTotal; + + // Calculate speed + Progress.ProgressAllSpeed = speedClamped; + Progress.ProgressAllTimeLeft = timeLeftSpan; + + // Update current progress percentages + Progress.ProgressAllPercentage = percentAll; + } + + lock (Status) + { + // Update current activity status + Status.IsProgressAllIndetermined = false; + Status.IsProgressPerFileIndetermined = false; + + // Set time estimation string + string timeLeftString = string.Format(Locale.Lang._Misc.TimeRemainHMSFormat, Progress.ProgressAllTimeLeft); + + Status.ActivityPerFile = string.Format(Locale.Lang._Misc.Speed, ConverterTool.SummarizeSizeSimple(speedPerFile)); + Status.ActivityAll = string.Format(Locale.Lang._GameRepairPage.PerProgressSubtitle2, + ConverterTool.SummarizeSizeSimple(ProgressAllSizeCurrent), + ConverterTool.SummarizeSizeSimple(ProgressAllSizeTotal)) + + $" | {timeLeftString}" + + $" ({string.Format(Locale.Lang._Misc.Speed, ConverterTool.SummarizeSizeSimple(speedAll))})"; + + // Trigger update + UpdateAll(); + } + } + + private void ResetProgressCounter() + { + _dataWriteLastSpeed = 0; + _dataWriteLastReceivedBytes = 0; + _dataWriteLastTick = 0; + + _downloadReadLastSpeed = 0; + _downloadReadLastReceivedBytes = 0; + _downloadReadLastTick = 0; + + ProgressAllSizeCurrent = 0; + ProgressPerFileSizeCurrent = 0; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs new file mode 100644 index 000000000..324f8b365 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/StarRailRepairV2.cs @@ -0,0 +1,123 @@ +using CollapseLauncher.GameVersioning; +using CollapseLauncher.InstallManager.StarRail; +using CollapseLauncher.Interfaces; +using CollapseLauncher.Statics; +using Hi3Helper; +using Hi3Helper.Data; +using Hi3Helper.Shared.ClassStruct; +using Microsoft.UI.Xaml; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +// ReSharper disable StringLiteralTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher +{ + internal partial class StarRailRepairV2 : ProgressBase, IRepair, IRepairAssetIndex + { + #region Properties + + public override string GamePath + { + get => GameVersionManager.GameDirPath; + set => GameVersionManager.GameDirPath = value; + } + + private GameTypeStarRailVersion InnerGameVersionManager { get; } + private StarRailInstall? InnerGameInstaller + { + get => field ??= GamePropertyVault.GetCurrentGameProperty().GameInstall as StarRailInstall; + } + + private bool IsCacheUpdateMode { get; } + private bool IsOnlyRecoverMain { get; } + private List OriginAssetIndex { get; set; } = []; + private string ExecName { get; } + + private string GameDataPersistentPathRelative { get => Path.Combine($"{ExecName}_Data", "Persistent"); } + private string GameDataPersistentPath { get => Path.Combine(GamePath, GameDataPersistentPathRelative); } + + private string GameAudioLangListPathStatic { get => Path.Combine(GameDataPersistentPath, "AudioLaucherRecord.txt"); } + + protected override string UserAgent => "UnityPlayer/2019.4.34f1 (UnityWebRequest/1.0, libcurl/7.75.0-DEV)"; + #endregion + + public StarRailRepairV2( + UIElement parentUI, + IGameVersion gameVersionManager, + IGameSettings gameSettings, + bool onlyRecoverMainAsset = false, + string? versionOverride = null, + bool isCacheUpdateMode = false) + : base(parentUI, + gameVersionManager, + gameSettings, + "", + versionOverride) + { + // Get flag to only recover main assets + IsOnlyRecoverMain = onlyRecoverMainAsset; + InnerGameVersionManager = (gameVersionManager as GameTypeStarRailVersion)!; + ExecName = Path.GetFileNameWithoutExtension(InnerGameVersionManager.GamePreset.GameExecutableName) ?? ""; + IsCacheUpdateMode = isCacheUpdateMode; + } + + ~StarRailRepairV2() => Dispose(); + + public List GetAssetIndex() => OriginAssetIndex; + + public async Task StartCheckRoutine(bool useFastCheck) + { + UseFastMethod = useFastCheck; + return await TryRunExamineThrow(CheckRoutine()); + } + + private async Task CheckRoutine() + { + // Always clear the asset index list + AssetIndex.Clear(); + + // Reset status and progress + ResetStatusAndProgress(); + + // Step 1: Fetch asset indexes + await Fetch(AssetIndex, Token!.Token); + + // Step 2: Remove blacklisted files from asset index (borrow function from StarRailInstall) + await InnerGameInstaller!.FilterAssetList(AssetIndex, x => x.N, Token.Token); + + // Step 3: Calculate the total size and count of the files + CountAssetIndex(AssetIndex); + + // Step 4: Check for the asset indexes integrity + await Check(AssetIndex, Token.Token); + + // Step 5: Summarize and returns true if the assetIndex count != 0 indicates broken file was found. + // either way, returns false. + string status3Msg = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusNeedUpdate : Locale.Lang._GameRepairPage.Status3; + string status4Msg = IsCacheUpdateMode ? Locale.Lang._CachesPage.CachesStatusUpToDate : Locale.Lang._GameRepairPage.Status4; + return SummarizeStatusAndProgress(AssetIndex, + string.Format(status3Msg, ProgressAllCountFound, ConverterTool.SummarizeSizeSimple(ProgressAllSizeFound)), + status4Msg); + } + + public void CancelRoutine() + { + // Trigger token cancellation + Token?.Cancel(); + Token?.Dispose(); + Token = null; + } + + public void Dispose() + { + CancelRoutine(); + GC.SuppressFinalize(this); + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs new file mode 100644 index 000000000..5367d05e8 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBlockMetadata.cs @@ -0,0 +1,151 @@ +using Hi3Helper.Data; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Binary Metadata (SRBM) data parser for Start_BlockV and BlockV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetBlockMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetBlockMetadata() + : base(256, + 768, + 16, + 2, + 12) + { } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + (DataSectionHeaderStruct dataHeader, int readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Header.SubStructSize, + token) + .ConfigureAwait(false); + currentOffset += readHeader; + + // ASSERT: Make sure the current data stream offset is at the exact data buffer offset. + Debug.Assert(dataHeader.DataStartOffset == currentOffset); + + int dataBufferLen = dataHeader.DataSize * dataHeader.DataCount; + byte[] dataBuffer = ArrayPool.Shared.Rent(dataBufferLen); + try + { + // -- Read data buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + dataBuffer.AsMemory(0, dataBufferLen), + token); + + // -- Create span + ReadOnlySpan dataBufferSpan = dataBuffer.AsSpan(0, dataBufferLen); + + // -- Allocate list + DataList = new List(dataHeader.DataCount); + while (!dataBufferSpan.IsEmpty) + { + dataBufferSpan = Metadata.Parse(dataBufferSpan, + dataHeader.DataSize, + out Metadata result); + DataList.Add(result); + } + } + finally + { + ArrayPool.Shared.Return(dataBuffer); + } + + return currentOffset; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + internal struct DataSectionHeaderStruct + { + /// + /// The absolute offset of data array in the data stream. + /// + public int DataStartOffset; + + /// + /// The count of data array in the data stream. + /// + public int DataCount; + + /// + /// The size of the one data inside the array. + /// + public int DataSize; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + internal unsafe struct MetadataStruct + { + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + private fixed byte _shifted4BytesMD5Checksum[16]; + + /// + /// Defined flags of the asset bundle block file. + /// + public uint Flags; + + /// + /// The size of the block file. + /// + public int FileSize; + + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + public ReadOnlySpan Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new ReadOnlySpan(magicP, 16); + } + } + } + } + + public class Metadata : StarRailAssetFlaggable + { + public static ReadOnlySpan Parse(ReadOnlySpan buffer, + int sizeOfStruct, + out Metadata result) + { + ref readonly MetadataStruct structRef = ref MemoryMarshal.AsRef(buffer); + byte[] md5Buffer = new byte[16]; + + structRef.Shifted4BytesMD5Checksum.CopyTo(md5Buffer); + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Buffer); + + result = new Metadata + { + Filename = $"{HexTool.BytesToHexUnsafe(md5Buffer)}.block", + FileSize = structRef.FileSize, + Flags = structRef.Flags, + MD5Checksum = md5Buffer + }; + + return buffer[sizeOfStruct..]; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs new file mode 100644 index 000000000..840eded8c --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetBundleMetadata.cs @@ -0,0 +1,116 @@ +using Hi3Helper.EncTool; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Asset Bundle Metadata (SRAM) data parser for Start_AsbV and AsbV. This parser read-only and cannot be written back.
+///
+public sealed class StarRailAssetBundleMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetBundleMetadata() + : base(1280, + 256, + 40, + 8, + 16) { } + + private static ReadOnlySpan MagicSignatureStatic => "SRAM"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; + + protected override async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertWithPosAndSeekAsync // Use ReadDataAssertWithPosAndSeekAsync for SRAM + (0, + x => x.HeaderOrDataLength, + token); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // -- Read the first data section header. + (DataSectionHeaderStruct headerStructs, int read) = + await dataStream.ReadDataAssertAndSeekAsync + (_ => Header.SubStructSize, + token); + currentOffset += read; + + // -- Skip other data section header. + int remainedBufferSize = Header.SubStructSize * (Header.SubStructCount - 1); + currentOffset += await dataStream.SeekForwardAsync(remainedBufferSize, token); + + // ASSERT: Make sure we are at data start offset before reading. + Debug.Assert(currentOffset == headerStructs.DataStartOffset); + + // -- Read data buffer. + int dataBufferLen = headerStructs.DataCount * headerStructs.DataSize; + byte[] dataBuffer = ArrayPool.Shared.Rent(dataBufferLen); + try + { + // -- Read data buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + dataBuffer.AsMemory(0, dataBufferLen), + token); + + // -- Create span + ReadOnlySpan dataBufferSpan = dataBuffer.AsSpan(0, dataBufferLen); + + // -- Allocate list + DataList = new List(headerStructs.DataCount); + while (!dataBufferSpan.IsEmpty) + { + dataBufferSpan = StarRailAssetBlockMetadata + .Metadata + .Parse(dataBufferSpan, + headerStructs.DataSize, + out StarRailAssetBlockMetadata.Metadata result); + DataList.Add(result); + } + } + finally + { + ArrayPool.Shared.Return(dataBuffer); + } + + return currentOffset; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct DataSectionHeaderStruct + { + public int Reserved; + + /// + /// The absolute offset of data array in the data stream. + /// + public int DataStartOffset; + + /// + /// The count of data array in the data stream. + /// + public int DataCount; + + /// + /// The size of the one data inside the array. + /// + public int DataSize; + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs new file mode 100644 index 000000000..73128c659 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetCsvMetadata.cs @@ -0,0 +1,102 @@ +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Streams; +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Comma-Separated Value Metadata (CSV) data parser for IFixV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetCsvMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetCsvMetadata() + : base(0, // Leave the rest of it to 0 as this metadata is a Comma-Separated Value (CSV) format + 0, + 0, + 0, + 0) { } + + protected override ReadOnlySpan MagicSignature => "\0\0\0\0"u8; + + protected override ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync(Stream dataStream, + CancellationToken token = default) + { + return ValueTask.FromResult((default(StarRailBinaryDataHeaderStruct), 0)); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // -- Allocate list + DataList = []; + + // -- Read list + await using NullPositionTrackableStream trackingNullStream = new(); + await using CopyToStream bridgeStream = new(dataStream, trackingNullStream, null, false); + using StreamReader reader = new(bridgeStream, leaveOpen: true); + while (await reader.ReadLineAsync(token) is { } line) + { + if (!Metadata.Parse(line, out Metadata result)) + { + continue; + } + DataList.Add(result); + } + + return trackingNullStream.Position; + } + + public class Metadata : StarRailAssetFlaggable + { + public static bool Parse(ReadOnlySpan line, out Metadata result) + { + Unsafe.SkipInit(out result); + if (line.IsEmpty || + line.IsWhiteSpace()) + { + return false; + } + + const string separators = ",;"; // Include ; as well, just in case. + const StringSplitOptions options = StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries; + + Span ranges = stackalloc Range[8]; + int rangesLen = line.SplitAny(ranges, separators, options); + if (rangesLen < 3) + { + throw new InvalidOperationException("Format has been changed! Please report this issue to our devs!"); + } + + ReadOnlySpan filePath = line[ranges[0]]; + ReadOnlySpan hashStr = line[ranges[1]]; + ReadOnlySpan fileSizeStr = line[ranges[2]]; + + byte[] hash = new byte[16]; + if (!HexTool.TryHexToBytesUnsafe(hashStr, hash) || + !uint.TryParse(fileSizeStr, out uint fileSize)) + { + throw new InvalidOperationException($"Cannot parse values for this current line: {line} Please report this issue to our devs!"); + } + + result = new Metadata + { + Filename = filePath.ToString(), + FileSize = fileSize, + MD5Checksum = hash + }; + return true; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs new file mode 100644 index 000000000..a98b5d233 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetGenericFileInfo.cs @@ -0,0 +1,56 @@ +using Hi3Helper.Data; +using Hi3Helper.Plugin.Core.Utility.Json.Converters; +using System.Text.Json.Serialization; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Asset Generic and Flaggable File Info +/// +public class StarRailAssetFlaggable : StarRailAssetGenericFileInfo +{ + /// + /// Defined flags of the asset bundle block file. + /// + public uint Flags { get; init; } + + /// + /// To indicate whether this asset is persistent. + /// + public virtual bool IsPersistent => (Flags & 0b00000000_00010000_00000000_00000000u) != 0; + + public override string ToString() => + $"{Filename} | Flags: {ConverterTool.ToBinaryString(Flags)} | IsPersistent: {IsPersistent} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize}"; +} + +/// +/// Star Rail Asset Generic File Info +/// +public class StarRailAssetGenericFileInfo +{ + /// + /// The filename of the asset. + /// + [JsonPropertyName("Path")] + public virtual required string? Filename { get; init; } + + /// + /// Size of the file in bytes. + /// + [JsonPropertyName("Size")] + public required long FileSize { get; init; } + + /// + /// The MD5 hash checksum of the file. + /// + [JsonPropertyName("Md5")] + [JsonConverter(typeof(HexStringToArrayJsonConverter))] + public required byte[] MD5Checksum { get; init; } + + public override string ToString() => + $"{Filename} | Hash: {HexTool.BytesToHexUnsafe(MD5Checksum)} | Size: {FileSize} bytes"; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs new file mode 100644 index 000000000..12a9ab838 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetJsonMetadata.cs @@ -0,0 +1,83 @@ +using Hi3Helper.EncTool; +using Hi3Helper.EncTool.Streams; +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail JSON-based Metadata parser for AudioV and VideoV. This parser read-only and cannot be written back.
+///
+public sealed partial class StarRailAssetJsonMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetJsonMetadata() + : base(0, // Leave the rest of it to 0 as this metadata has JSON struct + 0, + 0, + 0, + 0) + { } + + protected override ReadOnlySpan MagicSignature => "\0\0\0\0"u8; + + protected override ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync(Stream dataStream, + CancellationToken token = default) + { + return ValueTask.FromResult((default(StarRailBinaryDataHeaderStruct), 0)); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // -- Allocate list + DataList = []; + + // -- Read list + await using NullPositionTrackableStream trackingNullStream = new(); + await using CopyToStream bridgeStream = new(dataStream, trackingNullStream, null, false); + using StreamReader reader = new(bridgeStream, leaveOpen: true); + while (await reader.ReadLineAsync(token) is { } line) + { + Metadata? metadata = JsonSerializer.Deserialize(line, MetadataJsonContext.Default.Metadata); + if (metadata == null) + { + continue; + } + + DataList.Add(metadata); + } + + return trackingNullStream.Position; + } + + [JsonSerializable(typeof(Metadata))] + public partial class MetadataJsonContext : JsonSerializerContext; + + public class Metadata : StarRailAssetFlaggable + { + [JsonPropertyName("Patch")] + public bool IsPatch { get; init; } + + [JsonPropertyName("SubPackId")] + public int SubPackId { get; init; } + + [JsonPropertyName("TaskIds")] + public int[]? TaskIdList { get; init; } + + public override bool IsPersistent => IsPatch; + + public override string ToString() => $"{base.ToString()} | Patch: {IsPatch} | SubPackId: {SubPackId}" + + (TaskIdList?.Length == 0 ? "" : $" | TaskIds: [{string.Join(", ", TaskIdList ?? [])}]"); + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs new file mode 100644 index 000000000..180472160 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetNativeDataMetadata.cs @@ -0,0 +1,190 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Binary Metadata (SRBM) data parser for NativeDataV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetNativeDataMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetNativeDataMetadata() + : base(256, + 768, + 16, + 2, + 16) + { } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + (DataSectionHeaderStruct fileInfoHeaderStruct, int readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Header.SubStructSize, + token) + .ConfigureAwait(false); + currentOffset += readHeader; + + (DataSectionHeaderStruct filenameHeaderStruct, readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Header.SubStructSize, + token) + .ConfigureAwait(false); + currentOffset += readHeader; + + // ASSERT: Make sure the current data stream offset is at the exact filename buffer offset. + Debug.Assert(filenameHeaderStruct.DataStartOffset == currentOffset); + + int filenameBufferLen = filenameHeaderStruct.DataSize * filenameHeaderStruct.DataCount; + int fileInfoBufferLen = fileInfoHeaderStruct.DataSize * fileInfoHeaderStruct.DataCount; + byte[] filenameBuffer = ArrayPool.Shared.Rent(filenameBufferLen); + byte[] fileInfoBuffer = ArrayPool.Shared.Rent(fileInfoBufferLen); + try + { + // -- Read filename buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + filenameBuffer.AsMemory(0, filenameBufferLen), + token); + + // -- Read file info buffer + currentOffset += await dataStream.ReadBufferAssertAsync(currentOffset, + fileInfoBuffer.AsMemory(0, fileInfoBufferLen), + token); + + // -- Create spans + Span filenameBufferSpan = filenameBuffer.AsSpan(0, filenameBufferLen); + Span fileInfoBufferSpan = fileInfoBuffer.AsSpan(0, fileInfoBufferLen); + + // -- Allocate list + DataList = new List(fileInfoHeaderStruct.DataCount); + + for (int i = 0; i < fileInfoHeaderStruct.DataCount && !filenameBufferSpan.IsEmpty; i++) + { + ref FileInfoStruct fileInfo = ref MemoryMarshal.AsRef(fileInfoBufferSpan); + filenameBufferSpan = Metadata.Parse(filenameBufferSpan, + ref fileInfo, + out Metadata? asset); + fileInfoBufferSpan = fileInfoBufferSpan[fileInfoHeaderStruct.DataSize..]; + + if (asset == null) + { + throw new IndexOutOfRangeException("Failed to parse NativeData assets as the buffer data might be insufficient or out-of-bounds!"); + } + DataList.Add(asset); + } + } + finally + { + ArrayPool.Shared.Return(filenameBuffer); + ArrayPool.Shared.Return(fileInfoBuffer); + } + + return currentOffset; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + private struct DataSectionHeaderStruct + { + public int Reserved; + + /// + /// The absolute offset of data array in the data stream. + /// + public int DataStartOffset; + + /// + /// The count of data array in the data stream. + /// + public int DataCount; + + /// + /// The size of the one data inside the array. + /// + public int DataSize; + } + + [StructLayout(LayoutKind.Sequential, Pack = 4)] + public unsafe struct FileInfoStruct + { + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + private fixed byte _shifted4BytesMD5Checksum[16]; + + /// + /// The size of the file. + /// + public long FileSize; + + /// + /// The filename length on the filename buffer. + /// + public int FilenameLength; + + /// + /// The start offset of the filename inside the filename buffer. + /// + public int FilenameStartAt; + + /// + /// 4x4 bytes Reordered hash (each 4 bytes reordered as Big-endian). + /// + public ReadOnlySpan Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new ReadOnlySpan(magicP, 16); + } + } + } + } + + public class Metadata : StarRailAssetGenericFileInfo + { + public static Span Parse(Span filenameBuffer, + ref FileInfoStruct assetInfo, + out Metadata? result) + { + Unsafe.SkipInit(out result); + int filenameLength = assetInfo.FilenameLength; + if (filenameBuffer.Length < filenameLength) + { + return filenameBuffer; + } + + string filename = Encoding.UTF8.GetString(filenameBuffer[..filenameLength]); + long fileSize = assetInfo.FileSize; + byte[] md5Checksum = new byte[16]; + + assetInfo.Shifted4BytesMD5Checksum.CopyTo(md5Checksum); + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Checksum); // Reorder 4x4 hash using SIMD + + result = new Metadata + { + Filename = filename, + FileSize = fileSize, + MD5Checksum = md5Checksum + }; + + return filenameBuffer[filenameLength..]; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs new file mode 100644 index 000000000..bb829a3c2 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/Assets/StarRailAssetSignaturelessMetadata.cs @@ -0,0 +1,156 @@ +using Hi3Helper.Data; +using Hi3Helper.EncTool; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct.Assets; + +/// +/// Star Rail Signatureless Metadata parser for LuaV, DesignV. This parser is read-only and cannot be written back.
+///
+public sealed class StarRailAssetSignaturelessMetadata : StarRailAssetBinaryMetadata +{ + public StarRailAssetSignaturelessMetadata() : this(null) + { + } + + public StarRailAssetSignaturelessMetadata(string? customAssetExtension = null) + : base(0, + 256, + 0, // Leave the rest of it to 0 as this metadata has non-consistent header struct + 0, + 0) + { + AssetExtension = customAssetExtension ?? ".block"; + } + + private string AssetExtension { get; } + + protected override ReadOnlySpan MagicSignature => [0x00, 0x00, 0x00, 0xFF]; + + protected override async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> + ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + // We manipulate the Header to only read its first 8 bytes so + // the signature assertion can be bypassed as the other data + // is needed to be read by ReadDataCoreAsync, we don't want to + // read the whole 16 bytes of it. + byte[] first8BytesBuffer = new byte[8]; + StarRailBinaryDataHeaderStruct header = default; + + _ = await dataStream.ReadAtLeastAsync(first8BytesBuffer, + first8BytesBuffer.Length, + cancellationToken: token); + + CopyHeaderUnsafe(ref header, first8BytesBuffer); + + return (header, first8BytesBuffer.Length); + + static unsafe void CopyHeaderUnsafe(scoped ref StarRailBinaryDataHeaderStruct target, + scoped Span dataBuffer) + => dataBuffer.CopyTo(new Span(Unsafe.AsPointer(in target), dataBuffer.Length)); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + // In this read data section, we will read the data manually since + // the entire data is Big-endian ordered. + + // -- Read the count from the header + byte[] countBuffer = new byte[8]; + currentOffset += await dataStream.ReadAtLeastAsync(countBuffer, + countBuffer.Length, + cancellationToken: token) + .ConfigureAwait(false); + + Span countBufferSpan = countBuffer; + int parentDataCount = BinaryPrimitives.ReadInt32BigEndian(countBufferSpan); + + // -- Declare constant length for each parent and children data + const int parentDataBufferLen = 32; + const int childrenDataBufferLen = 12; + + // -- Read the rest of the data buffer and parse it + byte[] parentDataBuffer = ArrayPool.Shared.Rent(parentDataBufferLen); + Memory parentDataBufferMemory = parentDataBuffer.AsMemory(0, parentDataBufferLen); + + // -- Allocate list + DataList = new List(parentDataCount); + + try + { + for (int i = 0; i < parentDataCount; i++) + { + long lastPos = currentOffset; + // -- Parse data and add to list + currentOffset += await dataStream.ReadAtLeastAsync(parentDataBufferMemory, + parentDataBufferMemory.Length, + cancellationToken: token) + .ConfigureAwait(false); + Metadata.Parse(parentDataBuffer, + AssetExtension, + childrenDataBufferLen, + lastPos, + out int bytesToSkip, + out Metadata result); + DataList.Add(result); + + // -- Skip children data + currentOffset += await dataStream.SeekForwardAsync(bytesToSkip, token) + .ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(parentDataBuffer); + } + + return currentOffset; + } + + public class Metadata : StarRailAssetFlaggable + { + public static void Parse(ReadOnlySpan buffer, + string assetExtension, + int subDataSize, + long lastDataStreamPos, + out int bytesToSkip, + out Metadata result) + { + // uint firstAssetId = BinaryPrimitives.ReadUInt32BigEndian(buffer); + uint assetType = BinaryPrimitives.ReadUInt32BigEndian(buffer[20..]); + long fileSize = BinaryPrimitives.ReadUInt32BigEndian(buffer[24..]); + int subDataCount = BinaryPrimitives.ReadInt32BigEndian(buffer[28..]); + + byte[] md5Hash = new byte[16]; + buffer[4..20].CopyTo(md5Hash); + + // subDataCount = Number of sub-data struct count + // subDataSize = Number of sub-data struct size + // 1 = Unknown offset, seek +1 + bytesToSkip = subDataCount * subDataSize + 1; + result = new Metadata + { + MD5Checksum = md5Hash, + Filename = $"{HexTool.BytesToHexUnsafe(md5Hash)}{assetExtension}", + FileSize = fileSize, + Flags = assetType + }; + } + } +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs new file mode 100644 index 000000000..44cf6bca6 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetBinaryMetadata.cs @@ -0,0 +1,37 @@ +using System; +using CollapseLauncher.RepairManagement.StarRail.Struct.Assets; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Star Rail Binary Metadata (SRBM) data parser. This parser is an abstract, read-only and cannot be written back.
+/// This implementation inherit these subtypes:
+/// -
+/// -
+/// -
+/// - (This type, however, doesn't actually parse raw binary data, rather parsing a JSON entry). +///
+public abstract class StarRailAssetBinaryMetadata : StarRailBinaryData + where TAsset : StarRailAssetGenericFileInfo +{ + protected StarRailAssetBinaryMetadata( + short parentTypeFlag, + short typeVersionFlag, + int headerOrDataLength, + short subStructCount, + short subStructSize) + : base(MagicSignatureStatic, + parentTypeFlag, + typeVersionFlag, + headerOrDataLength, + subStructCount, + subStructSize) { } + + private static ReadOnlySpan MagicSignatureStatic => "SRBM"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs new file mode 100644 index 000000000..d4d962bf9 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailAssetMetadataIndex.cs @@ -0,0 +1,305 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Star Rail Metadata Index (SRMI) data parser. This parser also implements to write back the result to a .
+/// This implementation inherit these subtypes:
+///
+internal class StarRailAssetMetadataIndex : StarRailBinaryDataWritable +{ + public StarRailAssetMetadataIndex() : this(false, false) + { + } + + public StarRailAssetMetadataIndex(bool use6BytesPadding = false, bool useHeaderSizeOfForAssert = false) + : base(MagicSignatureStatic, + 768, + 256, + 0, // On SRMI, the header length is actually the entire size of the data inside the stream (including header). + // The size will be recalculated if something changed. + + 0, // On SRMI, the value is always be 0. + 12) // On SRMI, the subStruct header length is 12 bytes (compared to SRBM's 16 bytes) + { + Use6BytesPaddingMode = use6BytesPadding; + UseHeaderSizeOfForAssert = useHeaderSizeOfForAssert; + } + + private static ReadOnlySpan MagicSignatureStatic => "SRMI"u8; + protected override ReadOnlySpan MagicSignature => MagicSignatureStatic; + + private bool Use6BytesPaddingMode { get; } + private bool UseHeaderSizeOfForAssert { get; } + + /// + /// Reads the header and perform assertion on the header. + /// + /// The which provides the source of the data to be parsed. + /// Cancellation token for cancelling asynchronous operations. + /// + /// This returns the and the current offset/position of the data stream after reading the header. + /// + protected override async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertAndSeekAsync(x => UseHeaderSizeOfForAssert + ? Marshal.SizeOf() + : x.HeaderOrDataLength, + token); + } + + protected override async ValueTask ReadDataCoreAsync( + long currentOffset, + Stream dataStream, + CancellationToken token = default) + { + if (Use6BytesPaddingMode) + { + (MetadataIndex6BytesPadStruct indexData6BytesPad, int readHeader6BytesPad) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Unsafe.SizeOf(), token) + .ConfigureAwait(false); + currentOffset += readHeader6BytesPad; + + MetadataIndex.Parse(in indexData6BytesPad, out MetadataIndex metadataIndex6Bytes); + DataList = [metadataIndex6Bytes]; + + return currentOffset; + } + + (MetadataIndexStruct indexData, int readHeader) = + await dataStream + .ReadDataAssertAndSeekAsync(_ => Unsafe.SizeOf(), token) + .ConfigureAwait(false); + currentOffset += readHeader; + + MetadataIndex.Parse(in indexData, out MetadataIndex metadataIndex); + DataList = [metadataIndex]; + + return currentOffset; + } + + protected override async ValueTask WriteHeaderCoreAsync(Stream dataStream, CancellationToken token = default) + { + int sizeOfHeader = Marshal.SizeOf(); + int sizeOfData = DataList.Count * Marshal.SizeOf(); + + StarRailBinaryDataHeaderStruct header = Header; // Copy header + header.HeaderOrDataLength = sizeOfHeader + sizeOfData; // Set data length + + await dataStream.WriteAsync(header, token).ConfigureAwait(false); + } + + protected override async ValueTask WriteDataCoreAsync(Stream dataStream, CancellationToken token = default) + { + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (DataList.Count == 0) + { + throw new InvalidOperationException("Data is not initialized!"); + } + + if (DataList.Count > 1) + { + throw new InvalidOperationException("This struct doesn't accept multiple data!"); + } + + ref readonly MetadataIndex dataRef = ref CollectionsMarshal.AsSpan(DataList)[0]; + + if (Use6BytesPaddingMode) + { + dataRef.ToStruct6BytesPad(out MetadataIndex6BytesPadStruct indexStruct6BytesPad); + await dataStream.WriteAsync(indexStruct6BytesPad, token).ConfigureAwait(false); + return; + } + + dataRef.ToStruct(out MetadataIndexStruct indexStruct); + await dataStream.WriteAsync(indexStruct, token).ConfigureAwait(false); + } + + [StructLayout(LayoutKind.Sequential, Pack = 2)] + public unsafe struct MetadataIndexStruct + { + public int MajorVersion; + public int MinorVersion; + public int PatchVersion; + private fixed byte _shifted4BytesMD5Checksum[16]; + public int MetadataIndexFileSize; + public int PrevPatch; + public int UnixTimestamp; + private fixed byte _reserved[10]; + + public Span Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new Span(magicP, 16); + } + } + } + + public Span Reserved + { + get + { + fixed (byte* reservedP = _reserved) + { + return new Span(reservedP, 10); + } + } + } + } + + [StructLayout(LayoutKind.Sequential, Pack = 2)] + public unsafe struct MetadataIndex6BytesPadStruct + { + public int MajorVersion; + public int MinorVersion; + public int PatchVersion; + private fixed byte _shifted4BytesMD5Checksum[16]; + public int MetadataIndexFileSize; + public int PrevPatch; + public int UnixTimestamp; + private fixed byte _reserved[6]; + + public Span Shifted4BytesMD5Checksum + { + get + { + fixed (byte* magicP = _shifted4BytesMD5Checksum) + { + return new Span(magicP, 16); + } + } + } + + public Span Reserved + { + get + { + fixed (byte* reservedP = _reserved) + { + return new Span(reservedP, 6); + } + } + } + } + + public class MetadataIndex + { + public required int MajorVersion { get; init; } + public required int MinorVersion { get; init; } + public required int PatchVersion { get; init; } + public required byte[] MD5Checksum { get; init; } + public required int MetadataIndexFileSize { get; init; } + public required int PrevPatch { get; init; } + public required DateTimeOffset Timestamp { get; init; } + public required byte[] Reserved { get; init; } + + public static void Parse(in MetadataIndexStruct indexStruct, + out MetadataIndex result) + { + byte[] md5Buffer = new byte[16]; + byte[] reservedBuffer = new byte[10]; + + indexStruct.Shifted4BytesMD5Checksum.CopyTo(md5Buffer); + indexStruct.Reserved.CopyTo(reservedBuffer); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Buffer); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(indexStruct.UnixTimestamp); + + result = new MetadataIndex + { + MajorVersion = indexStruct.MajorVersion, + MinorVersion = indexStruct.MinorVersion, + PatchVersion = indexStruct.PatchVersion, + MD5Checksum = md5Buffer, + MetadataIndexFileSize = indexStruct.MetadataIndexFileSize, + PrevPatch = indexStruct.PrevPatch, + Timestamp = timestamp, + Reserved = reservedBuffer + }; + } + + public static void Parse(in MetadataIndex6BytesPadStruct indexStruct, + out MetadataIndex result) + { + byte[] md5Buffer = new byte[16]; + byte[] reservedBuffer = new byte[6]; + + indexStruct.Shifted4BytesMD5Checksum.CopyTo(md5Buffer); + indexStruct.Reserved.CopyTo(reservedBuffer); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Buffer); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(indexStruct.UnixTimestamp); + + result = new MetadataIndex + { + MajorVersion = indexStruct.MajorVersion, + MinorVersion = indexStruct.MinorVersion, + PatchVersion = indexStruct.PatchVersion, + MD5Checksum = md5Buffer, + MetadataIndexFileSize = indexStruct.MetadataIndexFileSize, + PrevPatch = indexStruct.PrevPatch, + Timestamp = timestamp, + Reserved = reservedBuffer + }; + } + + public void ToStruct(out MetadataIndexStruct indexStruct) + { + indexStruct = new MetadataIndexStruct + { + MetadataIndexFileSize = MetadataIndexFileSize, + MajorVersion = MajorVersion, + MinorVersion = MinorVersion, + PatchVersion = PatchVersion, + PrevPatch = PrevPatch, + UnixTimestamp = (int)Timestamp.ToUnixTimeSeconds() + }; + + Span reservedSpan = indexStruct.Reserved; + Span md5Span = indexStruct.Shifted4BytesMD5Checksum; + + Reserved.CopyTo(reservedSpan); + MD5Checksum.CopyTo(md5Span); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Span); + } + + public void ToStruct6BytesPad(out MetadataIndex6BytesPadStruct indexStruct) + { + indexStruct = new MetadataIndex6BytesPadStruct + { + MetadataIndexFileSize = MetadataIndexFileSize, + MajorVersion = MajorVersion, + MinorVersion = MinorVersion, + PatchVersion = PatchVersion, + PrevPatch = PrevPatch, + UnixTimestamp = (int)Timestamp.ToUnixTimeSeconds() + }; + + Span reservedSpan = indexStruct.Reserved; + Span md5Span = indexStruct.Shifted4BytesMD5Checksum; + + Reserved.CopyTo(reservedSpan); + MD5Checksum.CopyTo(md5Span); + + StarRailBinaryDataExtension.ReverseReorderBy4X4HashData(md5Span); + } + } +} \ No newline at end of file diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs new file mode 100644 index 000000000..a7c8ba7ba --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryData.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable CommentTypo + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Generic/Abstract Star Rail Binary Data. Do not use this class directly. +/// +public abstract class StarRailBinaryData +{ + /// + /// Magic Signature of the binary data. This property must be overriden by the derivative instances. + /// + protected abstract ReadOnlySpan MagicSignature { get; } + + /// + /// The header of the parsed binary data. + /// + public StarRailBinaryDataHeaderStruct Header { get; protected set; } + + /// + /// Create a default instance of the members. + /// + /// Member type of . + /// A parser instance which is a member of . + public static T CreateDefault() where T : StarRailBinaryData, new() => new(); + + /// + /// Parse the binary data from the provided and populate the . + /// + /// The which provides the source of the data to be parsed. + /// + /// Whether to seek the data to the end, even though not all data being read.
+ /// Keep in mind that this operation will actually read all remaining data from the and discard it. + /// + /// Cancellation token for cancelling asynchronous operations. + /// + public virtual async Task ParseAsync(Stream dataStream, bool seekToEnd = false, CancellationToken token = default) + { + (Header, int offset) = await ReadHeaderCoreAsync(dataStream, token); + if (!Header.MagicSignature.SequenceEqual(MagicSignature)) + { + throw new InvalidOperationException($"Magic Signature doesn't match! Expecting: {Encoding.UTF8.GetString(MagicSignature)} but got: {Encoding.UTF8.GetString(Header.MagicSignature)} instead."); + } + + await ReadDataCoreAsync(offset, dataStream, token); + if (seekToEnd) // Read all remained data to null stream, even though not all data is being read. + { + await dataStream.CopyToAsync(Stream.Null, token); + } + } + + /// + /// Reads the header and perform assertion on the header. + /// + /// The which provides the source of the data to be parsed. + /// Cancellation token for cancelling asynchronous operations. + /// + /// This returns the and the current offset/position of the data stream after reading the header. + /// + protected virtual async ValueTask<(StarRailBinaryDataHeaderStruct Header, int Offset)> ReadHeaderCoreAsync( + Stream dataStream, + CancellationToken token = default) + { + return await dataStream + .ReadDataAssertAndSeekAsync(x => x.HeaderOrDataLength, + token); + } + + /// + /// Reads the body of the binary data. + /// + /// The current offset of the data stream. + /// The which provides the source of the data to be parsed. + /// Cancellation token for cancelling asynchronous operations. + /// + /// This returns the current offset/position of the data stream after reading the data. + /// + protected abstract ValueTask ReadDataCoreAsync(long currentOffset, Stream dataStream, CancellationToken token = default); +} + +/// +/// Generic/Abstract Star Rail Binary Data which contain the list of assets. Do not use this class directly. +/// +public abstract class StarRailBinaryData : StarRailBinaryData +{ + /// + /// List of the assets parsed from the binary data. + /// + public List DataList { get; protected set; } = []; + + protected StarRailBinaryData(ReadOnlySpan magicSignature, + short parentTypeFlag, + short typeVersionFlag, + int headerOrDataLength, + short subStructCount, + short subStructSize) + { + StarRailBinaryDataHeaderStruct header = default; + magicSignature.CopyTo(header.MagicSignature); + + header.ParentTypeFlag = parentTypeFlag; + header.TypeVersionFlag = typeVersionFlag; + header.HeaderOrDataLength = headerOrDataLength; + header.SubStructCount = subStructCount; + header.SubStructSize = subStructSize; + + Header = header; + } +} + +/// +/// Generic Star Rail Binary Data Header Structure.
+/// This header is globally used by SRMI, SRBM, SRAM and Signatureless metadata format. +///
+[StructLayout(LayoutKind.Sequential, Pack = 2)] +public unsafe struct StarRailBinaryDataHeaderStruct +{ + private fixed byte _magicSignature[4]; + public short ParentTypeFlag; + public short TypeVersionFlag; + public int HeaderOrDataLength; + public short SubStructCount; + public short SubStructSize; + + public Span MagicSignature + { + get + { + fixed (byte* magicP = _magicSignature) + { + return new Span(magicP, 4); + } + } + } + + public override string ToString() => $"{Encoding.UTF8.GetString(MagicSignature)} | ParentTypeFlag: {ParentTypeFlag} | TypeVersionFlag: {TypeVersionFlag} | SubStructCount: {SubStructCount} | SubStructSize: {SubStructSize} | HeaderReportedLength: {HeaderOrDataLength} bytes"; +} diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs new file mode 100644 index 000000000..bee925dd8 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataExtension.cs @@ -0,0 +1,197 @@ +using Hi3Helper.EncTool; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Diagnostics; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.X86; +using System.Threading; +using System.Threading.Tasks; +// ReSharper disable InconsistentNaming + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +internal static class StarRailBinaryDataExtension +{ + extension(Stream stream) + { + internal async ValueTask<(T Data, int Read)> ReadDataAssertAndSeekAsync( + Func minimalSizeAssertGet, + CancellationToken token) + where T : unmanaged + { + T result = await stream.ReadAsync(token).ConfigureAwait(false); + int sizeOfImplemented = Unsafe.SizeOf(); + int minimalSizeToAssert = minimalSizeAssertGet(result); + + // ASSERT: Make sure the struct size is versionable and at least, bigger than what we currently implement. + // (cuz we know you might change this in the future, HoYo :/) + if (sizeOfImplemented > minimalSizeToAssert) + { + throw new InvalidOperationException($"Game data use {minimalSizeToAssert} bytes of struct for {typeof(T).Name} while current implementation only supports struct with size >= {sizeOfImplemented}. Please contact @neon-nyan or ping us on our Official Discord to report this issue :D"); + } + + // ASSERT: Make sure to advance the stream position if the struct is bigger than what we currently implement. + int read = sizeOfImplemented; + int remained = minimalSizeToAssert - read; + read += await stream.SeekForwardAsync(remained, token); + + return (result, read); + } + + internal async ValueTask<(T Data, int Read)> ReadDataAssertWithPosAndSeekAsync( + long currentPos, + Func expectedPosAfterReadGet, + CancellationToken token) + where T : unmanaged + { + T result = await stream.ReadAsync(token).ConfigureAwait(false); + int sizeOfImplemented = Unsafe.SizeOf(); + long expectedPosAfterRead = expectedPosAfterReadGet(result); + + int read = sizeOfImplemented; + currentPos += read; + + // ASSERT: Make sure the current stream position is at least smaller or equal to what the game + // expect to be positioned at. + // (Again, as the same situation for ReadDataAssertAndSeekAsync, HoYo might change this in the future + // so we got to stop this from reading out of bounds :/) + if (currentPos > expectedPosAfterRead) + { + throw new InvalidOperationException($"Game data expect stream position to: {expectedPosAfterRead} bytes after reading struct for {typeof(T).Name} while our current data stream implementation stops at position: {currentPos + read} bytes. Please contact @neon-nyan or ping us on our Official Discord to report this issue :D"); + } + + // ASSERT: Make sure to advance the stream position if the struct we implement is smaller than the game implement. + int remained = (int)(expectedPosAfterRead - currentPos); + read += await stream.SeekForwardAsync(remained, token); + + return (result, read); + } + + internal async ValueTask ReadBufferAssertAsync(long currentPos, + Memory buffer, + CancellationToken token) + { + int read = await stream.ReadAtLeastAsync(buffer, + buffer.Length, + cancellationToken: token) + .ConfigureAwait(false); + // ASSERT: Make sure the amount of data being read is equal to buffer size + Debug.Assert(buffer.Length == read); + return read; + } + + internal async ValueTask WriteAsync( + T value, + CancellationToken token) + where T : unmanaged + { + int sizeOfImplemented = Unsafe.SizeOf(); + byte[] buffer = ArrayPool.Shared.Rent(sizeOfImplemented); + + try + { + // ASSERT: Try copy the value to buffer and assert whether the size is unequal + if (!TryCopyStructToBuffer(in value, buffer, out int writtenValueSize) || + writtenValueSize != sizeOfImplemented) + { + throw new DataMisalignedException($"Buffer size is insufficient than the size of the actual struct of {typeof(T).Name}"); + } + + await stream.WriteAsync(buffer.AsMemory(0, writtenValueSize), token) + .ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + private static unsafe bool TryCopyStructToBuffer(in T value, Span buffer, out int written) + where T : unmanaged + { + written = Unsafe.SizeOf(); + ReadOnlySpan valueSpan = new(Unsafe.AsPointer(in value), written); + return valueSpan.TryCopyTo(buffer); + } + + internal static unsafe void ReverseReorderBy4X4HashData(Span data) + { + if (data.Length != 16) + throw new ArgumentException("Data length must be 16 bytes.", nameof(data)); + + void* dataP = Unsafe.AsPointer(ref MemoryMarshal.GetReference(data)); + if (Sse3.IsSupported) + { + ReverseByInt32X4Sse3(dataP); + return; + } + + if (Sse2.IsSupported) + { + ReverseByInt32X4Sse2(dataP); + return; + } + + ReverseByInt32X4Scalar(dataP); + } + + private static readonly Vector128 ReverseByInt32X4ByteMask = + Vector128.Create((byte)3, 2, 1, 0, 7, 6, 5, 4, 11, 10, 9, 8, 15, 14, 13, 12); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void ReverseByInt32X4Sse3(void* int32X4VectorP) + => *(Vector128*)int32X4VectorP = Ssse3.Shuffle(*(Vector128*)int32X4VectorP, ReverseByInt32X4ByteMask); // Swap + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void ReverseByInt32X4Sse2(void* int32X4VectorP) + { + Vector128 vector = *(Vector128*)int32X4VectorP; + + // Masks + var mask00FF = Vector128.Create(0x00ff00ffu); + var maskFF00 = Vector128.Create(0xff00ff00u); + + // Swap bytes within 16-bit halves + Vector128 t1 = Sse2.ShiftLeftLogical(vector, 8); + Vector128 t2 = Sse2.ShiftRightLogical(vector, 8); + + vector = Sse2.Or(Sse2.And(t1, maskFF00), + Sse2.And(t2, mask00FF)); + + // Swap 16-bit halves + Vector128 result = Sse2.Or(Sse2.ShiftLeftLogical(vector, 16), + Sse2.ShiftRightLogical(vector, 16)); + *(Vector128*)int32X4VectorP = result; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static unsafe void ReverseByInt32X4Scalar(void* int32X4P) + { + uint* uintP = (uint*)int32X4P; + + uintP[0] = BinaryPrimitives.ReverseEndianness(uintP[0]); + uintP[1] = BinaryPrimitives.ReverseEndianness(uintP[1]); + uintP[2] = BinaryPrimitives.ReverseEndianness(uintP[2]); + uintP[3] = BinaryPrimitives.ReverseEndianness(uintP[3]); + } + + internal static unsafe bool IsStructEqual(T left, T right) + where T : unmanaged + { + int structSize = Marshal.SizeOf(); + Span leftSpan = new(Unsafe.AsPointer(ref left), structSize); + Span rightSpan = new(Unsafe.AsPointer(ref right), structSize); + + return leftSpan.SequenceEqual(rightSpan); + } +} + diff --git a/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs new file mode 100644 index 000000000..418298385 --- /dev/null +++ b/CollapseLauncher/Classes/RepairManagement/StarRailV2/Struct/StarRailBinaryDataWritable.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +#pragma warning disable IDE0290 // Shut the fuck up +#pragma warning disable IDE0130 +#nullable enable + +namespace CollapseLauncher.RepairManagement.StarRail.Struct; + +/// +/// Generic/Abstract Star Rail Writeable Binary Data. Do not use this class directly.
+/// This implementation inherit these subtypes:
+/// - +///
+internal abstract class StarRailBinaryDataWritable : StarRailBinaryData +{ + protected StarRailBinaryDataWritable(ReadOnlySpan magicSignature, + short parentTypeFlag, + short typeVersionFlag, + int headerLength, + short subStructCount, + short subStructSize) + : base(magicSignature, + parentTypeFlag, + typeVersionFlag, + headerLength, + subStructCount, + subStructSize) { } + + /// + /// Write the binary data to a specified file path. + /// + /// Target file path to be written to. + /// Cancellation token for cancelling asynchronous operations. + public virtual ValueTask WriteAsync(string filePath, CancellationToken token = default) + => WriteAsync(new FileInfo(filePath), token); + + /// + /// Write the binary data to a specified file. + /// + /// Target file to be written to. + /// Cancellation token for cancelling asynchronous operations. + public virtual async ValueTask WriteAsync(FileInfo fileInfo, CancellationToken token = default) + { + fileInfo.Directory?.Create(); + if (fileInfo.Exists) + { + fileInfo.IsReadOnly = false; + } + + await using FileStream dataStream = fileInfo.Create(); + await WriteAsync(dataStream, token); + } + + /// + /// Write the binary data to a Stream instance. + /// + /// Target Stream to be written to. + /// Cancellation token for cancelling asynchronous operations. + public virtual async ValueTask WriteAsync(Stream dataStream, CancellationToken token = default) + { + if (StarRailBinaryDataExtension.IsStructEqual(Header, default)) + { + throw new InvalidOperationException("Header is not initialized!"); + } + + await WriteHeaderCoreAsync(dataStream, token); + await WriteDataCoreAsync(dataStream, token); + } + + /// + /// Writes the header of the data to the target data stream. + /// + /// Target data stream which the header will be written to. + /// Cancellation token for cancelling asynchronous operations. + protected abstract ValueTask WriteHeaderCoreAsync(Stream dataStream, CancellationToken token = default); + + /// + /// Writes the data to the target data stream. + /// + /// Target data stream which the data will be written to. + /// Cancellation token for cancelling asynchronous operations. + protected abstract ValueTask WriteDataCoreAsync(Stream dataStream, CancellationToken token = default); +} diff --git a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs index 5b8f3490a..ab635f99a 100644 --- a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs +++ b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Check.cs @@ -58,7 +58,7 @@ private async Task Check(List assetIndex, CancellationToke private async ValueTask CheckGenericAssetType(FilePropertiesRemote asset, List targetAssetIndex, CancellationToken token) { // Update activity status - Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status6, StarRailRepairExtension.GetFileRelativePath(asset.N, GamePath)); + Status.ActivityStatus = string.Format(Locale.Lang._GameRepairPage.Status6, asset.N[GamePath.Length..].AsSpan().Trim("\\/").ToString()); // Increment current total count ProgressAllCountCurrent++; diff --git a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs index 8b28eb08d..ead6a20a7 100644 --- a/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs +++ b/CollapseLauncher/Classes/RepairManagement/Zenless/ZenlessRepair.Repair.cs @@ -107,7 +107,7 @@ private async Task RepairAssetTypeGeneric((FilePropertiesRemote AssetIndex, IAss // Set repair activity status string timeLeftString = string.Format(Locale.Lang!._Misc!.TimeRemainHMSFormat!, Progress.ProgressAllTimeLeft); UpdateRepairStatus( - string.Format(Locale.Lang._GameRepairPage.Status8, Path.GetFileName(asset.AssetIndex.N)), + string.Format(IsCacheUpdateMode ? Locale.Lang!._Misc!.Downloading + ": {0}" : Locale.Lang._GameRepairPage.Status8, Path.GetFileName(asset.AssetIndex.N)), string.Format(Locale.Lang._GameRepairPage.PerProgressSubtitle2, ConverterTool.SummarizeSizeSimple(ProgressAllSizeCurrent), ConverterTool.SummarizeSizeSimple(ProgressAllSizeTotal)) + $" | {timeLeftString}", true); diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs index 87a60ea94..9ace60fee 100644 --- a/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs +++ b/CollapseLauncher/XAMLs/MainApp/Pages/Dialogs/SimpleDialogs.cs @@ -118,25 +118,17 @@ public static Task Dialog_InsufficientWritePermission(strin Lang._Misc.Okay); } - public static async Task<(Dictionary?, string?)> Dialog_ChooseAudioLanguageChoice( + public static async Task<(HashSet?, string?)> Dialog_ChooseAudioLanguageChoice( Dictionary langDict, string defaultLocaleCode = "ja-jp") { - bool[] choices = new bool[langDict.Count]; if (!langDict.ContainsKey(defaultLocaleCode)) { throw new KeyNotFoundException($"Default locale code: {defaultLocaleCode} is not found within langDict argument"); } - List localeCodeList = langDict.Keys.ToList(); - List langList = langDict.Values.ToList(); - // Naive approach to lookup default index value - string? refLocaleCode = - localeCodeList.FirstOrDefault(x => x.Equals(defaultLocaleCode, StringComparison.OrdinalIgnoreCase)); - int defaultIndex = localeCodeList.IndexOf(refLocaleCode ?? ""); - int choiceAsDefault = defaultIndex; - StackPanel parentPanel = CollapseUIExt.CreateStackPanel(); + StackPanel parentPanel = CollapseUIExt.CreateStackPanel(); parentPanel.AddElementToStackPanel(new TextBlock { @@ -153,7 +145,7 @@ public static Task Dialog_InsufficientWritePermission(strin defaultChoiceRadioButton.HorizontalContentAlignment = HorizontalAlignment.Stretch; parentPanel.AddElementToStackPanel(defaultChoiceRadioButton); - ContentDialogCollapse dialog = new ContentDialogCollapse(ContentDialogTheme.Warning) + ContentDialogCollapse dialog = new(ContentDialogTheme.Warning) { Title = Lang._Dialogs.ChooseAudioLangTitle, Content = parentPanel, @@ -165,8 +157,10 @@ public static Task Dialog_InsufficientWritePermission(strin XamlRoot = SharedXamlRoot }; + List checkboxes = []; + InputCursor inputCursor = InputSystemCursor.Create(InputSystemCursorShape.Hand); - for (int i = 0; i < langList.Count; i++) + foreach ((string localeId, string language) in langDict) { Grid checkBoxGrid = CollapseUIExt.CreateGrid() .WithColumns(new GridLength(1, GridUnitType.Star), @@ -174,27 +168,28 @@ public static Task Dialog_InsufficientWritePermission(strin .WithHorizontalAlignment(HorizontalAlignment.Stretch) .WithMargin(0, 0, 0, 8); - CheckBox checkBox = new CheckBox + CheckBox checkBox = new() { Content = checkBoxGrid, HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalContentAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center + VerticalContentAlignment = VerticalAlignment.Center, + Tag = localeId }; + checkboxes.Add(checkBox); - TextBlock useAsDefaultText = new TextBlock + TextBlock useAsDefaultText = new() { - Text = Lang._Misc.UseAsDefault, - HorizontalAlignment = HorizontalAlignment.Right, + Text = Lang._Misc.UseAsDefault, + HorizontalAlignment = HorizontalAlignment.Right, HorizontalTextAlignment = TextAlignment.Right, - VerticalAlignment = VerticalAlignment.Top, - Opacity = 0.5, - Name = "UseAsDefaultLabel" + VerticalAlignment = VerticalAlignment.Top, + Opacity = 0.5, + Name = "UseAsDefaultLabel" }; useAsDefaultText.EnableSingleImplicitAnimation(VisualPropertyType.Opacity); - Grid iconTextGrid = CollapseUIExt.CreateIconTextGrid( - langList[i], + Grid iconTextGrid = CollapseUIExt.CreateIconTextGrid(language, "\uf1ab", iconSize: 14, textSize: 14, @@ -204,167 +199,161 @@ public static Task Dialog_InsufficientWritePermission(strin iconTextGrid.EnableSingleImplicitAnimation(VisualPropertyType.Opacity); iconTextGrid.VerticalAlignment = VerticalAlignment.Center; - checkBoxGrid.AddElementToGridColumn(iconTextGrid, 0); + checkBoxGrid.AddElementToGridColumn(iconTextGrid, 0); checkBoxGrid.AddElementToGridColumn(useAsDefaultText, 1); RadioButton radioButton = new RadioButton - { - Content = checkBox, - Style = - CollapseUIExt - .GetApplicationResource< - Style>("AudioLanguageSelectionRadioButtonStyle"), - Background = - CollapseUIExt - .GetApplicationResource< - Brush>("AudioLanguageSelectionRadioButtonBrush") - } - .WithHorizontalAlignment(HorizontalAlignment.Stretch) - .WithVerticalAlignment(VerticalAlignment.Center) - .WithCursor(inputCursor); + { + Content = checkBox, + Style = + CollapseUIExt + .GetApplicationResource< + Style>("AudioLanguageSelectionRadioButtonStyle"), + Background = + CollapseUIExt + .GetApplicationResource< + Brush>("AudioLanguageSelectionRadioButtonBrush"), + Tag = localeId + } + .WithHorizontalAlignment(HorizontalAlignment.Stretch) + .WithVerticalAlignment(VerticalAlignment.Center) + .WithCursor(inputCursor); defaultChoiceRadioButton.Items.Add(radioButton); - radioButton.Tag = i; - checkBox.Tag = i; + // Check the radio button and check box if the localeId is equal to default + if (localeId.Equals(defaultLocaleCode, StringComparison.OrdinalIgnoreCase)) + { + checkBox.IsChecked = true; + defaultChoiceRadioButton.SelectedItem = radioButton; + + iconTextGrid.Opacity = 1; + } radioButton.Checked += (sender, _) => - { - RadioButton? radioButtonLocal = sender as RadioButton; - choiceAsDefault = (int)(radioButtonLocal?.Tag ?? 0); - checkBox.IsChecked = true; + { + RadioButton? radioButtonLocal = sender as RadioButton; + checkBox.IsChecked = true; - if (radioButtonLocal?.FindDescendant("UseAsDefaultLabel") is TextBlock - textBlockLocal) - { - textBlockLocal.Opacity = 1; - } - }; + if (radioButtonLocal?.FindDescendant("UseAsDefaultLabel") is TextBlock + textBlockLocal) + { + textBlockLocal.Opacity = 1; + } + }; radioButton.Unchecked += (sender, _) => - { - RadioButton? radioButtonLocal = sender as RadioButton; - if (radioButtonLocal?.FindDescendant("UseAsDefaultLabel") is TextBlock - textBlockLocal) - { - textBlockLocal.Opacity = 0.5; - } - }; + { + RadioButton? radioButtonLocal = sender as RadioButton; + if (radioButtonLocal?.FindDescendant("UseAsDefaultLabel") is TextBlock + textBlockLocal) + { + textBlockLocal.Opacity = 0.5; + } + }; radioButton.PointerEntered += (sender, _) => - { - RadioButton? radioButtonLocal = sender as RadioButton; - TextBlock? textBlockLocal = - radioButtonLocal - ?.FindDescendant("UseAsDefaultLabel") as TextBlock; - - CheckBox? thisCheckBox = radioButtonLocal?.Content as CheckBox; - Grid? thisIconText = thisCheckBox?.FindDescendant("IconText") as Grid; - if (textBlockLocal is null || thisIconText is null || - (thisCheckBox?.IsChecked ?? false)) - { - return; - } + { + RadioButton? radioButtonLocal = sender as RadioButton; + TextBlock? textBlockLocal = + radioButtonLocal + ?.FindDescendant("UseAsDefaultLabel") as TextBlock; + + CheckBox? thisCheckBox = radioButtonLocal?.Content as CheckBox; + Grid? thisIconText = thisCheckBox?.FindDescendant("IconText") as Grid; + if (textBlockLocal is null || thisIconText is null || + (thisCheckBox?.IsChecked ?? false)) + { + return; + } - textBlockLocal.Opacity = 1; - thisIconText.Opacity = 1; - }; + textBlockLocal.Opacity = 1; + thisIconText.Opacity = 1; + }; radioButton.PointerExited += (sender, _) => - { - RadioButton? radioButtonLocal = sender as RadioButton; - TextBlock? textBlockLocal = - radioButtonLocal?.FindDescendant("UseAsDefaultLabel") as TextBlock; - - CheckBox? thisCheckBox = radioButtonLocal?.Content as CheckBox; - Grid? thisIconText = thisCheckBox?.FindDescendant("IconText") as Grid; - if (textBlockLocal is null || thisIconText is null || - (thisCheckBox?.IsChecked ?? false)) - { - return; - } + { + RadioButton? radioButtonLocal = sender as RadioButton; + TextBlock? textBlockLocal = + radioButtonLocal?.FindDescendant("UseAsDefaultLabel") as TextBlock; + + CheckBox? thisCheckBox = radioButtonLocal?.Content as CheckBox; + Grid? thisIconText = thisCheckBox?.FindDescendant("IconText") as Grid; + if (textBlockLocal is null || thisIconText is null || + (thisCheckBox?.IsChecked ?? false)) + { + return; + } - textBlockLocal.Opacity = 0.5; - thisIconText.Opacity = 0.5; - }; + textBlockLocal.Opacity = 0.5; + thisIconText.Opacity = 0.5; + }; - if (i == defaultIndex) + checkBox.Checked += (sender, _) => { - choices[i] = true; - checkBox.IsChecked = true; - defaultChoiceRadioButton.SelectedIndex = i; - iconTextGrid.Opacity = 1; - } + CheckBox thisCheckBox = (CheckBox)sender; + radioButton.IsEnabled = true; - checkBox.Checked += (sender, _) => - { - CheckBox? thisCheckBox = sender as CheckBox; - int thisIndex = (int)(thisCheckBox?.Tag ?? 0); - choices[thisIndex] = true; - radioButton.IsEnabled = true; + dialog.IsPrimaryButtonEnabled = IsHasAnyChoices(); + defaultChoiceRadioButton.SelectedItem ??= radioButton; - bool isHasAnyChoices = choices.Any(x => x); - dialog.IsPrimaryButtonEnabled = isHasAnyChoices; - if (defaultChoiceRadioButton.SelectedIndex < 0) - { - defaultChoiceRadioButton.SelectedIndex = thisIndex; - } + if (thisCheckBox?.FindDescendant("IconText") is Grid thisIconText) + { + thisIconText.Opacity = 1; + } + }; - if (thisCheckBox?.FindDescendant("IconText") is Grid thisIconText) - { - thisIconText.Opacity = 1; - } - }; checkBox.Unchecked += (sender, _) => - { - CheckBox? thisCheckBox = sender as CheckBox; - int thisIndex = (int)(thisCheckBox?.Tag ?? 0); - choices[thisIndex] = false; - radioButton.IsChecked = false; - - if (thisCheckBox?.FindDescendant("IconText") is Grid thisIconText) - { - thisIconText.Opacity = 0.5; - } + { + CheckBox thisCheckBox = (CheckBox)sender; + radioButton.IsChecked = false; - bool isHasAnyChoices = choices.Any(x => x); - dialog.IsPrimaryButtonEnabled = isHasAnyChoices; + if (thisCheckBox?.FindDescendant("IconText") is Grid thisIconText) + { + thisIconText.Opacity = 0.5; + } - // TODO: Find a better way rather than this SPAGHEETTTTT CODE - if (defaultChoiceRadioButton.SelectedIndex >= 0 || !isHasAnyChoices) - { - return; - } + bool isHasAnyChoices = IsHasAnyChoices(); + dialog.IsPrimaryButtonEnabled = isHasAnyChoices; - for (int index = 0; index < choices.Length; index++) - { - if (!choices[index]) - { - continue; - } + if (defaultChoiceRadioButton.SelectedItem != null || !isHasAnyChoices) + { + return; + } - defaultChoiceRadioButton.SelectedIndex = index; - break; - } - }; + for (int index = 0; index < checkboxes.Count; index++) + { + CheckBox otherCheckbox = checkboxes[index]; + if (!(otherCheckbox.IsChecked ?? false)) + { + continue; + } + + defaultChoiceRadioButton.SelectedIndex = index; + break; + } + }; } ContentDialogResult dialogResult = await dialog.ShowAsync(); - if (dialogResult == ContentDialogResult.None) + if (dialogResult == ContentDialogResult.None || + defaultChoiceRadioButton.SelectedIndex < 0 || + defaultChoiceRadioButton.SelectedItem as RadioButton is not { Tag: string selectedDefaultVoLocaleId }) { return (null, null); } - Dictionary returnDictionary = new(); - for (int i = 0; i < choices.Length; i++) + HashSet selectedVoLocaleIds = new(StringComparer.OrdinalIgnoreCase); + foreach (string selectedVoLocateId in checkboxes.Where(x => x.IsChecked ?? false) + .Select(x => x.Tag) + .OfType()) { - if (choices[i]) - { - returnDictionary.Add(localeCodeList[i], langList[i]); - } + selectedVoLocaleIds.Add(selectedVoLocateId); } - return (returnDictionary, localeCodeList[choiceAsDefault]); + return (selectedVoLocaleIds, selectedDefaultVoLocaleId); + + bool IsHasAnyChoices() => checkboxes.Any(x => x.IsChecked ?? false); } public static async Task<(List?, int)> Dialog_ChooseAudioLanguageChoice( diff --git a/Hi3Helper.EncTool b/Hi3Helper.EncTool index 15f443056..088db0f7d 160000 --- a/Hi3Helper.EncTool +++ b/Hi3Helper.EncTool @@ -1 +1 @@ -Subproject commit 15f443056e3019118ed37d653c6c06c13b368158 +Subproject commit 088db0f7dbd6bc45c2e7bf86afaf1138ce8a2fd9 diff --git a/Hi3Helper.Sophon b/Hi3Helper.Sophon index 84c5119f5..080d077fc 160000 --- a/Hi3Helper.Sophon +++ b/Hi3Helper.Sophon @@ -1 +1 @@ -Subproject commit 84c5119f51af3224ef41e5e08067f8c6eded3ef1 +Subproject commit 080d077fcef87d5c591aca5c7bacef0198c4e8d1